initial 2
Some checks failed
CI / test (push) Has been cancelled

This commit is contained in:
2026-06-26 09:26:50 +01:00
parent 194330fb47
commit 3b37368e7d
213 changed files with 14688 additions and 1 deletions

View File

@@ -0,0 +1,51 @@
import { describe, it, expect } from "vitest";
import {
normalizeExplorationPayload,
normalizeTeacherPayload,
safeParseAiJson,
} from "./ai-normalize";
describe("safeParseAiJson", () => {
it("returns null for invalid JSON", () => {
expect(safeParseAiJson("not json")).toBeNull();
});
});
describe("normalizeTeacherPayload", () => {
it("coerces string quiz answers and alternate field names", () => {
const normalized = normalizeTeacherPayload({
flashcards: [{ front: "Q", back: "A" }],
quiz: [{ question: "Pick one", options: ["a", "b"], answer: "1" }],
homework: "Read for 20 minutes",
});
expect(normalized).toMatchObject({
assignment: "Read for 20 minutes",
quiz: [{ answer: 1 }],
});
});
});
describe("normalizeExplorationPayload", () => {
it("maps description to hook", () => {
const normalized = normalizeExplorationPayload({
explorations: [
{
title: "Read poetry",
description: "Spend time with a poem",
category: "reading",
duration: 15,
},
],
});
expect(normalized).toEqual({
explorations: [
{
title: "Read poetry",
hook: "Spend time with a poem",
category: "reading",
minutes: 15,
},
],
});
});
});

View File

@@ -0,0 +1,124 @@
import { parseAiJson } from "./parse-json";
export function safeParseAiJson(raw: string): unknown | null {
try {
return parseAiJson(raw);
} catch {
return null;
}
}
export function normalizeTeacherPayload(data: unknown): unknown {
if (!data || typeof data !== "object") return data;
const o = data as Record<string, unknown>;
const flashcards = Array.isArray(o.flashcards)
? o.flashcards
.map((c) => {
if (!c || typeof c !== "object") return null;
const card = c as Record<string, unknown>;
const front = String(card.front ?? "").trim();
const back = String(card.back ?? "").trim();
if (!front && !back) return null;
return { front, back };
})
.filter(Boolean)
: [];
const quiz = Array.isArray(o.quiz)
? o.quiz
.map((q) => {
if (!q || typeof q !== "object") return null;
const item = q as Record<string, unknown>;
const options = Array.isArray(item.options)
? item.options.map((opt) => String(opt))
: [];
if (options.length === 0) return null;
let answer = Number(item.answer ?? 0);
if (!Number.isFinite(answer)) answer = 0;
if (answer < 0 || answer >= options.length) answer = 0;
return {
question: String(item.question ?? "Question"),
options,
answer,
};
})
.filter(Boolean)
: [];
return {
title: o.title != null ? String(o.title) : undefined,
introduction: o.introduction != null ? String(o.introduction) : undefined,
objectives: Array.isArray(o.objectives)
? o.objectives.map(String).filter(Boolean)
: undefined,
readingSteps: Array.isArray(o.readingSteps)
? o.readingSteps.map(String).filter(Boolean)
: undefined,
reflectionPrompt:
o.reflectionPrompt != null ? String(o.reflectionPrompt) : undefined,
flashcards,
quiz,
assignment: String(o.assignment ?? o.homework ?? "").trim(),
};
}
export function normalizeExplorationPayload(data: unknown): unknown {
if (!data || typeof data !== "object") return data;
const o = data as Record<string, unknown>;
const items = Array.isArray(o.explorations)
? o.explorations
: Array.isArray(o.quests)
? o.quests
: [];
return {
explorations: items
.map((item) => {
if (!item || typeof item !== "object") return null;
const e = item as Record<string, unknown>;
const title = String(e.title ?? "").trim();
if (!title) return null;
const minutes = Number(e.minutes ?? e.duration ?? 20);
return {
title,
hook: String(e.hook ?? e.description ?? e.reason ?? title).trim(),
category: String(e.category ?? "general").trim(),
minutes: Number.isFinite(minutes) && minutes > 0 ? minutes : 20,
};
})
.filter(Boolean),
};
}
export function debugAiLog(
location: string,
message: string,
data: Record<string, unknown>
) {
console.error(`[adventureos-ai] ${location}: ${message}`, JSON.stringify(data));
// #region agent log
fetch("http://127.0.0.1:7257/ingest/abfa22e4-3d86-4176-9679-7c59b1f0489f", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Debug-Session-Id": "04e12c",
},
body: JSON.stringify({
sessionId: "04e12c",
location,
message,
data,
timestamp: Date.now(),
}),
}).catch(() => {});
// #endregion
}
export function isAiTimeoutError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
if (error.name === "TimeoutError") return true;
if (error.message.toLowerCase().includes("timeout")) return true;
return (error as Error & { code?: number }).code === 23;
}

View File

@@ -0,0 +1,38 @@
import { describe, it, expect } from "vitest";
import {
isModelAvailable,
modelNamesMatch,
resolveModelName,
} from "./model-resolve";
describe("resolveModelName", () => {
const available = ["llama3.2:latest", "mistral:7b"];
it("returns exact match", () => {
expect(resolveModelName("mistral:7b", available)).toBe("mistral:7b");
});
it("returns partial tag match", () => {
expect(resolveModelName("llama3.2:3b", available)).toBe("llama3.2:latest");
});
it("falls back to first model", () => {
expect(resolveModelName("unknown:model", available)).toBe("llama3.2:latest");
});
it("returns null when no models", () => {
expect(resolveModelName("llama3.2:3b", [])).toBeNull();
});
});
describe("isModelAvailable", () => {
it("detects partial matches", () => {
expect(isModelAvailable("llama3.2:3b", ["llama3.2:latest"])).toBe(true);
});
});
describe("modelNamesMatch", () => {
it("matches same base with requested tag", () => {
expect(modelNamesMatch("llama3.2:3b", "llama3.2:latest")).toBe(true);
});
});

View File

@@ -0,0 +1,26 @@
/** Match Ollama model names with or without tags (e.g. llama3.2:3b vs llama3.2:latest). */
export function modelNamesMatch(requested: string, available: string): boolean {
if (requested === available) return true;
const reqBase = requested.split(":")[0];
const availBase = available.split(":")[0];
return reqBase === availBase && requested.includes(":");
}
export function isModelAvailable(requested: string, availableModels: string[]): boolean {
return availableModels.some((name) => modelNamesMatch(requested, name));
}
export function resolveModelName(
requested: string,
availableModels: string[]
): string | null {
if (availableModels.length === 0) return null;
const exact = availableModels.find((name) => name === requested);
if (exact) return exact;
const partial = availableModels.find((name) => modelNamesMatch(requested, name));
if (partial) return partial;
return availableModels[0] ?? null;
}

View File

@@ -0,0 +1,12 @@
import { describe, it, expect } from "vitest";
import { parseAiJson } from "./parse-json";
describe("parseAiJson", () => {
it("parses plain JSON", () => {
expect(parseAiJson('{"a":1}')).toEqual({ a: 1 });
});
it("strips markdown fences", () => {
expect(parseAiJson('```json\n{"a":1}\n```')).toEqual({ a: 1 });
});
});

View File

@@ -0,0 +1,8 @@
export function parseAiJson(raw: string): unknown {
let text = raw.trim();
const fenced = text.match(/^```(?:json)?\s*([\s\S]*?)```$/i);
if (fenced) {
text = fenced[1].trim();
}
return JSON.parse(text);
}

View File

@@ -0,0 +1,145 @@
export const PROMPT_DEFAULTS: Record<
string,
{ name: string; description: string; category: "template" | "system_prompt"; body: string }
> = {
quest_generation: {
name: "Quest Generation",
description: "Daily micro-quest suggestions",
category: "template",
body: `Based on this week's activity, suggest up to 3 small micro-quests for today.
Context: {{context}}
Return JSON: { "quests": [{ "title", "reason", "xp_hint", "category" }] }`,
},
exploration_generation: {
name: "Exploration Generation",
description: "Weekly curiosity quests",
category: "template",
body: `Suggest 3-5 gentle personal growth curiosity quests for this week in AdventureOS.
Each quest should feel optional, meaningful, and achievable in 15-45 minutes — reading, learning, exercise, reflection, spiritual practice, or small technical exploration.
Avoid duplicating active, completed, or recently denied quests listed in the context.
Context (scores, recent activity, existing/denied quests): {{context}}
Return JSON only: { "explorations": [{ "title", "hook", "category", "minutes" }] }`,
},
weekly_review: {
name: "Weekly Review",
description: "Mentor weekly review letter",
category: "template",
body: `Write a weekly mentor review. Identify gentle patterns, offer encouragement, suggest one focus.
Context: {{context}}
Return JSON: { "patterns": ["..."], "encouragement": "...", "focus_suggestion": "...", "letter": "150-250 word warm letter in second person" }`,
},
homework_generation: {
name: "Homework Generation",
description: "Teacher homework assignments",
category: "template",
body: `Create topic-specific learning materials about: {{topic}}
Include a short introduction, learning objectives, reading/research steps, homework, reflection prompt, flashcards, and a quiz.
Return JSON only:
{
"title": "...",
"introduction": "...",
"objectives": ["..."],
"readingSteps": ["..."],
"reflectionPrompt": "...",
"flashcards": [{ "front", "back" }],
"quiz": [{ "question", "options": ["a","b","c","d"], "answer": 0 }],
"assignment": "..."
}
Use answer as a 0-based index number.`,
},
reflection_prompt: {
name: "Reflection Prompt",
description: "Daily reflection assistance",
category: "template",
body: `Help {{user_name}} reflect on their day.
Week summary: {{week_summary}}
Prayer summary: {{prayer_summary}}
Offer 2-3 gentle reflection questions. Return JSON: { "questions": ["..."] }`,
},
mentor_summary: {
name: "Mentor Summary",
description: "Short mentor summary",
category: "template",
body: `Summarize {{user_name}} warmly in 150-300 words based on these verified memories.
Memories: {{context}}
Write in second person. Do not invent facts not in the memories. Plain text only.`,
},
learning_assignment: {
name: "Learning Assignment",
description: "Structured learning task",
category: "template",
body: `Create a gentle 20-minute learning assignment about {{learning_topics}}.
Return JSON: { "assignment": "...", "steps": ["..."] }`,
},
reading_recommendation: {
name: "Reading Recommendation",
description: "Book or article suggestions",
category: "template",
body: `Based on reading progress: {{reading_progress}}
Suggest 1-2 reading directions. Return JSON: { "recommendations": [{ "title", "reason" }] }`,
},
system_core: {
name: "Core System Instructions",
description: "Base persona for all AI roles",
category: "system_prompt",
body: `You are the Guide in AdventureOS — a wise, warm mentor helping someone build consistency over years.
Never use guilt, shame, hustle culture, or comparison language.
Be concise, encouraging, and practical. Output valid JSON only.`,
},
system_mentor: {
name: "Mentor Instructions",
description: "Weekly review mentor persona",
category: "system_prompt",
body: `You write as a patient mentor who has walked many roads. Speak in second person. Celebrate small wins.`,
},
system_quest: {
name: "Quest Generation Instructions",
description: "Quest giver persona",
category: "system_prompt",
body: `Suggest tiny, achievable quests. Never overwhelming. Each quest should feel optional and inviting.`,
},
system_homework: {
name: "Homework Instructions",
description: "Teacher homework persona",
category: "system_prompt",
body: `Create clear, engaging learning materials. Keep quizzes fair and assignments under 30 minutes.`,
},
system_weekly_review: {
name: "Weekly Review Instructions",
description: "Review generation persona",
category: "system_prompt",
body: `Identify patterns without judgment. The letter should feel like a handwritten note from a trusted friend.`,
},
system_teacher: {
name: "Teacher Instructions",
description: "Teacher content persona",
category: "system_prompt",
body: `Explain concepts simply. Use examples from everyday life. Encourage curiosity over perfection.`,
},
};
export const PLACEHOLDER_DOCS = [
{ key: "user_name", description: "User display name" },
{ key: "context", description: "JSON activity context" },
{ key: "topic", description: "Learning topic" },
{ key: "week_summary", description: "Summary of the week" },
{ key: "reading_progress", description: "Reading stats" },
{ key: "exercise_summary", description: "Exercise activity" },
{ key: "prayer_summary", description: "Prayer/litany progress" },
{ key: "learning_topics", description: "Current learning topics" },
{ key: "chronicle_context", description: "Long-term journey context" },
];
export const PERSONALITY_MODIFIERS: Record<string, string> = {
supportive_mentor: "Be warm, encouraging, and patient. Focus on progress over perfection.",
wise_teacher: "Be thoughtful and measured. Share wisdom through gentle observations.",
quiet_observer: "Be brief and understated. Notice patterns without much commentary.",
academic_tutor: "Be precise and educational. Explain the why behind suggestions.",
friendly_coach: "Be energetic but never pushy. Use friendly, conversational language.",
};
export const VERBOSITY_MODIFIERS: Record<string, string> = {
minimal: "Keep responses very brief.",
balanced: "Be concise but complete.",
detailed: "Provide thorough, detailed responses.",
};

View File

@@ -0,0 +1,25 @@
import { describe, it, expect } from "vitest";
import { renderTemplate, findMissingPlaceholders } from "./render";
describe("renderTemplate", () => {
it("substitutes placeholders", () => {
const result = renderTemplate("Hello {{user_name}}, context: {{context}}", {
user_name: "Traveler",
context: { level: 5 },
});
expect(result).toContain("Hello Traveler");
expect(result).toContain('"level":5');
});
it("leaves missing placeholders intact", () => {
const result = renderTemplate("Hello {{missing}}", {});
expect(result).toBe("Hello {{missing}}");
});
});
describe("findMissingPlaceholders", () => {
it("finds missing keys", () => {
const missing = findMissingPlaceholders("{{user_name}} {{topic}}", { user_name: "A" });
expect(missing).toEqual(["topic"]);
});
});

View File

@@ -0,0 +1,41 @@
const PLACEHOLDER_RE = /\{\{(\w+)\}\}/g;
export function renderTemplate(
body: string,
data: Record<string, string | number | unknown>
): string {
return body.replace(PLACEHOLDER_RE, (_, key: string) => {
const val = data[key];
if (val === undefined || val === null) return `{{${key}}}`;
if (typeof val === "object") return JSON.stringify(val);
return String(val);
});
}
export function findMissingPlaceholders(
body: string,
data: Record<string, unknown>
): string[] {
const missing: string[] = [];
let match;
const re = /\{\{(\w+)\}\}/g;
while ((match = re.exec(body)) !== null) {
const key = match[1];
if (data[key] === undefined && !missing.includes(key)) {
missing.push(key);
}
}
return missing;
}
export const SAMPLE_PREVIEW_DATA: Record<string, unknown> = {
user_name: "Traveler",
context: { level: 5, recentReading: 42, exerciseDays: 3 },
topic: "Stoic philosophy",
week_summary: "You read 35 pages, prayed 4 days, and exercised twice.",
reading_progress: "Currently reading page 120 of 300",
exercise_summary: "2 exercise sessions this week",
prayer_summary: "4 of 5 morning prayers completed",
learning_topics: "Roman history, Latin roots",
chronicle_context: "Chapter 3: The Long Road — 45 days on the journey",
};

View File

@@ -0,0 +1,38 @@
import type { AIProvider, AIProviderType } from "./types";
import { createOllamaProvider } from "./providers/ollama";
import {
createOpenAICompatibleProvider,
createLlamaCppProvider,
} from "./providers/openai-compatible";
const providers: Record<AIProviderType, () => AIProvider> = {
ollama: createOllamaProvider,
openai_compatible: () => createOpenAICompatibleProvider("openai_compatible"),
llamacpp: createLlamaCppProvider,
};
export function getProvider(type: AIProviderType): AIProvider {
const factory = providers[type];
if (!factory) throw new Error(`Unknown provider: ${type}`);
return factory();
}
export function listProviderTypes(): {
type: AIProviderType;
name: string;
envVars: string[];
}[] {
return [
{ type: "ollama", name: "Ollama", envVars: [] },
{
type: "openai_compatible",
name: "OpenAI Compatible",
envVars: ["OPENAI_API_KEY"],
},
{
type: "llamacpp",
name: "llama.cpp Server",
envVars: ["LLAMACPP_API_KEY"],
},
];
}

View File

@@ -0,0 +1,147 @@
import type {
AIProvider,
AIProviderConfig,
AIHealthResult,
GenerateTextRequest,
GenerateTextResponse,
} from "../types";
import { isModelAvailable, resolveModelName } from "../model-resolve";
import { redactError, redactUrl } from "../types";
export function createOllamaProvider(): AIProvider {
return {
type: "ollama",
async healthCheck(config: AIProviderConfig): Promise<AIHealthResult> {
const start = Date.now();
try {
const res = await fetch(`${config.baseUrl}/api/tags`, {
signal: AbortSignal.timeout(5000),
});
const latencyMs = Date.now() - start;
if (!res.ok) {
return {
status: "offline",
provider: "ollama",
model: config.model,
latencyMs,
lastSuccessAt: null,
lastFailureAt: new Date().toISOString(),
lastError: redactError(`HTTP ${res.status}`),
baseUrlSafe: redactUrl(config.baseUrl),
contextLength: null,
memoryUsageMb: null,
modelsAvailable: [],
};
}
const data = await res.json();
const models = (data.models ?? []).map(
(m: { name: string }) => m.name
);
const modelReady = isModelAvailable(config.model, models);
const resolvedModel = resolveModelName(config.model, models);
return {
status:
models.length === 0
? "degraded"
: modelReady || resolvedModel
? "online"
: "degraded",
provider: "ollama",
model: resolvedModel ?? config.model,
latencyMs,
lastSuccessAt: new Date().toISOString(),
lastFailureAt: modelReady ? null : new Date().toISOString(),
lastError: modelReady
? null
: models.length === 0
? "No models installed in Ollama"
: `Model "${config.model}" not found. Available: ${models.slice(0, 5).join(", ")}`,
baseUrlSafe: redactUrl(config.baseUrl),
contextLength: null,
memoryUsageMb: null,
modelsAvailable: models,
};
} catch (e) {
return {
status: "offline",
provider: "ollama",
model: config.model,
latencyMs: Date.now() - start,
lastSuccessAt: null,
lastFailureAt: new Date().toISOString(),
lastError: redactError(e instanceof Error ? e.message : "Connection failed"),
baseUrlSafe: redactUrl(config.baseUrl),
contextLength: null,
memoryUsageMb: null,
modelsAvailable: [],
};
}
},
async listModels(config: AIProviderConfig) {
const res = await fetch(`${config.baseUrl}/api/tags`, {
signal: AbortSignal.timeout(5000),
});
if (!res.ok) return [];
const data = await res.json();
return (data.models ?? []).map((m: { name: string }) => m.name);
},
async generateText(
config: AIProviderConfig,
req: GenerateTextRequest
): Promise<GenerateTextResponse> {
const start = Date.now();
const requested = req.model || config.model;
let model = requested;
const tagsRes = await fetch(`${config.baseUrl}/api/tags`, {
signal: AbortSignal.timeout(5000),
});
if (tagsRes.ok) {
const tagsData = await tagsRes.json();
const models = (tagsData.models ?? []).map(
(m: { name: string }) => m.name as string
);
const resolved = resolveModelName(requested, models);
if (resolved) {
model = resolved;
} else if (models.length === 0) {
throw new Error(`Ollama error: no models installed`);
}
if (model !== requested) {
console.error(
`[adventureos-ai] ollama: resolved model "${requested}" -> "${model}"`
);
}
}
const res = await fetch(`${config.baseUrl}/api/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model,
prompt: req.prompt,
system: req.system,
stream: false,
format: req.format === "json" ? "json" : undefined,
options: {
temperature: req.temperature ?? config.temperature,
num_predict: req.maxTokens ?? config.maxTokens,
},
}),
signal: AbortSignal.timeout(req.timeoutMs ?? config.timeoutMs),
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(
`Ollama error: HTTP ${res.status}${body ? `${body.slice(0, 200)}` : ""}`
);
}
const data = await res.json();
return {
text: data.response ?? "",
model,
latencyMs: Date.now() - start,
};
},
};
}

View File

@@ -0,0 +1,127 @@
import type {
AIProvider,
AIProviderConfig,
AIHealthResult,
GenerateTextRequest,
GenerateTextResponse,
} from "../types";
import { redactError, redactUrl } from "../types";
import { getOpenAiCompatibleApiKey } from "@/lib/config";
function getAuthHeaders(): Record<string, string> {
const key = getOpenAiCompatibleApiKey();
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (key) headers.Authorization = `Bearer ${key}`;
return headers;
}
export function createOpenAICompatibleProvider(
type: "openai_compatible" | "llamacpp" = "openai_compatible"
): AIProvider {
return {
type,
async healthCheck(config: AIProviderConfig): Promise<AIHealthResult> {
const start = Date.now();
const base = config.baseUrl.replace(/\/+$/, "");
try {
const res = await fetch(`${base}/v1/models`, {
headers: getAuthHeaders(),
signal: AbortSignal.timeout(5000),
});
const latencyMs = Date.now() - start;
if (!res.ok) {
return {
status: "offline",
provider: type,
model: config.model,
latencyMs,
lastSuccessAt: null,
lastFailureAt: new Date().toISOString(),
lastError: redactError(`HTTP ${res.status}`),
baseUrlSafe: redactUrl(config.baseUrl),
contextLength: null,
memoryUsageMb: null,
modelsAvailable: [],
};
}
const data = await res.json();
const models = (data.data ?? []).map((m: { id: string }) => m.id);
return {
status: "online",
provider: type,
model: config.model,
latencyMs,
lastSuccessAt: new Date().toISOString(),
lastFailureAt: null,
lastError: null,
baseUrlSafe: redactUrl(config.baseUrl),
contextLength: null,
memoryUsageMb: null,
modelsAvailable: models,
};
} catch (e) {
return {
status: "offline",
provider: type,
model: config.model,
latencyMs: Date.now() - start,
lastSuccessAt: null,
lastFailureAt: new Date().toISOString(),
lastError: redactError(e instanceof Error ? e.message : "Connection failed"),
baseUrlSafe: redactUrl(config.baseUrl),
contextLength: null,
memoryUsageMb: null,
modelsAvailable: [],
};
}
},
async listModels(config: AIProviderConfig) {
const base = config.baseUrl.replace(/\/+$/, "");
const res = await fetch(`${base}/v1/models`, {
headers: getAuthHeaders(),
signal: AbortSignal.timeout(5000),
});
if (!res.ok) return [];
const data = await res.json();
return (data.data ?? []).map((m: { id: string }) => m.id);
},
async generateText(
config: AIProviderConfig,
req: GenerateTextRequest
): Promise<GenerateTextResponse> {
const start = Date.now();
const base = config.baseUrl.replace(/\/+$/, "");
const messages = [];
if (req.system) messages.push({ role: "system", content: req.system });
messages.push({ role: "user", content: req.prompt });
const res = await fetch(`${base}/v1/chat/completions`, {
method: "POST",
headers: getAuthHeaders(),
body: JSON.stringify({
model: req.model || config.model,
messages,
temperature: req.temperature ?? config.temperature,
max_tokens: req.maxTokens ?? config.maxTokens,
response_format:
req.format === "json" ? { type: "json_object" } : undefined,
}),
signal: AbortSignal.timeout(req.timeoutMs ?? config.timeoutMs),
});
if (!res.ok) {
const errText = await res.text().catch(() => "");
throw new Error(redactError(`API error ${res.status}: ${errText}`));
}
const data = await res.json();
const text = data.choices?.[0]?.message?.content ?? "";
return {
text,
model: req.model || config.model,
latencyMs: Date.now() - start,
};
},
};
}
export const createLlamaCppProvider = () =>
createOpenAICompatibleProvider("llamacpp");

View File

@@ -0,0 +1,16 @@
import { describe, it, expect } from "vitest";
import { redactError, redactUrl } from "./types";
describe("redactError", () => {
it("redacts API keys", () => {
const result = redactError("Error with sk-abcdefghijklmnop token");
expect(result).not.toContain("sk-abcdefghijklmnop");
expect(result).toContain("sk-***");
});
});
describe("redactUrl", () => {
it("returns origin and path", () => {
expect(redactUrl("http://localhost:11434/api")).toBe("http://localhost:11434/api");
});
});

108
apps/web/src/lib/ai/types.ts Executable file
View File

@@ -0,0 +1,108 @@
export type AIProviderType = "ollama" | "llamacpp" | "openai_compatible";
export type AIHealthStatus = "online" | "offline" | "degraded";
export interface AIError {
code: string;
message: string;
retryable: boolean;
}
export interface GenerateTextRequest {
model: string;
prompt: string;
system?: string;
temperature?: number;
maxTokens?: number;
format?: "json" | "text";
timeoutMs?: number;
}
export interface GenerateTextResponse {
text: string;
model: string;
latencyMs: number;
}
export interface AIHealthResult {
status: AIHealthStatus;
provider: AIProviderType;
model: string;
latencyMs: number;
lastSuccessAt: string | null;
lastFailureAt: string | null;
lastError: string | null;
baseUrlSafe: string | null;
contextLength: number | null;
memoryUsageMb: number | null;
modelsAvailable: string[];
}
export interface AIProvider {
readonly type: AIProviderType;
healthCheck(config: AIProviderConfig): Promise<AIHealthResult>;
listModels?(config: AIProviderConfig): Promise<string[]>;
generateText(
config: AIProviderConfig,
req: GenerateTextRequest
): Promise<GenerateTextResponse>;
}
export interface AIProviderConfig {
type: AIProviderType;
baseUrl: string;
model: string;
temperature: number;
maxTokens: number;
timeoutMs: number;
enabled: boolean;
}
export interface AIBehaviorConfig {
personality: "supportive_mentor" | "wise_teacher" | "quiet_observer" | "academic_tutor" | "friendly_coach";
verbosity: "minimal" | "balanced" | "detailed";
frequency: "daily" | "weekly" | "monthly";
creativity: number;
enabled: boolean;
strictMode: boolean;
}
export const DEFAULT_AI_BEHAVIOR: AIBehaviorConfig = {
personality: "supportive_mentor",
verbosity: "balanced",
frequency: "weekly",
creativity: 0.7,
enabled: true,
strictMode: false,
};
import { env } from "@/lib/config";
export const DEFAULT_AI_PROVIDER: AIProviderConfig = {
type: "ollama",
baseUrl: env.ollamaUrl,
model: env.ollamaModelFast,
temperature: 0.7,
maxTokens: 2048,
timeoutMs: 60000,
enabled: true,
};
export function redactUrl(url: string): string {
try {
const u = new URL(url);
if (u.password) u.password = "***";
if (u.username && u.username !== "localhost") u.username = "***";
return u.origin + u.pathname.replace(/\/+$/, "");
} catch {
return url.replace(/\/\/[^@]+@/, "//***@");
}
}
export function redactError(msg: string): string {
return msg
.replace(/sk-[a-zA-Z0-9]{10,}/g, "sk-***")
.replace(/Bearer\s+[a-zA-Z0-9._-]+/gi, "Bearer ***")
.replace(/api[_-]?key[=:]\s*\S+/gi, "api_key=***")
.slice(0, 500);
}

32
apps/web/src/lib/api-client.ts Executable file
View File

@@ -0,0 +1,32 @@
export class ApiClientError extends Error {
constructor(
message: string,
readonly status: number
) {
super(message);
this.name = "ApiClientError";
}
}
export async function apiFetch<T>(
path: string,
init?: RequestInit
): Promise<T> {
const res = await fetch(path, init);
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const message =
typeof data === "object" && data && "error" in data
? String((data as { error: string }).error)
: "Request failed";
throw new ApiClientError(message, res.status);
}
return data as T;
}
export function jsonBody(body: unknown): RequestInit {
return {
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
};
}

24
apps/web/src/lib/api.ts Executable file
View File

@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { mapErrorToResponse } from "@/lib/errors";
export function jsonOk<T>(data: T, status = 200) {
return NextResponse.json(data, { status });
}
export function jsonError(message: string, status = 400) {
return NextResponse.json({ error: message }, { status });
}
export async function handleApi<T>(
fn: () => Promise<T>,
unauthorized = false
) {
try {
const data = await fn();
return jsonOk(data);
} catch (e) {
const { message, status } = mapErrorToResponse(e);
if (status >= 500) console.error(e);
return jsonError(message, unauthorized ? 401 : status);
}
}

37
apps/web/src/lib/auth.ts Executable file
View File

@@ -0,0 +1,37 @@
import { getIronSession, SessionOptions } from "iron-session";
import { cookies } from "next/headers";
import { env } from "@/lib/config";
export interface SessionData {
isLoggedIn: boolean;
}
export const sessionOptions: SessionOptions = {
password: env.sessionSecret,
cookieName: "adventureos_session",
cookieOptions: {
secure: env.isProduction,
httpOnly: true,
sameSite: "lax",
},
};
export async function getSession() {
return getIronSession<SessionData>(await cookies(), sessionOptions);
}
export function verifyPassword(password: string): boolean {
return password === env.authPassword;
}
export async function requireAuth() {
const session = await getSession();
if (!session.isLoggedIn) {
throw new Error("Unauthorized");
}
}
export function verifyCronSecret(request: Request): boolean {
const secret = request.headers.get("x-cron-secret");
return secret === env.cronSecret;
}

View File

@@ -0,0 +1,48 @@
/** Settings table keys persisted per user. */
export const SETTINGS_KEYS = {
theme: "theme",
aiConfig: "ai_config",
aiProvider: "ai_provider",
aiHealthCache: "ai_health_cache",
weeklyReadingGoal: "weekly_reading_goal",
dayBoundaryHour: "day_boundary_hour",
aiProfileSummary: "ai_profile_summary",
aiMemoryLearning: "ai_memory_learning",
} as const;
export type SettingsKey = (typeof SETTINGS_KEYS)[keyof typeof SETTINGS_KEYS];
/** Action event types recorded for undo. */
export const ACTION_TYPES = {
adventureItemUpdate: "adventure_item.update",
adventureItemCreate: "adventure_item.create",
adventureItemDelete: "adventure_item.delete",
adventureWorkHoursUpdate: "adventure.work_hours.update",
adventureRestDay: "adventure.rest_day",
dailyTodoCreate: "daily_todo.create",
dailyTodoUpdate: "daily_todo.update",
dailyTodoDelete: "daily_todo.delete",
readingLogPages: "reading.log_pages",
reflectionSave: "reflection.save",
explorationUpdate: "exploration.update",
templateItemDelete: "template_item.delete",
} as const;
export type ActionType = (typeof ACTION_TYPES)[keyof typeof ACTION_TYPES];
/** Entity types referenced by action events. */
export const ENTITY_TYPES = {
book: "book",
readingProgress: "reading_progress",
dailyAdventureItem: "daily_adventure_item",
dailyTodo: "daily_todo",
dailyAdventure: "daily_adventure",
exploration: "exploration",
adventureItem: "adventure_item",
reflection: "reflection",
} as const;
export type EntityType = (typeof ENTITY_TYPES)[keyof typeof ENTITY_TYPES];
/** Default theme when none is stored. */
export const DEFAULT_THEME_ID = "minimal-dark";

61
apps/web/src/lib/config/env.ts Executable file
View File

@@ -0,0 +1,61 @@
/**
* Centralised environment configuration.
* Preserves existing env variable names and default values.
*/
export const env = {
get nodeEnv(): string {
return process.env.NODE_ENV ?? "development";
},
get isProduction(): boolean {
return env.nodeEnv === "production";
},
get sessionSecret(): string {
return process.env.SESSION_SECRET ?? "complex_password_at_least_32_characters_long";
},
get authPassword(): string {
return process.env.AUTH_PASSWORD ?? "adventure";
},
get cronSecret(): string {
return process.env.CRON_SECRET ?? "change-me-cron-secret";
},
get ollamaUrl(): string {
return process.env.OLLAMA_URL ?? "http://localhost:11434";
},
get ollamaModelFast(): string {
return process.env.OLLAMA_MODEL_FAST ?? "llama3.2:3b";
},
get ollamaModelProse(): string {
return process.env.OLLAMA_MODEL_PROSE ?? process.env.OLLAMA_MODEL_FAST ?? "llama3.2:3b";
},
get openAiApiKey(): string | undefined {
return process.env.OPENAI_API_KEY;
},
get llamaCppApiKey(): string | undefined {
return process.env.LLAMACPP_API_KEY;
},
get calibreLibraryPath(): string | undefined {
return process.env.CALIBRE_LIBRARY_PATH;
},
get calibreMetadataDbPath(): string | undefined {
return process.env.CALIBRE_METADATA_DB_PATH;
},
get calibreReadOnly(): boolean {
return process.env.CALIBRE_READ_ONLY !== "false";
},
} as const;
export function getOpenAiCompatibleApiKey(): string | undefined {
return env.openAiApiKey ?? env.llamaCppApiKey;
}

View File

@@ -0,0 +1,10 @@
export { env, getOpenAiCompatibleApiKey } from "./env";
export {
SETTINGS_KEYS,
ACTION_TYPES,
ENTITY_TYPES,
DEFAULT_THEME_ID,
type SettingsKey,
type ActionType,
type EntityType,
} from "./constants";

View File

@@ -0,0 +1,25 @@
import { format, parseISO } from "date-fns";
import {
getLogicalToday,
getLogicalYesterday,
isWithinGraceWindow,
DEFAULT_DAY_BOUNDARY_HOUR,
} from "@adventureos/shared";
export function todayString(): string {
return format(new Date(), "yyyy-MM-dd");
}
export { getLogicalToday, getLogicalYesterday, isWithinGraceWindow, DEFAULT_DAY_BOUNDARY_HOUR };
export function formatDisplayDate(dateStr: string): string {
return format(parseISO(dateStr), "EEEE, d MMM yyyy");
}
export function weekStartString(date = new Date()): string {
const d = new Date(date);
const day = d.getDay();
const diff = d.getDate() - day + (day === 0 ? -6 : 1);
d.setDate(diff);
return format(d, "yyyy-MM-dd");
}

45
apps/web/src/lib/dates.ts Executable file
View File

@@ -0,0 +1,45 @@
import { format, subDays, startOfWeek, parseISO } from "date-fns";
import {
getLogicalToday as sharedLogicalToday,
getLogicalYesterday as sharedLogicalYesterday,
isWithinGraceWindow as sharedGraceWindow,
DEFAULT_DAY_BOUNDARY_HOUR,
} from "@adventureos/shared";
export function todayString(): string {
return format(new Date(), "yyyy-MM-dd");
}
export function getLogicalToday(boundaryHour = DEFAULT_DAY_BOUNDARY_HOUR, now = new Date()): string {
return sharedLogicalToday(boundaryHour, now);
}
export function getLogicalYesterday(boundaryHour = DEFAULT_DAY_BOUNDARY_HOUR, now = new Date()): string {
return sharedLogicalYesterday(boundaryHour, now);
}
export function isWithinGraceWindow(
boundaryHour = DEFAULT_DAY_BOUNDARY_HOUR,
graceHours = 4,
now = new Date()
): boolean {
return sharedGraceWindow(boundaryHour, graceHours, now);
}
export function formatDisplayDate(dateStr: string): string {
return format(parseISO(dateStr), "EEEE, d MMM yyyy");
}
export function weekStartString(date = new Date()): string {
return format(startOfWeek(date, { weekStartsOn: 1 }), "yyyy-MM-dd");
}
export function lastNDays(n: number): string[] {
return Array.from({ length: n }, (_, i) =>
format(subDays(new Date(), i), "yyyy-MM-dd")
);
}
export function dayOfWeek(dateStr: string): number {
return parseISO(dateStr).getDay();
}

2
apps/web/src/lib/db.ts Executable file
View File

@@ -0,0 +1,2 @@
export { db } from "@adventureos/db";
export * from "@adventureos/db/schema";

View File

@@ -0,0 +1,43 @@
export class AppError extends Error {
constructor(
message: string,
readonly statusCode: number = 500
) {
super(message);
this.name = "AppError";
}
}
export class NotFoundError extends AppError {
constructor(message = "Not found") {
super(message, 500);
}
}
export class ValidationError extends AppError {
constructor(message: string) {
super(message, 400);
}
}
export class UnauthorizedError extends AppError {
constructor(message = "Unauthorized") {
super(message, 401);
}
}
export class ServiceUnavailableError extends AppError {
constructor(message: string) {
super(message, 503);
}
}
export function mapErrorToResponse(error: unknown): { message: string; status: number } {
if (error instanceof AppError) {
return { message: error.message, status: error.statusCode };
}
const msg = error instanceof Error ? error.message : "Unknown error";
if (msg === "Unauthorized") return { message: msg, status: 401 };
if (msg.includes("No user found")) return { message: msg, status: 503 };
return { message: msg, status: 500 };
}

View File

@@ -0,0 +1,20 @@
export type ReflectionData = {
wentWell: string;
learned: string;
improveTomorrow: string;
};
export function hasMeaningfulReflectionContent(data: ReflectionData): boolean {
return [data.wentWell, data.learned, data.improveTomorrow].some(
(field) => field.trim().length > 0
);
}
export function shouldAwardReflectionXp(
existing: ReflectionData | null | undefined,
data: ReflectionData
): boolean {
if (!hasMeaningfulReflectionContent(data)) return false;
if (!existing) return true;
return !hasMeaningfulReflectionContent(existing);
}

View File

@@ -0,0 +1,26 @@
import { eq, and, isNull, desc } from "drizzle-orm";
import { db, adventureTemplates, adventureItems } from "@/lib/db";
export async function listTemplates(userId: string) {
return db
.select()
.from(adventureTemplates)
.where(and(eq(adventureTemplates.userId, userId), isNull(adventureTemplates.deletedAt)))
.orderBy(desc(adventureTemplates.sortPriority));
}
export async function findTemplateById(templateId: string) {
const [row] = await db
.select()
.from(adventureTemplates)
.where(eq(adventureTemplates.id, templateId));
return row ?? null;
}
export async function listTemplateItems(templateId: string) {
return db
.select()
.from(adventureItems)
.where(and(eq(adventureItems.templateId, templateId), isNull(adventureItems.deletedAt)))
.orderBy(adventureItems.sortOrder);
}

View File

@@ -0,0 +1,3 @@
export * from "./settings.repository";
export * from "./teacher.repository";
export * from "./adventure-templates.repository";

View File

@@ -0,0 +1,29 @@
import { eq, and, sql } from "drizzle-orm";
import { db, settings } from "@/lib/db";
import type { SettingsKey } from "@/lib/config";
export async function getSettingsForUser(userId: string) {
return db.select().from(settings).where(eq(settings.userId, userId));
}
export async function upsertSetting(
userId: string,
key: SettingsKey | string,
value: unknown
) {
await db
.insert(settings)
.values({ userId, key, value })
.onConflictDoUpdate({
target: [settings.userId, settings.key],
set: { value: sql`excluded.value` },
});
}
export async function getSettingValue(userId: string, key: SettingsKey | string) {
const [row] = await db
.select()
.from(settings)
.where(and(eq(settings.userId, userId), eq(settings.key, key)));
return row?.value ?? null;
}

View File

@@ -0,0 +1,51 @@
import { eq, desc } from "drizzle-orm";
import { db, teacherContent } from "@/lib/db";
export async function listTeacherContent(userId: string) {
return db
.select()
.from(teacherContent)
.where(eq(teacherContent.userId, userId))
.orderBy(desc(teacherContent.createdAt));
}
export async function findTeacherContentById(id: string) {
const [row] = await db.select().from(teacherContent).where(eq(teacherContent.id, id));
return row ?? null;
}
export async function insertTeacherContent(data: {
userId: string;
topic: string;
content: Record<string, unknown>;
explorationId?: string;
status?: string;
}) {
const [row] = await db
.insert(teacherContent)
.values({
userId: data.userId,
explorationId: data.explorationId,
topic: data.topic,
content: data.content,
status: data.status ?? "active",
})
.returning();
return row;
}
export async function updateTeacherContent(
id: string,
data: Partial<{
status: string;
completedNote: string;
completedAt: Date;
}>
) {
const [row] = await db
.update(teacherContent)
.set(data)
.where(eq(teacherContent.id, id))
.returning();
return row;
}

View File

@@ -0,0 +1,93 @@
import { and, eq } from "drizzle-orm";
import { getAchievement, ACHIEVEMENTS } from "@adventureos/shared";
import { db, achievements, users, userProgress, explorations, weeklyReviews } from "../db";
import { getBooksCompletedCount, getTotalPagesRead, getReadingStreak } from "./reading";
import { refreshScores } from "./adventure";
export async function getAchievements(userId: string) {
return db
.select()
.from(achievements)
.where(eq(achievements.userId, userId));
}
export async function unlockAchievement(
userId: string,
key: string,
metadata?: Record<string, unknown>
) {
const existing = await db
.select()
.from(achievements)
.where(and(eq(achievements.userId, userId), eq(achievements.key, key)));
if (existing.length > 0) return null;
const [a] = await db
.insert(achievements)
.values({ userId, key, metadata })
.returning();
return a;
}
export async function checkAchievements(userId: string) {
const unlocked: string[] = [];
const [user] = await db.select().from(users).where(eq(users.id, userId));
const [progress] = await db
.select()
.from(userProgress)
.where(eq(userProgress.userId, userId));
if (!user || !progress) return unlocked;
const booksDone = await getBooksCompletedCount(userId);
const totalPages = await getTotalPagesRead(userId);
const streak = await getReadingStreak(userId);
const explorationDone = await db
.select()
.from(explorations)
.where(and(eq(explorations.userId, userId), eq(explorations.status, "completed")));
const reviews = await db
.select()
.from(weeklyReviews)
.where(eq(weeklyReviews.userId, userId));
const checks: [string, boolean][] = [
["first_book", booksDone >= 1],
["books_5", booksDone >= 5],
["books_10", booksDone >= 10],
["pages_1000", totalPages >= 1000],
["reading_streak_7", streak.best >= 7],
["reading_streak_30", streak.best >= 30],
["level_10", progress.level >= 10],
["level_25", progress.level >= 25],
["level_50", progress.level >= 50],
["level_100", progress.level >= 100],
["exploration_1", explorationDone.length >= 1],
["exploration_10", explorationDone.length >= 10],
["weekly_review_4", reviews.length >= 4],
["consistency_60", progress.consistencyScore >= 60],
[
"year_one",
user.createdAt &&
Date.now() - new Date(user.createdAt).getTime() >= 365 * 86400000,
],
];
for (const [key, condition] of checks) {
if (condition) {
const a = await unlockAchievement(userId, key);
if (a) unlocked.push(key);
}
}
return unlocked;
}
export function getAllAchievementDefinitions() {
return ACHIEVEMENTS;
}
export function formatAchievement(key: string) {
return getAchievement(key);
}

View File

@@ -0,0 +1,73 @@
import { db, actionEvents } from "../db";
import { eq, desc, and, isNull, lt, sql } from "drizzle-orm";
export interface RecordActionInput {
userId: string;
actionType: string;
entityType: string;
entityId: string;
summary: string;
beforeState: Record<string, unknown>;
afterState: Record<string, unknown>;
inversePatch?: Record<string, unknown>;
metadata?: Record<string, unknown>;
undoable?: boolean;
}
export async function recordAction(input: RecordActionInput) {
const [event] = await db
.insert(actionEvents)
.values({
userId: input.userId,
actionType: input.actionType,
entityType: input.entityType,
entityId: input.entityId,
summary: input.summary,
beforeState: input.beforeState,
afterState: input.afterState,
inversePatch: input.inversePatch,
metadata: input.metadata ?? {},
undoable: input.undoable ?? true,
})
.returning();
return event;
}
export async function getRecentActions(userId: string, limit = 20) {
return db
.select()
.from(actionEvents)
.where(and(eq(actionEvents.userId, userId), isNull(actionEvents.undoneAt)))
.orderBy(desc(actionEvents.createdAt))
.limit(limit);
}
export async function getActionEvent(userId: string, actionEventId: string) {
const [event] = await db
.select()
.from(actionEvents)
.where(
and(eq(actionEvents.id, actionEventId), eq(actionEvents.userId, userId))
);
return event ?? null;
}
export async function markUndone(actionEventId: string) {
await db
.update(actionEvents)
.set({ undoneAt: new Date() })
.where(eq(actionEvents.id, actionEventId));
}
export async function pruneOldActions(userId: string, daysOld = 90) {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - daysOld);
await db
.delete(actionEvents)
.where(
and(
eq(actionEvents.userId, userId),
lt(actionEvents.createdAt, cutoff)
)
);
}

View File

@@ -0,0 +1,4 @@
export * from "./adventure/materialization";
export * from "./adventure/daily";
export * from "./adventure/scoring";
export * from "./adventure/templates";

View File

@@ -0,0 +1,4 @@
export function initialChecklistValue(config: Record<string, unknown>): Record<string, unknown> {
const size = (config.checklistSize as number) ?? 5;
return { checks: Array(size).fill(false) };
}

View File

@@ -0,0 +1,351 @@
import { and, eq } from "drizzle-orm";
import {
xpForAdventureState,
type AdventureItemState,
type DaySnapshot,
XP_AWARDS,
computeAllScores,
} from "@adventureos/shared";
import {
db,
dailyAdventureItems,
dailyAdventures,
dailyTodos,
userProgress,
readingLogs,
books,
xpEvents,
} from "@/lib/db";
import { lastNDays } from "@/lib/dates";
import { awardXp } from "@/lib/services/xp";
import { recordAction } from "@/lib/services/action-events";
import { ACTION_TYPES } from "@/lib/config";
import { deriveState } from "./derive-state";
import { getDailyAdventure, materializeDay } from "./materialization";
import { refreshScores } from "./scoring";
export async function updateAdventureItem(
userId: string,
date: string,
itemId: string,
updates: { value?: Record<string, unknown>; state?: AdventureItemState }
) {
const { items } = await getDailyAdventure(userId, date);
const item = items.find((i) => i.id === itemId);
if (!item) throw new Error("Item not found");
const newValue = { ...(item.value as Record<string, unknown>), ...updates.value };
const newState =
updates.state ?? deriveState(item.type, newValue, item.config as Record<string, unknown>);
const oldState = item.state as AdventureItemState;
const beforeState = {
value: item.value,
state: item.state,
completedAt: item.completedAt,
};
await db
.update(dailyAdventureItems)
.set({
value: newValue,
state: newState,
completedAt: newState === "done" ? new Date() : null,
})
.where(eq(dailyAdventureItems.id, itemId));
const xpEventIds: string[] = [];
if (newState !== oldState && newState !== "blank") {
const xp = xpForAdventureState(newState);
if (xp > 0) {
const result = await awardXp(userId, date, "adventure_item", xp, {
itemId,
state: newState,
});
if (result.xpEventId) xpEventIds.push(result.xpEventId);
}
if (item.type === "checkbox" && item.label.toLowerCase().includes("exercise") && newState === "done") {
const ex = await awardXp(userId, date, "exercise", XP_AWARDS.exercise, { itemId });
if (ex.xpEventId) xpEventIds.push(ex.xpEventId);
}
if (item.type === "checklist") {
const oldChecks = ((item.value as Record<string, unknown>).checks as boolean[]) ?? [];
const newChecks = (newValue.checks as boolean[]) ?? [];
const added = newChecks.filter((c, i) => c && !oldChecks[i]).length;
if (added > 0) {
const sp = await awardXp(userId, date, "spiritual", added * XP_AWARDS.spiritual_per_check, {
itemId,
});
if (sp.xpEventId) xpEventIds.push(sp.xpEventId);
}
}
}
const actionEvent = await recordAction({
userId,
actionType: ACTION_TYPES.adventureItemUpdate,
entityType: "daily_adventure_item",
entityId: itemId,
summary: `Updated ${item.label}`,
beforeState,
afterState: { value: newValue, state: newState, completedAt: newState === "done" ? new Date() : null },
metadata: { date, xpEventIds, itemLabel: item.label },
});
await refreshScores(userId);
return { state: newState, value: newValue, actionEventId: actionEvent.id };
}
export async function setRestDay(userId: string, date: string) {
const adventure = await materializeDay(userId, date);
const before = { isRestDay: adventure.isRestDay };
await db
.update(dailyAdventures)
.set({ isRestDay: true })
.where(eq(dailyAdventures.id, adventure.id));
const xpResult = await awardXp(userId, date, "rest_day", XP_AWARDS.rest_day);
const actionEvent = await recordAction({
userId,
actionType: ACTION_TYPES.adventureRestDay,
entityType: "daily_adventure",
entityId: adventure.id,
summary: "Marked rest day",
beforeState: before,
afterState: { isRestDay: true },
metadata: {
date,
adventureId: adventure.id,
xpEventIds: xpResult.xpEventId ? [xpResult.xpEventId] : [],
},
});
return { actionEventId: actionEvent.id };
}
export async function updateDaySettings(
userId: string,
date: string,
updates: { workHoursTarget?: number; dayMode?: string }
) {
const { adventure } = await getDailyAdventure(userId, date);
const before = {
workHoursTarget: adventure.workHoursTarget,
dayMode: adventure.dayMode,
isRestDay: adventure.isRestDay,
};
const setValues: Record<string, unknown> = {
isCustomized: true,
customizedAt: new Date(),
};
if (updates.workHoursTarget !== undefined) {
setValues.workHoursTarget = updates.workHoursTarget.toString();
}
if (updates.dayMode !== undefined) {
setValues.dayMode = updates.dayMode;
if (updates.dayMode === "rest") {
setValues.isRestDay = true;
} else if (updates.dayMode === "normal" || updates.dayMode === "low_energy") {
setValues.isRestDay = false;
}
}
await db.update(dailyAdventures).set(setValues).where(eq(dailyAdventures.id, adventure.id));
const actionEvent = await recordAction({
userId,
actionType: ACTION_TYPES.adventureWorkHoursUpdate,
entityType: "daily_adventure",
entityId: adventure.id,
summary: updates.dayMode
? `Set day mode to ${updates.dayMode}`
: `Set work hours target to ${updates.workHoursTarget}h`,
beforeState: before,
afterState: updates,
metadata: { date },
});
return { actionEventId: actionEvent.id };
}
export async function updateDailyItemMeta(
userId: string,
date: string,
itemId: string,
updates: {
label?: string;
enabled?: boolean;
config?: Record<string, unknown>;
}
) {
const { adventure, items } = await getDailyAdventure(userId, date);
const item = items.find((i) => i.id === itemId);
if (!item) throw new Error("Item not found");
const beforeState = { label: item.label, enabled: item.enabled, config: item.config };
await db
.update(dailyAdventureItems)
.set({
label: updates.label ?? item.label,
enabled: updates.enabled ?? item.enabled,
config: updates.config ? { ...(item.config as object), ...updates.config } : item.config,
isCustom: item.isCustom || !!updates.label,
})
.where(eq(dailyAdventureItems.id, itemId));
await db
.update(dailyAdventures)
.set({ isCustomized: true, customizedAt: new Date() })
.where(eq(dailyAdventures.id, adventure.id));
const actionEvent = await recordAction({
userId,
actionType: ACTION_TYPES.adventureItemUpdate,
entityType: "daily_adventure_item",
entityId: itemId,
summary: `Updated ${item.label}`,
beforeState,
afterState: updates,
metadata: { date },
});
return { actionEventId: actionEvent.id };
}
export async function addCustomDailyItem(
userId: string,
date: string,
data: { label: string; type?: string }
) {
const { adventure, items } = await getDailyAdventure(userId, date);
const maxOrder = items.reduce((m, i) => Math.max(m, i.sortOrder), 0);
const [created] = await db
.insert(dailyAdventureItems)
.values({
dailyAdventureId: adventure.id,
type: data.type ?? "checkbox",
label: data.label,
isCustom: true,
sortOrder: maxOrder + 1,
})
.returning();
await db
.update(dailyAdventures)
.set({ isCustomized: true, customizedAt: new Date() })
.where(eq(dailyAdventures.id, adventure.id));
const actionEvent = await recordAction({
userId,
actionType: ACTION_TYPES.adventureItemCreate,
entityType: "daily_adventure_item",
entityId: created.id,
summary: `Added ${data.label}`,
beforeState: {},
afterState: { label: data.label, type: data.type ?? "checkbox" },
metadata: { date },
});
return { item: created, actionEventId: actionEvent.id };
}
export async function deleteCustomDailyItem(userId: string, date: string, itemId: string) {
const { adventure, items } = await getDailyAdventure(userId, date);
const item = items.find((i) => i.id === itemId);
if (!item) throw new Error("Item not found");
if (!item.isCustom) throw new Error("Only custom items can be removed");
await db
.update(dailyAdventureItems)
.set({ deletedAt: new Date() })
.where(eq(dailyAdventureItems.id, itemId));
const actionEvent = await recordAction({
userId,
actionType: ACTION_TYPES.adventureItemDelete,
entityType: "daily_adventure_item",
entityId: itemId,
summary: `Removed ${item.label}`,
beforeState: { deletedAt: null, label: item.label },
afterState: { deletedAt: new Date() },
metadata: { date, adventureId: adventure.id },
});
return { actionEventId: actionEvent.id };
}
export async function createDailyTodo(userId: string, date: string, label: string) {
const { adventure, todos } = await getDailyAdventure(userId, date);
const maxOrder = todos.reduce((m, t) => Math.max(m, t.sortOrder), 0);
const [todo] = await db
.insert(dailyTodos)
.values({ dailyAdventureId: adventure.id, label, sortOrder: maxOrder + 1 })
.returning();
await db
.update(dailyAdventures)
.set({ isCustomized: true, customizedAt: new Date() })
.where(eq(dailyAdventures.id, adventure.id));
const actionEvent = await recordAction({
userId,
actionType: ACTION_TYPES.dailyTodoCreate,
entityType: "daily_todo",
entityId: todo.id,
summary: `Added todo: ${label}`,
beforeState: {},
afterState: { label, done: false },
metadata: { date },
});
return { todo, actionEventId: actionEvent.id };
}
export async function updateDailyTodo(
userId: string,
date: string,
todoId: string,
updates: { label?: string; done?: boolean }
) {
const { todos } = await getDailyAdventure(userId, date);
const todo = todos.find((t) => t.id === todoId);
if (!todo) throw new Error("Todo not found");
const beforeState = { label: todo.label, done: todo.done };
await db
.update(dailyTodos)
.set({
label: updates.label ?? todo.label,
done: updates.done ?? todo.done,
})
.where(eq(dailyTodos.id, todoId));
const actionEvent = await recordAction({
userId,
actionType: ACTION_TYPES.dailyTodoUpdate,
entityType: "daily_todo",
entityId: todoId,
summary: updates.done !== undefined ? `Todo: ${todo.label}` : `Updated todo: ${todo.label}`,
beforeState,
afterState: updates,
metadata: { date },
});
return { actionEventId: actionEvent.id };
}
export async function deleteDailyTodo(userId: string, date: string, todoId: string) {
const { todos } = await getDailyAdventure(userId, date);
const todo = todos.find((t) => t.id === todoId);
if (!todo) throw new Error("Todo not found");
await db.update(dailyTodos).set({ deletedAt: new Date() }).where(eq(dailyTodos.id, todoId));
const actionEvent = await recordAction({
userId,
actionType: ACTION_TYPES.dailyTodoDelete,
entityType: "daily_todo",
entityId: todoId,
summary: `Removed todo: ${todo.label}`,
beforeState: { label: todo.label, deletedAt: null },
afterState: { deletedAt: new Date() },
metadata: { date },
});
return { actionEventId: actionEvent.id };
}

View File

@@ -0,0 +1,37 @@
import { describe, it, expect } from "vitest";
import { deriveState } from "./derive-state";
describe("deriveState", () => {
it("checkbox: blank when not done", () => {
expect(deriveState("checkbox", {}, {})).toBe("blank");
});
it("checkbox: done when checked", () => {
expect(deriveState("checkbox", { done: true }, {})).toBe("done");
});
it("duration: done at target", () => {
expect(deriveState("duration", { hours: 8 }, { targetHours: 8 })).toBe("done");
});
it("duration: partial at half target", () => {
expect(deriveState("duration", { hours: 4 }, { targetHours: 8 })).toBe("partial");
});
it("duration: started below half", () => {
expect(deriveState("duration", { hours: 2 }, { targetHours: 8 })).toBe("started");
});
it("checklist: partial when some checked", () => {
expect(deriveState("checklist", { checks: [true, false, false] }, {})).toBe("partial");
});
it("checklist: done when all checked", () => {
expect(deriveState("checklist", { checks: [true, true] }, {})).toBe("done");
});
it("reading: done at 10+ pages", () => {
expect(deriveState("reading", { pages: 10 }, {})).toBe("done");
expect(deriveState("reading", { pages: 5 }, {})).toBe("partial");
});
});

View File

@@ -0,0 +1,32 @@
import type { AdventureItemState } from "@adventureos/shared";
export function deriveState(
type: string,
value: Record<string, unknown>,
config: Record<string, unknown>
): AdventureItemState {
if (type === "checkbox" || type === "timeblock") {
return value.done ? "done" : "blank";
}
if (type === "duration") {
const hours = (value.hours as number) ?? 0;
const target = (config.targetHours as number) ?? 0;
if (hours <= 0) return "blank";
if (target > 0 && hours >= target) return "done";
if (hours > 0) return hours >= target * 0.5 ? "partial" : "started";
return "started";
}
if (type === "checklist") {
const checks = (value.checks as boolean[]) ?? [];
const done = checks.filter(Boolean).length;
if (done === 0) return "blank";
if (done === checks.length) return "done";
return "partial";
}
if (type === "reading") {
const pages = (value.pages as number) ?? 0;
if (pages <= 0) return "blank";
return pages >= 10 ? "done" : "partial";
}
return "blank";
}

View File

@@ -0,0 +1,4 @@
export * from "./materialization";
export * from "./daily";
export * from "./scoring";
export * from "./templates";

View File

@@ -0,0 +1,148 @@
import { and, eq, isNull } from "drizzle-orm";
import {
db,
dailyAdventureItems,
dailyAdventures,
adventureTemplates,
adventureItems,
dailyTodos,
} from "@/lib/db";
import { dayOfWeek } from "@/lib/dates";
import { initialChecklistValue } from "./checklist-value";
import { pickTemplateForDay } from "./template-matching";
export async function findTemplateForDay(userId: string, date: string) {
const dow = dayOfWeek(date);
const templates = await db
.select()
.from(adventureTemplates)
.where(and(eq(adventureTemplates.userId, userId), isNull(adventureTemplates.deletedAt)));
return pickTemplateForDay(templates, dow);
}
export async function materializeDay(userId: string, date: string) {
const [existing] = await db
.select()
.from(dailyAdventures)
.where(and(eq(dailyAdventures.userId, userId), eq(dailyAdventures.date, date)));
if (existing) return existing;
const template = await findTemplateForDay(userId, date);
if (!template) {
const [adventure] = await db
.insert(dailyAdventures)
.values({ userId, date })
.returning();
return adventure;
}
const items = await db
.select()
.from(adventureItems)
.where(and(eq(adventureItems.templateId, template.id), isNull(adventureItems.deletedAt)))
.orderBy(adventureItems.sortOrder);
const [adventure] = await db
.insert(dailyAdventures)
.values({ userId, date, templateId: template.id })
.returning();
for (const item of items) {
const config = (item.config ?? {}) as Record<string, unknown>;
const value = item.type === "checklist" ? initialChecklistValue(config) : {};
await db.insert(dailyAdventureItems).values({
dailyAdventureId: adventure.id,
sourceItemId: item.id,
type: item.type,
label: item.label,
config,
value,
sortOrder: item.sortOrder,
enabled: item.enabled,
});
}
return adventure;
}
export async function getDailyAdventure(userId: string, date: string) {
const adventure = await materializeDay(userId, date);
const items = await db
.select()
.from(dailyAdventureItems)
.where(
and(
eq(dailyAdventureItems.dailyAdventureId, adventure.id),
isNull(dailyAdventureItems.deletedAt)
)
)
.orderBy(dailyAdventureItems.sortOrder);
const todos = await db
.select()
.from(dailyTodos)
.where(
and(eq(dailyTodos.dailyAdventureId, adventure.id), isNull(dailyTodos.deletedAt))
)
.orderBy(dailyTodos.sortOrder);
return { adventure, items, todos };
}
export async function applyTemplateToDate(
userId: string,
date: string,
templateId: string,
force = false
) {
const { adventure } = await getDailyAdventure(userId, date);
if (adventure.isCustomized && !force) {
throw new Error("Day is customized — confirm overwrite to apply template");
}
const [template] = await db
.select()
.from(adventureTemplates)
.where(and(eq(adventureTemplates.id, templateId), eq(adventureTemplates.userId, userId)));
if (!template) throw new Error("Template not found");
await db
.update(dailyAdventureItems)
.set({ deletedAt: new Date() })
.where(eq(dailyAdventureItems.dailyAdventureId, adventure.id));
const items = await db
.select()
.from(adventureItems)
.where(and(eq(adventureItems.templateId, templateId), isNull(adventureItems.deletedAt)))
.orderBy(adventureItems.sortOrder);
for (const item of items) {
const config = (item.config ?? {}) as Record<string, unknown>;
const value = item.type === "checklist" ? initialChecklistValue(config) : {};
await db.insert(dailyAdventureItems).values({
dailyAdventureId: adventure.id,
sourceItemId: item.id,
type: item.type,
label: item.label,
config,
value,
sortOrder: item.sortOrder,
enabled: item.enabled,
});
}
await db
.update(dailyAdventures)
.set({
templateId,
isCustomized: false,
customizedAt: null,
workHoursTarget: null,
})
.where(eq(dailyAdventures.id, adventure.id));
return { ok: true };
}

View File

@@ -0,0 +1,11 @@
import { and, eq } from "drizzle-orm";
import { db, settings } from "@/lib/db";
import { SETTINGS_KEYS } from "@/lib/config";
export async function getWeeklyReadingGoal(userId: string): Promise<number> {
const [row] = await db
.select()
.from(settings)
.where(and(eq(settings.userId, userId), eq(settings.key, SETTINGS_KEYS.weeklyReadingGoal)));
return (row?.value as number) ?? 50;
}

View File

@@ -0,0 +1,100 @@
import { and, eq } from "drizzle-orm";
import { computeAllScores, type DaySnapshot } from "@adventureos/shared";
import { db, userProgress, readingLogs, books, xpEvents } from "@/lib/db";
import { lastNDays } from "@/lib/dates";
import { getDailyAdventure } from "./materialization";
import { getWeeklyReadingGoal } from "./scoring-helpers";
export async function buildDaySnapshot(userId: string, date: string): Promise<DaySnapshot> {
const { adventure, items } = await getDailyAdventure(userId, date);
const scorable = items.filter((i) => i.type !== "note" && i.enabled);
const touched = scorable.filter((i) => i.state !== "blank").length;
const exerciseItem = items.find(
(i) => i.type === "checkbox" && i.label.toLowerCase().includes("exercise")
);
const workItem = items.find((i) => i.type === "duration");
const teachingItem = items.find(
(i) => i.type === "checkbox" && i.label.toLowerCase().includes("teaching")
);
const classItems = items.filter(
(i) => i.type === "timeblock" || i.label.toLowerCase().includes("class")
);
const prayerItem = items.find((i) => i.label === "Prayer");
const litanyItem = items.find((i) => i.label === "Litanies");
const prayerChecks =
((prayerItem?.value as Record<string, unknown>)?.checks as boolean[])?.filter(Boolean)
.length ?? 0;
const prayerTotal =
((prayerItem?.value as Record<string, unknown>)?.checks as boolean[])?.length ?? 5;
const litanyChecks =
((litanyItem?.value as Record<string, unknown>)?.checks as boolean[])?.filter(Boolean)
.length ?? 0;
const litanyTotal =
((litanyItem?.value as Record<string, unknown>)?.checks as boolean[])?.length ?? 6;
const dayLogs = await db
.select({ pages: readingLogs.pagesRead })
.from(readingLogs)
.innerJoin(books, eq(readingLogs.bookId, books.id))
.where(and(eq(books.userId, userId), eq(readingLogs.date, date)));
const pagesRead = dayLogs.reduce((s, l) => s + l.pages, 0);
const weeklyGoal = await getWeeklyReadingGoal(userId);
const workTarget =
adventure.workHoursTarget != null
? Number(adventure.workHoursTarget)
: ((workItem?.config as Record<string, unknown>)?.targetHours as number) ?? 7.5;
const completedExplorationXp = await db
.select()
.from(xpEvents)
.where(
and(
eq(xpEvents.userId, userId),
eq(xpEvents.date, date),
eq(xpEvents.source, "exploration")
)
);
const explorationsDone = completedExplorationXp.filter(
(e) => !(e.metadata as Record<string, unknown>)?.revoked
).length;
return {
date,
adventureSlots: adventure.isRestDay ? 0 : scorable.length,
adventureTouched: adventure.isRestDay ? scorable.length : touched,
exerciseDone: exerciseItem?.state === "done",
workHours: ((workItem?.value as Record<string, unknown>)?.hours as number) ?? 0,
workTarget,
sleepLogged: false,
classesDone: classItems.filter((i) => i.state === "done").length,
teachingDone: teachingItem?.state === "done",
explorationsDone,
prayerChecks,
prayerTotal,
litanyChecks,
litanyTotal,
pagesRead,
readingGoalWeekly: weeklyGoal,
hadSpiritualEver: prayerChecks + litanyChecks > 0,
};
}
export async function refreshScores(userId: string) {
const days: DaySnapshot[] = [];
for (const date of lastNDays(30)) {
days.push(await buildDaySnapshot(userId, date));
}
const weeklyGoal = await getWeeklyReadingGoal(userId);
const scores = computeAllScores(days, weeklyGoal);
await db
.update(userProgress)
.set({ ...scores, scoresUpdatedAt: new Date() })
.where(eq(userProgress.userId, userId));
}

View File

@@ -0,0 +1,49 @@
import { describe, it, expect } from "vitest";
import { pickTemplateForDay } from "./template-matching";
describe("pickTemplateForDay", () => {
const templates = [
{
id: "weekday",
daysOfWeek: [1, 2, 3, 4, 5],
sortPriority: 0,
isDefault: true,
deletedAt: null,
},
{
id: "tuesday-thursday",
daysOfWeek: [2, 4],
sortPriority: 10,
isDefault: false,
deletedAt: null,
},
{
id: "deleted",
daysOfWeek: [2],
sortPriority: 100,
isDefault: false,
deletedAt: new Date(),
},
];
it("prefers higher sortPriority on overlapping days", () => {
expect(pickTemplateForDay(templates, 2)?.id).toBe("tuesday-thursday");
});
it("breaks ties by fewer daysOfWeek entries", () => {
const tied = [
{ id: "broad", daysOfWeek: [1, 2, 3, 4, 5], sortPriority: 5, isDefault: false, deletedAt: null },
{ id: "narrow", daysOfWeek: [2], sortPriority: 5, isDefault: false, deletedAt: null },
];
expect(pickTemplateForDay(tied, 2)?.id).toBe("narrow");
});
it("falls back to default when no day match", () => {
expect(pickTemplateForDay(templates, 0)?.id).toBe("weekday");
});
it("ignores deleted templates", () => {
const onlyDeleted = templates.filter((t) => t.id === "deleted");
expect(pickTemplateForDay(onlyDeleted, 2)).toBeNull();
});
});

View File

@@ -0,0 +1,23 @@
export type TemplateCandidate = {
id: string;
daysOfWeek: number[];
sortPriority: number;
isDefault: boolean;
deletedAt: Date | null;
};
export function pickTemplateForDay(
templates: TemplateCandidate[],
dayOfWeek: number
): TemplateCandidate | null {
const active = templates.filter((t) => t.deletedAt === null);
const matching = active
.filter((t) => t.daysOfWeek.includes(dayOfWeek))
.sort(
(a, b) =>
b.sortPriority - a.sortPriority || a.daysOfWeek.length - b.daysOfWeek.length
);
if (matching.length > 0) return matching[0];
return active.find((t) => t.isDefault) ?? active[0] ?? null;
}

View File

@@ -0,0 +1,192 @@
import { and, eq, isNull, inArray } from "drizzle-orm";
import { db, adventureTemplates, adventureItems } from "@/lib/db";
import { recordAction } from "@/lib/services/action-events";
import { ACTION_TYPES } from "@/lib/config";
export async function updateTemplate(
userId: string,
templateId: string,
data: Partial<{
name: string;
daysOfWeek: number[];
isDefault: boolean;
sortPriority: number;
}>
) {
const [template] = await db
.select()
.from(adventureTemplates)
.where(and(eq(adventureTemplates.id, templateId), eq(adventureTemplates.userId, userId)));
if (!template) throw new Error("Template not found");
await db.update(adventureTemplates).set(data).where(eq(adventureTemplates.id, templateId));
return { ok: true };
}
export async function getTemplates(userId: string) {
const templates = await db
.select()
.from(adventureTemplates)
.where(and(eq(adventureTemplates.userId, userId), isNull(adventureTemplates.deletedAt)));
const result = [];
for (const t of templates) {
const items = await db
.select()
.from(adventureItems)
.where(and(eq(adventureItems.templateId, t.id), isNull(adventureItems.deletedAt)))
.orderBy(adventureItems.sortOrder);
result.push({ ...t, items });
}
return result;
}
export async function duplicateTemplate(userId: string, templateId: string) {
const templates = await getTemplates(userId);
const source = templates.find((t) => t.id === templateId);
if (!source) throw new Error("Template not found");
const [copy] = await db
.insert(adventureTemplates)
.values({
userId,
name: `${source.name} (copy)`,
daysOfWeek: [],
isDefault: false,
isSystem: false,
sortPriority: 0,
})
.returning();
for (const item of source.items) {
await db.insert(adventureItems).values({
templateId: copy.id,
type: item.type,
label: item.label,
config: item.config,
sortOrder: item.sortOrder,
enabled: item.enabled ?? true,
});
}
return copy;
}
export async function deleteTemplate(userId: string, templateId: string) {
const [template] = await db
.select()
.from(adventureTemplates)
.where(and(eq(adventureTemplates.id, templateId), eq(adventureTemplates.userId, userId)));
if (!template) throw new Error("Template not found");
if (template.isSystem) throw new Error("System templates cannot be deleted");
await db
.update(adventureTemplates)
.set({ deletedAt: new Date() })
.where(eq(adventureTemplates.id, templateId));
return { ok: true };
}
export async function verifyTemplateOwnership(userId: string, templateId: string) {
const [template] = await db
.select()
.from(adventureTemplates)
.where(
and(
eq(adventureTemplates.id, templateId),
eq(adventureTemplates.userId, userId),
isNull(adventureTemplates.deletedAt)
)
);
if (!template) throw new Error("Template not found");
return template;
}
export async function createTemplate(
userId: string,
data: { name: string; daysOfWeek?: number[]; isDefault?: boolean }
) {
const [template] = await db
.insert(adventureTemplates)
.values({
userId,
name: data.name,
daysOfWeek: data.daysOfWeek ?? [],
isDefault: data.isDefault ?? false,
})
.returning();
return template;
}
export async function upsertTemplateItem(
templateId: string,
item: {
id?: string;
type: string;
label: string;
config?: Record<string, unknown>;
sortOrder?: number;
enabled?: boolean;
}
) {
if (item.id) {
await db
.update(adventureItems)
.set({
type: item.type,
label: item.label,
config: item.config ?? {},
sortOrder: item.sortOrder ?? 0,
enabled: item.enabled ?? true,
})
.where(eq(adventureItems.id, item.id));
return item.id;
}
const [created] = await db
.insert(adventureItems)
.values({
templateId,
type: item.type,
label: item.label,
config: item.config ?? {},
sortOrder: item.sortOrder ?? 0,
enabled: item.enabled ?? true,
})
.returning();
return created.id;
}
export async function deleteTemplateItem(itemId: string, userId: string) {
const templates = await db
.select()
.from(adventureTemplates)
.where(eq(adventureTemplates.userId, userId));
const templateIds = templates.map((t) => t.id);
if (templateIds.length === 0) throw new Error("Item not found");
const [item] = await db
.select()
.from(adventureItems)
.where(
and(eq(adventureItems.id, itemId), inArray(adventureItems.templateId, templateIds))
);
if (!item) throw new Error("Item not found");
const beforeState = { deletedAt: null, label: item.label };
await db
.update(adventureItems)
.set({ deletedAt: new Date() })
.where(eq(adventureItems.id, itemId));
const actionEvent = await recordAction({
userId,
actionType: ACTION_TYPES.templateItemDelete,
entityType: "adventure_item",
entityId: itemId,
summary: `Deleted template item: ${item.label}`,
beforeState,
afterState: { deletedAt: new Date() },
});
return { actionEventId: actionEvent.id };
}

View File

@@ -0,0 +1,154 @@
import { and, eq, desc, isNull } from "drizzle-orm";
import { db, aiChatSessions, aiChatMessages } from "../db";
import { buildMentorContext, formatContextForPrompt } from "./ai-context";
import { generateChatReply, getAiAvailability } from "./ai";
import { listMemories, getProfileSummary } from "./ai-memory";
import { MEMORY_CATEGORIES, type MemoryCategory } from "@adventureos/shared";
export async function listChatSessions(userId: string) {
return db
.select()
.from(aiChatSessions)
.where(and(eq(aiChatSessions.userId, userId), isNull(aiChatSessions.archivedAt)))
.orderBy(desc(aiChatSessions.updatedAt))
.limit(20);
}
export async function createChatSession(
userId: string,
title?: string,
featureContext?: Record<string, unknown>
) {
const [session] = await db
.insert(aiChatSessions)
.values({
userId,
title: title ?? "New conversation",
featureContext: featureContext ?? {},
})
.returning();
return session;
}
export async function getChatSession(userId: string, sessionId: string) {
const [session] = await db
.select()
.from(aiChatSessions)
.where(and(eq(aiChatSessions.userId, userId), eq(aiChatSessions.id, sessionId)));
if (!session) return null;
const messages = await db
.select()
.from(aiChatMessages)
.where(eq(aiChatMessages.sessionId, sessionId))
.orderBy(aiChatMessages.createdAt);
return { session, messages };
}
export async function sendChatMessage(userId: string, sessionId: string, content: string) {
const sessionData = await getChatSession(userId, sessionId);
if (!sessionData) throw new Error("Session not found");
const { session, messages } = sessionData;
await db.insert(aiChatMessages).values({
sessionId,
role: "user",
content: content.trim(),
});
const availability = await getAiAvailability(userId);
const ctx = await buildMentorContext(userId, {
userMessage: content,
feature: "mentor",
activeDate: (session.featureContext as Record<string, unknown>)?.date as string | undefined,
logContext: true,
});
let reply: string;
let offline = false;
let memoryIdsUsed = ctx.memoryIds;
if (!availability.canUse || !availability.online) {
offline = true;
reply = buildOfflineReply(content, ctx);
} else {
const history = messages
.slice(-6)
.map((m) => `${m.role}: ${m.content}`)
.join("\n");
const generated = await generateChatReply(userId, {
userMessage: content,
contextBlock: formatContextForPrompt(ctx),
history,
});
reply =
generated ??
"I'm having trouble responding right now. Your memories are still saved — try again when the AI is online.";
if (!generated) offline = true;
}
const [assistantMsg] = await db
.insert(aiChatMessages)
.values({
sessionId,
role: "assistant",
content: reply,
metadata: { memoryIdsUsed, offline },
})
.returning();
await db
.update(aiChatSessions)
.set({ updatedAt: new Date(), title: session.title === "New conversation" ? truncateTitle(content) : session.title })
.where(eq(aiChatSessions.id, sessionId));
return { message: assistantMsg, reply, memoryIdsUsed, offline };
}
function truncateTitle(text: string): string {
const t = text.trim();
return t.length > 40 ? `${t.slice(0, 37)}` : t;
}
function buildOfflineReply(message: string, ctx: Awaited<ReturnType<typeof buildMentorContext>>): string {
const lower = message.toLowerCase();
if (lower.includes("what do you know") || lower.includes("about me")) {
const memLines = ctx.memories.map((m) => `${m.title}: ${m.content}`).join("\n");
return [
"The AI is offline, but here is what I have saved locally:",
ctx.profileSummary ? `\nSummary: ${ctx.profileSummary}` : "",
memLines ? `\nMemories:\n${memLines}` : "\nNo memories saved yet.",
"\nYou can edit these in Settings → AI Memory.",
].join("");
}
return "The mentor is offline right now. Your adventure data is still here — try again when AI is online, or check Settings → AI Memory.";
}
export async function archiveChatSession(userId: string, sessionId: string) {
await db
.update(aiChatSessions)
.set({ archivedAt: new Date() })
.where(and(eq(aiChatSessions.userId, userId), eq(aiChatSessions.id, sessionId)));
}
export async function getWhatAiKnows(userId: string) {
const summary = await getProfileSummary(userId);
const memories = await listMemories(userId, { enabled: true });
const grouped: Record<string, typeof memories> = {};
for (const m of memories) {
if (!grouped[m.category]) grouped[m.category] = [];
grouped[m.category].push(m);
}
return {
summary: summary?.summary ?? "",
grouped: Object.entries(grouped).map(([category, items]) => ({
category,
label: MEMORY_CATEGORIES[category as MemoryCategory] ?? category,
items,
})),
};
}

View File

@@ -0,0 +1,116 @@
import { eq, and, desc } from "drizzle-orm";
import { db, settings, aiHealthLog } from "../db";
import {
DEFAULT_AI_BEHAVIOR,
DEFAULT_AI_PROVIDER,
type AIBehaviorConfig,
type AIProviderConfig,
type AIHealthResult,
} from "../ai/types";
import { getProvider } from "../ai/provider-registry";
import { PERSONALITY_MODIFIERS, VERBOSITY_MODIFIERS } from "../ai/prompts/defaults";
import { SETTINGS_KEYS } from "@/lib/config";
export async function getAiBehaviorConfig(userId: string): Promise<AIBehaviorConfig> {
const [row] = await db
.select()
.from(settings)
.where(and(eq(settings.userId, userId), eq(settings.key, SETTINGS_KEYS.aiConfig)));
if (!row?.value) return DEFAULT_AI_BEHAVIOR;
return { ...DEFAULT_AI_BEHAVIOR, ...(row.value as Partial<AIBehaviorConfig>) };
}
export async function getAiProviderConfig(userId: string): Promise<AIProviderConfig> {
const [row] = await db
.select()
.from(settings)
.where(and(eq(settings.userId, userId), eq(settings.key, SETTINGS_KEYS.aiProvider)));
if (!row?.value) return DEFAULT_AI_PROVIDER;
return { ...DEFAULT_AI_PROVIDER, ...(row.value as Partial<AIProviderConfig>) };
}
export async function saveAiBehaviorConfig(userId: string, config: Partial<AIBehaviorConfig>) {
const current = await getAiBehaviorConfig(userId);
const merged = { ...current, ...config };
const { sql } = await import("drizzle-orm");
await db
.insert(settings)
.values({ userId, key: SETTINGS_KEYS.aiConfig, value: merged })
.onConflictDoUpdate({
target: [settings.userId, settings.key],
set: { value: sql`excluded.value` },
});
return merged;
}
export async function saveAiProviderConfig(userId: string, config: Partial<AIProviderConfig>) {
const current = await getAiProviderConfig(userId);
const merged = { ...current, ...config };
const { sql } = await import("drizzle-orm");
await db
.insert(settings)
.values({ userId, key: SETTINGS_KEYS.aiProvider, value: merged })
.onConflictDoUpdate({
target: [settings.userId, settings.key],
set: { value: sql`excluded.value` },
});
return merged;
}
export async function getCachedHealth(userId: string): Promise<AIHealthResult | null> {
const [row] = await db
.select()
.from(settings)
.where(and(eq(settings.userId, userId), eq(settings.key, SETTINGS_KEYS.aiHealthCache)));
return (row?.value as AIHealthResult) ?? null;
}
export async function runHealthCheck(userId: string): Promise<AIHealthResult> {
const config = await getAiProviderConfig(userId);
const provider = getProvider(config.type);
const result = await provider.healthCheck(config);
const { sql } = await import("drizzle-orm");
await db.insert(aiHealthLog).values({
userId,
providerType: config.type,
status: result.status,
latencyMs: result.latencyMs,
model: config.model,
contextLength: result.contextLength,
memoryUsageMb: result.memoryUsageMb,
baseUrlSafe: result.baseUrlSafe,
errorMessage: result.lastError,
});
await db
.insert(settings)
.values({ userId, key: SETTINGS_KEYS.aiHealthCache, value: result })
.onConflictDoUpdate({
target: [settings.userId, settings.key],
set: { value: sql`excluded.value` },
});
return result;
}
export async function getLastHealthLogs(userId: string, limit = 10) {
return db
.select()
.from(aiHealthLog)
.where(eq(aiHealthLog.userId, userId))
.orderBy(desc(aiHealthLog.checkedAt))
.limit(limit);
}
export function buildSystemPrompt(
behavior: AIBehaviorConfig,
corePrompt: string,
rolePrompt?: string
): string {
const parts = [corePrompt];
if (rolePrompt) parts.push(rolePrompt);
parts.push(PERSONALITY_MODIFIERS[behavior.personality] ?? "");
parts.push(VERBOSITY_MODIFIERS[behavior.verbosity] ?? "");
return parts.filter(Boolean).join("\n\n");
}

View File

@@ -0,0 +1,33 @@
import { describe, it, expect } from "vitest";
import { formatContextForPrompt } from "./ai-context";
describe("formatContextForPrompt", () => {
it("includes low-energy day note", () => {
const text = formatContextForPrompt({
profileSummary: "A reader.",
memories: [],
memoryIds: [],
activeDay: { dayMode: "low_energy", isBackfilled: false },
recentActivity: "recent",
featureSlice: "",
tokenEstimate: 100,
layers: { memories: "" },
});
expect(text).toContain("low_energy");
expect(text).toContain("gentle");
});
it("includes backfill note", () => {
const text = formatContextForPrompt({
profileSummary: "",
memories: [{ id: "1", category: "likes", title: "T", content: "C" }],
memoryIds: ["1"],
activeDay: { isBackfilled: true, dayMode: "normal" },
recentActivity: "",
featureSlice: "",
tokenEstimate: 50,
layers: { memories: "[likes] T: C" },
});
expect(text).toContain("backfilled");
});
});

View File

@@ -0,0 +1,274 @@
import { format, subDays } from "date-fns";
import type { MemoryCategory, DayMode } from "@adventureos/shared";
import { MEMORY_CATEGORIES } from "@adventureos/shared";
import { requireUser } from "./user";
import { getDailyAdventure, buildDaySnapshot } from "./adventure";
import { getReflection } from "./reflection";
import { getProfileSummary, listMemories, markMemoriesUsed } from "./ai-memory";
import { getLogicalToday } from "../dates";
import { getDayBoundaryHour } from "./day-boundary";
import { db, explorations, weeklyReviews, aiContextLogs } from "../db";
import { and, eq, desc } from "drizzle-orm";
import { weekStartString } from "../dates";
const ALWAYS_INCLUDE: MemoryCategory[] = ["ai_tone", "boundaries", "current_goals"];
const KEYWORD_CATEGORY_MAP: Record<string, MemoryCategory[]> = {
read: ["reading_preferences", "likes"],
book: ["reading_preferences"],
worry: ["worries"],
anxious: ["worries"],
goal: ["current_goals", "long_term_goals"],
learn: ["learning_interests", "likes"],
exercise: ["exercise_preferences"],
work: ["work_study", "daily_routines"],
pray: ["spiritual_practices"],
spiritual: ["spiritual_practices"],
tired: ["discouragers", "motivators"],
focus: ["current_goals"],
};
export type MentorContextOptions = {
userMessage?: string;
feature?: string;
activeDate?: string;
topic?: string;
maxMemories?: number;
logContext?: boolean;
};
export type BuiltContext = {
profileSummary: string;
memories: { id: string; category: string; title: string; content: string }[];
memoryIds: string[];
activeDay: Record<string, unknown> | null;
recentActivity: string;
featureSlice: string;
tokenEstimate: number;
layers: Record<string, string>;
};
function estimateTokens(text: string): number {
return Math.ceil(text.length / 4);
}
function truncate(text: string, max: number): string {
return text.length <= max ? text : `${text.slice(0, max - 1)}`;
}
function scoreMemory(
m: { category: string; title: string; content: string; tags: string[] | null },
categories: Set<string>,
message: string
): number {
let score = 0;
if (categories.has(m.category)) score += 3;
if (ALWAYS_INCLUDE.includes(m.category as MemoryCategory)) score += 5;
const lower = message.toLowerCase();
if (lower.includes(m.title.toLowerCase())) score += 2;
for (const tag of m.tags ?? []) {
if (lower.includes(tag.toLowerCase())) score += 1;
}
return score;
}
function categoriesFromMessage(message: string): Set<string> {
const cats = new Set<string>(ALWAYS_INCLUDE);
const lower = message.toLowerCase();
for (const [kw, list] of Object.entries(KEYWORD_CATEGORY_MAP)) {
if (lower.includes(kw)) list.forEach((c) => cats.add(c));
}
return cats;
}
export async function buildMentorContext(
userId: string,
opts: MentorContextOptions = {}
): Promise<BuiltContext> {
const { user, progress } = await requireUser();
const boundaryHour = await getDayBoundaryHour(userId);
const activeDate = opts.activeDate ?? getLogicalToday(boundaryHour);
const message = opts.userMessage ?? "";
const summaryRow = await getProfileSummary(userId);
const profileSummary = truncate(summaryRow?.summary ?? "", 1200);
const allMemories = await listMemories(userId, { enabled: true });
const targetCats = categoriesFromMessage(message);
const scored = allMemories
.map((m) => ({ m, score: scoreMemory(m, targetCats, message) }))
.sort((a, b) => b.score - a.score);
const maxMem = opts.maxMemories ?? 12;
const selected = scored.slice(0, maxMem).map((s) => s.m);
const memoryIds = selected.map((m) => m.id);
const memories = selected.map((m) => ({
id: m.id,
category: m.category,
title: m.title,
content: truncate(m.content, 120),
}));
let activeDay: Record<string, unknown> | null = null;
try {
const { adventure, items } = await getDailyAdventure(userId, activeDate);
activeDay = {
date: activeDate,
dayMode: adventure.dayMode ?? "normal",
isRestDay: adventure.isRestDay,
isBackfilled: adventure.isBackfilled,
itemsLogged: items.filter((i) => i.state !== "blank").length,
itemCount: items.filter((i) => i.enabled && i.type !== "note").length,
};
} catch {
activeDay = { date: activeDate };
}
const digestParts: string[] = [];
for (let i = 0; i < 5; i++) {
const d = format(subDays(new Date(activeDate), i), "yyyy-MM-dd");
const snap = await buildDaySnapshot(userId, d);
const ref = await getReflection(userId, d);
digestParts.push(
`${d}: touched ${snap.adventureTouched}/${snap.adventureSlots}, work ${snap.workHours}h, read ${snap.pagesRead}p` +
(ref?.learned ? `, learned: ${truncate(ref.learned, 60)}` : "")
);
}
const recentActivity = truncate(digestParts.join("\n"), 800);
let featureSlice = "";
if (opts.feature === "teacher" && opts.topic) {
featureSlice = `Teacher topic: ${opts.topic}`;
} else if (opts.feature === "review") {
featureSlice = `Weekly review for user level ${progress?.level}`;
} else if (opts.feature === "quest") {
featureSlice = "Generating daily quests";
} else if (opts.feature === "cartographer") {
featureSlice = "Cartographer weekly explorations";
}
const layers: Record<string, string> = {
userName: user.displayName,
profileSummary,
memories: memories.map((m) => `[${m.category}] ${m.title}: ${m.content}`).join("\n"),
activeDay: JSON.stringify(activeDay),
recentActivity,
featureSlice,
};
const tokenEstimate = Object.values(layers).reduce((s, v) => s + estimateTokens(v), 0);
if (memoryIds.length > 0) {
await markMemoriesUsed(userId, memoryIds);
}
if (opts.logContext) {
await db.insert(aiContextLogs).values({
userId,
feature: opts.feature ?? "mentor",
memoryIds,
tokenEstimate,
layers,
});
}
return {
profileSummary,
memories,
memoryIds,
activeDay,
recentActivity,
featureSlice,
tokenEstimate,
layers,
};
}
/** Legacy context for quest/review generation — enriched with memory. */
export async function buildAiContext(userId: string) {
const { progress } = await requireUser();
const ctx = await buildMentorContext(userId, { feature: "quest" });
const days = [];
for (let i = 0; i < 7; i++) {
const d = format(subDays(new Date(), i), "yyyy-MM-dd");
days.push(await buildDaySnapshot(userId, d));
}
const reflections = [];
for (let i = 0; i < 3; i++) {
const d = format(subDays(new Date(), i), "yyyy-MM-dd");
const r = await getReflection(userId, d);
if (r) reflections.push({ date: d, learned: r.learned });
}
const lastReview = await db
.select()
.from(weeklyReviews)
.where(eq(weeklyReviews.userId, userId))
.orderBy(desc(weeklyReviews.weekStart))
.limit(1);
const weekOf = weekStartString();
const thisWeekExplorations = await db
.select()
.from(explorations)
.where(and(eq(explorations.userId, userId), eq(explorations.weekOf, weekOf)));
const priorWeekOf = format(subDays(new Date(weekOf), 7), "yyyy-MM-dd");
const priorWeekExplorations = await db
.select()
.from(explorations)
.where(and(eq(explorations.userId, userId), eq(explorations.weekOf, priorWeekOf)));
const recentlyDenied = [...thisWeekExplorations, ...priorWeekExplorations]
.filter((e) => e.status === "dismissed")
.map((e) => e.title);
return {
level: progress?.level,
chapter: progress?.currentChapter,
scores: {
consistency: progress?.consistencyScore,
reading: progress?.readingScore,
},
last7Days: days,
reflections,
lastIntention: lastReview[0]?.userIntention,
explorations: {
thisWeek: thisWeekExplorations.map((e) => ({
title: e.title,
status: e.status,
})),
recentlyDenied,
},
personalMemory: {
profileSummary: ctx.profileSummary,
memories: ctx.memories,
},
dayModes: days.map((d) => ({ date: d.date })),
};
}
export function formatContextForPrompt(ctx: BuiltContext): string {
const dayNote =
ctx.activeDay?.dayMode && ctx.activeDay.dayMode !== "normal"
? `Note: User marked this as a ${ctx.activeDay.dayMode} day. Be gentle, not judgmental.`
: ctx.activeDay?.isBackfilled
? "Note: User logged this day later (backfilled). Acknowledge continuity, not failure."
: "";
return [
`Profile: ${ctx.profileSummary}`,
ctx.memories.length ? `Memories:\n${ctx.layers.memories}` : "",
`Today: ${ctx.layers.activeDay}`,
dayNote,
`Recent:\n${ctx.recentActivity}`,
ctx.featureSlice,
]
.filter(Boolean)
.join("\n\n");
}
export { MEMORY_CATEGORIES };

View File

@@ -0,0 +1,13 @@
import { describe, it, expect } from "vitest";
import { MEMORY_CATEGORIES, DEFAULT_AI_MEMORY_LEARNING } from "@adventureos/shared";
describe("memory constants", () => {
it("has 20 memory categories", () => {
expect(Object.keys(MEMORY_CATEGORIES)).toHaveLength(20);
});
it("defaults learning to opt-in", () => {
expect(DEFAULT_AI_MEMORY_LEARNING.learningEnabled).toBe(false);
expect(DEFAULT_AI_MEMORY_LEARNING.requireApproval).toBe(true);
});
});

View File

@@ -0,0 +1,321 @@
import { and, eq, isNull, desc, ilike, or, inArray } from "drizzle-orm";
import {
type MemoryCategory,
type MemorySourceType,
type MemorySensitivity,
type AiProfileSummary,
type AiMemoryLearningSettings,
DEFAULT_AI_MEMORY_LEARNING,
MEMORY_CATEGORIES,
} from "@adventureos/shared";
import { db, aiMemories, aiMemorySuggestions } from "../db";
import { SETTINGS_KEYS } from "@/lib/config";
import { getSettingValue, upsertSetting } from "@/lib/repositories/settings.repository";
export type MemoryRow = typeof aiMemories.$inferSelect;
export type MemoryInput = {
category: MemoryCategory;
title: string;
content: string;
sourceType?: MemorySourceType;
sourceRef?: { type: string; id?: string; date?: string };
confidence?: number;
userVerified?: boolean;
enabled?: boolean;
sensitivity?: MemorySensitivity;
tags?: string[];
};
export async function listMemories(
userId: string,
opts: {
category?: string;
q?: string;
enabled?: boolean;
includeArchived?: boolean;
} = {}
) {
const conditions = [eq(aiMemories.userId, userId)];
if (!opts.includeArchived) conditions.push(isNull(aiMemories.archivedAt));
if (opts.category) conditions.push(eq(aiMemories.category, opts.category));
if (opts.enabled !== undefined) conditions.push(eq(aiMemories.enabled, opts.enabled));
if (opts.q?.trim()) {
const pattern = `%${opts.q.trim()}%`;
conditions.push(or(ilike(aiMemories.title, pattern), ilike(aiMemories.content, pattern))!);
}
return db
.select()
.from(aiMemories)
.where(and(...conditions))
.orderBy(desc(aiMemories.updatedAt));
}
export async function getMemory(userId: string, id: string) {
const [row] = await db
.select()
.from(aiMemories)
.where(and(eq(aiMemories.userId, userId), eq(aiMemories.id, id)));
return row ?? null;
}
export async function createMemory(userId: string, input: MemoryInput) {
if (!(input.category in MEMORY_CATEGORIES)) {
throw new Error("Invalid memory category");
}
const [row] = await db
.insert(aiMemories)
.values({
userId,
category: input.category,
title: input.title.trim(),
content: input.content.trim(),
sourceType: input.sourceType ?? "manual",
sourceRef: input.sourceRef,
confidence: String(input.confidence ?? 1),
userVerified: input.userVerified ?? true,
enabled: input.enabled ?? true,
sensitivity: input.sensitivity ?? "normal",
tags: input.tags ?? [],
})
.returning();
return row;
}
export async function updateMemory(
userId: string,
id: string,
updates: Partial<MemoryInput> & { markWrong?: boolean }
) {
const existing = await getMemory(userId, id);
if (!existing) throw new Error("Memory not found");
if (updates.markWrong) {
await db
.update(aiMemories)
.set({ archivedAt: new Date(), enabled: false, updatedAt: new Date() })
.where(eq(aiMemories.id, id));
return { archived: true };
}
const set: Record<string, unknown> = { updatedAt: new Date() };
if (updates.title !== undefined) set.title = updates.title.trim();
if (updates.content !== undefined) set.content = updates.content.trim();
if (updates.category !== undefined) set.category = updates.category;
if (updates.enabled !== undefined) set.enabled = updates.enabled;
if (updates.tags !== undefined) set.tags = updates.tags;
if (updates.sensitivity !== undefined) set.sensitivity = updates.sensitivity;
if (updates.userVerified !== undefined) set.userVerified = updates.userVerified;
await db.update(aiMemories).set(set).where(eq(aiMemories.id, id));
return getMemory(userId, id);
}
export async function archiveMemory(userId: string, id: string) {
await db
.update(aiMemories)
.set({ archivedAt: new Date(), enabled: false, updatedAt: new Date() })
.where(and(eq(aiMemories.userId, userId), eq(aiMemories.id, id)));
}
export async function resetAllMemories(userId: string) {
await db
.update(aiMemories)
.set({ archivedAt: new Date(), enabled: false, updatedAt: new Date() })
.where(and(eq(aiMemories.userId, userId), isNull(aiMemories.archivedAt)));
await db
.update(aiMemorySuggestions)
.set({ status: "ignored", reviewedAt: new Date() })
.where(and(eq(aiMemorySuggestions.userId, userId), eq(aiMemorySuggestions.status, "pending")));
await upsertSetting(userId, SETTINGS_KEYS.aiProfileSummary, {
summary: "",
generatedAt: new Date().toISOString(),
sourceMemoryCount: 0,
version: 0,
});
}
export async function getProfileSummary(userId: string): Promise<AiProfileSummary | null> {
const value = (await getSettingValue(userId, SETTINGS_KEYS.aiProfileSummary)) as AiProfileSummary | null;
return value ?? null;
}
export async function saveProfileSummary(userId: string, summary: string) {
const existing = (await getProfileSummary(userId)) ?? {
summary: "",
generatedAt: new Date().toISOString(),
sourceMemoryCount: 0,
version: 0,
};
const next: AiProfileSummary = {
...existing,
summary: summary.trim(),
generatedAt: new Date().toISOString(),
version: existing.version + 1,
};
await upsertSetting(userId, SETTINGS_KEYS.aiProfileSummary, next);
return next;
}
export async function getMemoryLearningSettings(userId: string): Promise<AiMemoryLearningSettings> {
const value = (await getSettingValue(userId, SETTINGS_KEYS.aiMemoryLearning)) as
| Partial<AiMemoryLearningSettings>
| null;
return { ...DEFAULT_AI_MEMORY_LEARNING, ...value };
}
export async function saveMemoryLearningSettings(
userId: string,
settings: Partial<AiMemoryLearningSettings>
) {
const current = await getMemoryLearningSettings(userId);
const next = { ...current, ...settings };
await upsertSetting(userId, SETTINGS_KEYS.aiMemoryLearning, next);
return next;
}
export async function listSuggestions(userId: string, status = "pending") {
return db
.select()
.from(aiMemorySuggestions)
.where(and(eq(aiMemorySuggestions.userId, userId), eq(aiMemorySuggestions.status, status)))
.orderBy(desc(aiMemorySuggestions.createdAt));
}
export async function acceptSuggestion(
userId: string,
suggestionId: string,
edits?: Partial<MemoryInput>
) {
const [suggestion] = await db
.select()
.from(aiMemorySuggestions)
.where(
and(eq(aiMemorySuggestions.userId, userId), eq(aiMemorySuggestions.id, suggestionId))
);
if (!suggestion) throw new Error("Suggestion not found");
const memory = await createMemory(userId, {
category: (edits?.category ?? suggestion.category) as MemoryCategory,
title: edits?.title ?? suggestion.title,
content: edits?.content ?? suggestion.content,
sourceType: suggestion.sourceType as MemorySourceType,
sourceRef: suggestion.sourceRef ?? undefined,
confidence: Number(suggestion.confidence),
userVerified: true,
});
await db
.update(aiMemorySuggestions)
.set({ status: "accepted", reviewedAt: new Date() })
.where(eq(aiMemorySuggestions.id, suggestionId));
return memory;
}
export async function rejectSuggestion(userId: string, suggestionId: string) {
await db
.update(aiMemorySuggestions)
.set({ status: "rejected", reviewedAt: new Date() })
.where(
and(eq(aiMemorySuggestions.userId, userId), eq(aiMemorySuggestions.id, suggestionId))
);
}
export async function ignoreSuggestion(userId: string, suggestionId: string) {
await db
.update(aiMemorySuggestions)
.set({ status: "ignored", reviewedAt: new Date() })
.where(
and(eq(aiMemorySuggestions.userId, userId), eq(aiMemorySuggestions.id, suggestionId))
);
}
export async function createSuggestion(
userId: string,
input: {
category: MemoryCategory;
title: string;
content: string;
sourceType: MemorySourceType;
sourceRef?: { type: string; id?: string; date?: string };
confidence?: number;
}
) {
const settings = await getMemoryLearningSettings(userId);
if (!settings.learningEnabled) return null;
const pending = await listSuggestions(userId, "pending");
if (pending.length >= settings.maxPendingSuggestions) return null;
const dup = await db
.select()
.from(aiMemories)
.where(
and(
eq(aiMemories.userId, userId),
isNull(aiMemories.archivedAt),
ilike(aiMemories.content, `%${input.content.slice(0, 40)}%`)
)
)
.limit(1);
if (dup.length > 0) return null;
const [row] = await db
.insert(aiMemorySuggestions)
.values({
userId,
category: input.category,
title: input.title.trim(),
content: input.content.trim(),
sourceType: input.sourceType,
sourceRef: input.sourceRef,
confidence: String(input.confidence ?? 0.7),
status: "pending",
})
.returning();
return row;
}
export async function markMemoriesUsed(userId: string, memoryIds: string[]) {
if (memoryIds.length === 0) return;
await db
.update(aiMemories)
.set({ lastUsedAt: new Date() })
.where(and(eq(aiMemories.userId, userId), inArray(aiMemories.id, memoryIds)));
}
export async function exportMemories(userId: string) {
const memories = await listMemories(userId, { includeArchived: false });
const summary = await getProfileSummary(userId);
const learning = await getMemoryLearningSettings(userId);
return { memories, summary, learning };
}
export async function importMemories(
userId: string,
data: { memories: MemoryInput[] },
overwrite = false
) {
let imported = 0;
for (const m of data.memories) {
if (!overwrite) {
const existing = await db
.select()
.from(aiMemories)
.where(
and(
eq(aiMemories.userId, userId),
eq(aiMemories.title, m.title),
isNull(aiMemories.archivedAt)
)
)
.limit(1);
if (existing.length > 0) continue;
}
await createMemory(userId, { ...m, sourceType: "import" });
imported++;
}
return { imported };
}

View File

@@ -0,0 +1,163 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { teacherSchema } from "./ai";
vi.mock("./ai-config", () => ({
getAiBehaviorConfig: vi.fn(),
getAiProviderConfig: vi.fn(),
buildSystemPrompt: vi.fn(() => "system"),
}));
vi.mock("./ai-templates", () => ({
getTemplateBody: vi.fn(async () => "topic: {{topic}}"),
}));
vi.mock("../ai/provider-registry", () => ({
getProvider: vi.fn(),
}));
vi.mock("./user", () => ({
requireUser: vi.fn(async () => ({ user: { id: "user-1" } })),
}));
import { getAiBehaviorConfig, getAiProviderConfig } from "./ai-config";
import { getProvider } from "../ai/provider-registry";
describe("teacherSchema", () => {
it("coerces string quiz answers to numbers", () => {
const parsed = teacherSchema.parse({
flashcards: [{ front: "Q", back: "A" }],
quiz: [{ question: "Q?", options: ["a", "b"], answer: "0" }],
assignment: "Read",
});
expect(parsed.quiz[0].answer).toBe(0);
});
it("accepts optional structured fields", () => {
const parsed = teacherSchema.parse({
title: "Roman Roads",
introduction: "A brief intro",
objectives: ["Learn basics"],
readingSteps: ["Find a source"],
reflectionPrompt: "What surprised you?",
flashcards: [{ front: "Q", back: "A" }],
quiz: [{ question: "Q?", options: ["a", "b"], answer: 0 }],
assignment: "Research",
});
expect(parsed.title).toBe("Roman Roads");
});
});
describe("generateTeacherContent", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
});
it("throws for empty topic", async () => {
const { generateTeacherContent } = await import("./ai");
await expect(generateTeacherContent(" ", "user-1")).rejects.toThrow(
"Topic is required"
);
});
it("returns AI content when provider succeeds", async () => {
vi.mocked(getAiBehaviorConfig).mockResolvedValue({
enabled: true,
creativity: 0.7,
personality: "supportive_mentor",
verbosity: "balanced",
strictMode: false,
});
vi.mocked(getAiProviderConfig).mockResolvedValue({
enabled: true,
type: "ollama",
baseUrl: "http://localhost:11434",
model: "test",
maxTokens: 1000,
temperature: 0.7,
timeoutMs: 30000,
});
vi.mocked(getProvider).mockReturnValue({
type: "ollama",
healthCheck: vi.fn(async () => ({ status: "online" as const })),
listModels: vi.fn(async () => []),
generateText: vi.fn(async () => ({
text: JSON.stringify({
flashcards: [{ front: "What is Rust?", back: "A systems language" }],
quiz: [
{
question: "Rust is?",
options: ["Fast", "Slow"],
answer: 0,
},
],
assignment: "Read chapter 1",
}),
model: "test",
latencyMs: 10,
})),
});
const { generateTeacherContent } = await import("./ai");
const result = await generateTeacherContent("Rust ownership", "user-1");
expect(result.source).toBe("ai");
expect(result.content.flashcards[0].back).toContain("systems language");
});
it("returns fallback when AI is disabled", async () => {
vi.mocked(getAiBehaviorConfig).mockResolvedValue({
enabled: false,
creativity: 0.7,
personality: "supportive_mentor",
verbosity: "balanced",
strictMode: false,
});
vi.mocked(getAiProviderConfig).mockResolvedValue({
enabled: true,
type: "ollama",
baseUrl: "http://localhost:11434",
model: "test",
maxTokens: 1000,
temperature: 0.7,
timeoutMs: 30000,
});
const { generateTeacherContent } = await import("./ai");
const result = await generateTeacherContent("Stoicism", "user-1");
expect(result.source).toBe("fallback");
expect(result.content.assignment).toContain("Stoicism");
});
it("returns fallback when AI JSON is invalid and strictMode is off", async () => {
vi.mocked(getAiBehaviorConfig).mockResolvedValue({
enabled: true,
creativity: 0.7,
personality: "supportive_mentor",
verbosity: "balanced",
strictMode: false,
});
vi.mocked(getAiProviderConfig).mockResolvedValue({
enabled: true,
type: "ollama",
baseUrl: "http://localhost:11434",
model: "test",
maxTokens: 1000,
temperature: 0.7,
timeoutMs: 30000,
});
vi.mocked(getProvider).mockReturnValue({
type: "ollama",
healthCheck: vi.fn(async () => ({ status: "online" as const })),
listModels: vi.fn(async () => []),
generateText: vi.fn(async () => ({
text: "not valid json",
model: "test",
latencyMs: 10,
})),
});
const { generateTeacherContent } = await import("./ai");
const result = await generateTeacherContent("Stoicism", "user-1");
expect(result.source).toBe("fallback");
});
});

View File

@@ -0,0 +1,162 @@
import { eq, and, desc } from "drizzle-orm";
import { db, aiPromptTemplates, aiPromptVersions } from "../db";
import { PROMPT_DEFAULTS } from "../ai/prompts/defaults";
import { renderTemplate, SAMPLE_PREVIEW_DATA } from "../ai/prompts/render";
export async function seedPromptTemplatesForUser(userId: string) {
for (const [key, def] of Object.entries(PROMPT_DEFAULTS)) {
const [existing] = await db
.select()
.from(aiPromptTemplates)
.where(and(eq(aiPromptTemplates.userId, userId), eq(aiPromptTemplates.key, key)));
if (existing) continue;
const [template] = await db
.insert(aiPromptTemplates)
.values({
userId,
key,
name: def.name,
description: def.description,
category: def.category,
body: def.body,
enabled: true,
version: 1,
isDefaultOverride: false,
})
.returning();
await db.insert(aiPromptVersions).values({
templateId: template.id,
version: 1,
body: def.body,
createdBy: "seed",
});
}
}
export async function getPromptTemplates(userId: string) {
await seedPromptTemplatesForUser(userId);
return db
.select()
.from(aiPromptTemplates)
.where(eq(aiPromptTemplates.userId, userId))
.orderBy(aiPromptTemplates.category, aiPromptTemplates.name);
}
export async function getPromptTemplate(userId: string, key: string) {
await seedPromptTemplatesForUser(userId);
const [template] = await db
.select()
.from(aiPromptTemplates)
.where(and(eq(aiPromptTemplates.userId, userId), eq(aiPromptTemplates.key, key)));
return template ?? null;
}
export async function getTemplateBody(userId: string, key: string): Promise<string> {
const template = await getPromptTemplate(userId, key);
if (template?.enabled) return template.body;
return PROMPT_DEFAULTS[key]?.body ?? "";
}
export async function updatePromptTemplate(
userId: string,
key: string,
updates: { body?: string; enabled?: boolean; name?: string; description?: string }
) {
const template = await getPromptTemplate(userId, key);
if (!template) throw new Error("Template not found");
const newVersion = template.version + 1;
const newBody = updates.body ?? template.body;
await db
.update(aiPromptTemplates)
.set({
body: newBody,
enabled: updates.enabled ?? template.enabled,
name: updates.name ?? template.name,
description: updates.description ?? template.description,
version: updates.body ? newVersion : template.version,
isDefaultOverride: updates.body ? true : template.isDefaultOverride,
updatedAt: new Date(),
})
.where(eq(aiPromptTemplates.id, template.id));
if (updates.body) {
await db.insert(aiPromptVersions).values({
templateId: template.id,
version: newVersion,
body: newBody,
createdBy: "user",
});
}
return getPromptTemplate(userId, key);
}
export async function resetPromptTemplate(userId: string, key: string) {
const def = PROMPT_DEFAULTS[key];
if (!def) throw new Error("Unknown template key");
const template = await getPromptTemplate(userId, key);
if (!template) throw new Error("Template not found");
const newVersion = template.version + 1;
await db
.update(aiPromptTemplates)
.set({
body: def.body,
version: newVersion,
isDefaultOverride: false,
updatedAt: new Date(),
})
.where(eq(aiPromptTemplates.id, template.id));
await db.insert(aiPromptVersions).values({
templateId: template.id,
version: newVersion,
body: def.body,
createdBy: "reset",
});
return getPromptTemplate(userId, key);
}
export async function getTemplateVersions(userId: string, key: string) {
const template = await getPromptTemplate(userId, key);
if (!template) return [];
return db
.select()
.from(aiPromptVersions)
.where(eq(aiPromptVersions.templateId, template.id))
.orderBy(desc(aiPromptVersions.version));
}
export async function previewTemplate(userId: string, key: string, body?: string) {
const template = await getPromptTemplate(userId, key);
const text = body ?? template?.body ?? PROMPT_DEFAULTS[key]?.body ?? "";
return renderTemplate(text, SAMPLE_PREVIEW_DATA);
}
export async function restoreTemplateVersion(
userId: string,
key: string,
version: number
) {
const template = await getPromptTemplate(userId, key);
if (!template) throw new Error("Template not found");
const [ver] = await db
.select()
.from(aiPromptVersions)
.where(
and(
eq(aiPromptVersions.templateId, template.id),
eq(aiPromptVersions.version, version)
)
);
if (!ver) throw new Error("Version not found");
return updatePromptTemplate(userId, key, { body: ver.body });
}

427
apps/web/src/lib/services/ai.ts Executable file
View File

@@ -0,0 +1,427 @@
import { z } from "zod";
import {
pickRandomExplorations,
pickRandomQuests,
} from "@adventureos/shared";
import { getProvider } from "../ai/provider-registry";
import {
debugAiLog,
isAiTimeoutError,
normalizeExplorationPayload,
normalizeTeacherPayload,
safeParseAiJson,
} from "../ai/ai-normalize";
import { parseAiJson } from "../ai/parse-json";
import {
getAiBehaviorConfig,
getAiProviderConfig,
buildSystemPrompt,
} from "./ai-config";
import { getTemplateBody } from "./ai-templates";
import { renderTemplate } from "../ai/prompts/render";
import { requireUser } from "./user";
import { env } from "@/lib/config";
const questSchema = z.object({
quests: z
.array(
z.object({
title: z.string(),
reason: z.string(),
xp_hint: z.string(),
category: z.string(),
})
)
.max(3),
});
const explorationSchema = z.object({
explorations: z
.array(
z.object({
title: z.string(),
hook: z.string(),
category: z.string(),
minutes: z.coerce.number(),
})
)
.max(5),
});
const mentorSchema = z.object({
patterns: z.array(z.string()),
encouragement: z.string(),
focus_suggestion: z.string(),
letter: z.string(),
});
export const teacherSchema = z.object({
title: z.string().optional(),
introduction: z.string().optional(),
objectives: z.array(z.string()).optional(),
readingSteps: z.array(z.string()).optional(),
reflectionPrompt: z.string().optional(),
flashcards: z.array(z.object({ front: z.string(), back: z.string() })),
quiz: z.array(
z.object({
question: z.string(),
options: z.array(z.string()),
answer: z.coerce.number(),
})
),
assignment: z.string(),
});
export type TeacherContent = z.infer<typeof teacherSchema>;
export type AiContentSource = "ai" | "fallback";
export type AiFallbackReason = "offline" | "model_unavailable" | "parse_failed" | "generation_failed" | "timeout";
export type TeacherGenerationResult = {
content: TeacherContent;
source: AiContentSource;
fallbackReason?: AiFallbackReason;
};
export type ExplorationGenerationResult = {
explorations: z.infer<typeof explorationSchema>["explorations"];
source: AiContentSource;
fallbackReason?: AiFallbackReason;
};
async function getUserId(): Promise<string> {
const { user } = await requireUser();
return user.id;
}
let lastAiFallbackReason: AiFallbackReason | null = null;
export function getLastAiFallbackReason(): AiFallbackReason | null {
return lastAiFallbackReason;
}
export async function getAiAvailability(userId: string) {
const behavior = await getAiBehaviorConfig(userId);
if (!behavior.enabled) return { canUse: false, online: false };
const providerConfig = await getAiProviderConfig(userId);
if (!providerConfig.enabled) return { canUse: false, online: false };
try {
const provider = getProvider(providerConfig.type);
const health = await provider.healthCheck(providerConfig);
return { canUse: true, online: health.status === "online" };
} catch {
return { canUse: true, online: false };
}
}
async function aiGenerate(
userId: string,
templateKey: string,
systemKey: string,
roleKey: string | undefined,
data: Record<string, unknown>,
modelOverride?: string,
format: "json" | "text" = "json"
): Promise<string | null> {
lastAiFallbackReason = null;
const behavior = await getAiBehaviorConfig(userId);
if (!behavior.enabled) return null;
const providerConfig = await getAiProviderConfig(userId);
if (!providerConfig.enabled) return null;
const templateBody = await getTemplateBody(userId, templateKey);
const coreSystem = await getTemplateBody(userId, systemKey);
const roleSystem = roleKey ? await getTemplateBody(userId, roleKey) : undefined;
const system = buildSystemPrompt(behavior, coreSystem, roleSystem);
const { user } = await requireUser();
const prompt = renderTemplate(templateBody, {
...data,
user_name: user.displayName,
context: data.context ?? data,
});
try {
const provider = getProvider(providerConfig.type);
const model =
modelOverride ??
(templateKey === "weekly_review"
? env.ollamaModelProse
: providerConfig.model);
const res = await provider.generateText(providerConfig, {
model,
prompt,
system,
temperature: behavior.creativity,
maxTokens: providerConfig.maxTokens,
format,
timeoutMs: providerConfig.timeoutMs,
});
return res.text;
} catch (e) {
if (behavior.strictMode) throw e;
lastAiFallbackReason = isAiTimeoutError(e) ? "timeout" : "generation_failed";
debugAiLog("ai.ts:aiGenerate", "generation failed", {
templateKey,
timedOut: lastAiFallbackReason === "timeout",
error: e instanceof Error ? e.message : "unknown",
});
console.error("AI generation failed:", e);
return null;
}
}
function hasMeaningfulTeacherContent(content: TeacherContent): boolean {
return (
content.assignment.trim().length > 0 ||
content.flashcards.length > 0 ||
content.quiz.length > 0
);
}
function parseTeacherResponse(raw: string): TeacherContent | null {
const parsed = safeParseAiJson(raw);
if (!parsed) return null;
const normalized = normalizeTeacherPayload(parsed);
const result = teacherSchema.safeParse(normalized);
if (!result.success) {
debugAiLog("ai.ts:parseTeacherResponse", "schema failed", {
issues: result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`),
});
return null;
}
if (!hasMeaningfulTeacherContent(result.data)) return null;
return result.data;
}
function parseExplorationResponse(raw: string) {
const parsed = safeParseAiJson(raw);
if (!parsed) return null;
const normalized = normalizeExplorationPayload(parsed);
const result = explorationSchema.safeParse(normalized);
if (!result.success) {
debugAiLog("ai.ts:parseExplorationResponse", "schema failed", {
issues: result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`),
});
return null;
}
if (result.data.explorations.length === 0) return null;
return result.data.explorations;
}
function teacherFallback(topic: string): TeacherContent {
return {
flashcards: [
{
front: `What is ${topic}?`,
back: "Explore this topic through reading and observation.",
},
],
quiz: [
{
question: `Which approach helps you learn about ${topic}?`,
options: ["Curious reading", "Giving up", "Ignoring it", "Rushing"],
answer: 0,
},
],
assignment: `Spend 20 minutes researching ${topic}. Write one sentence about what surprised you.`,
};
}
export async function generateQuests(context: Record<string, unknown>, userId?: string) {
const uid = userId ?? (await getUserId());
const raw = await aiGenerate(
uid,
"quest_generation",
"system_core",
"system_quest",
{ context: JSON.stringify(context) }
);
if (raw) {
try {
const parsed = questSchema.parse(parseAiJson(raw));
return parsed.quests;
} catch {
/* fallback */
}
}
return pickRandomQuests(3);
}
export async function generateExplorations(
context: Record<string, unknown>,
userId?: string
): Promise<ExplorationGenerationResult> {
const uid = userId ?? (await getUserId());
const behavior = await getAiBehaviorConfig(uid);
const availability = await getAiAvailability(uid);
const raw = await aiGenerate(
uid,
"exploration_generation",
"system_core",
"system_quest",
{ context: JSON.stringify(context) }
);
if (raw) {
const explorations = parseExplorationResponse(raw);
if (explorations) {
return { explorations, source: "ai" };
}
debugAiLog("ai.ts:generateExplorations", "parse failed", {
online: availability.online,
canUse: availability.canUse,
rawLen: raw.length,
});
if (behavior.strictMode && availability.canUse && availability.online) {
throw new Error("AI returned invalid exploration data. Try again.");
}
} else {
debugAiLog("ai.ts:generateExplorations", "no raw response", {
online: availability.online,
canUse: availability.canUse,
});
if (behavior.strictMode && availability.canUse && availability.online) {
throw new Error("AI generation failed");
}
}
return { explorations: pickRandomExplorations(4), source: "fallback", fallbackReason: lastAiFallbackReason ?? "generation_failed" };
}
export async function generateMentorReview(context: Record<string, unknown>, userId?: string) {
const uid = userId ?? (await getUserId());
const raw = await aiGenerate(
uid,
"weekly_review",
"system_core",
"system_weekly_review",
{ context: JSON.stringify(context) },
env.ollamaModelProse
);
if (raw) {
try {
return mentorSchema.parse(parseAiJson(raw));
} catch {
/* fallback */
}
}
return {
patterns: ["You showed up when you could — that matters."],
encouragement: "Consistency is built in small moments, not perfect weeks.",
focus_suggestion: "Pick one habit to nurture gently next week.",
letter: `Dear Traveler,\n\nAnother week on the long road. Some days you moved forward with purpose; others you rested or tended to what life asked of you. Both belong in a life well lived.\n\nWhat matters is that you are still here, still building, still curious. Next week, choose one small anchor — reading, movement, or prayer — and let everything else orbit around it gently.\n\nThe adventure continues.\n\n— Your Guide`,
};
}
export async function generateTeacherContent(
topic: string,
userId?: string,
personalContext?: string
): Promise<TeacherGenerationResult> {
const trimmed = topic.trim();
if (!trimmed) {
throw new Error("Topic is required");
}
const uid = userId ?? (await getUserId());
const behavior = await getAiBehaviorConfig(uid);
const availability = await getAiAvailability(uid);
const raw = await aiGenerate(
uid,
"homework_generation",
"system_core",
"system_teacher",
{ topic: trimmed, context: personalContext ?? "" }
);
if (raw) {
const content = parseTeacherResponse(raw);
if (content) {
return { content, source: "ai" };
}
debugAiLog("ai.ts:generateTeacherContent", "parse failed", {
online: availability.online,
canUse: availability.canUse,
rawLen: raw.length,
});
if (behavior.strictMode && availability.canUse && availability.online) {
throw new Error("AI returned invalid lesson content. Try again.");
}
} else {
debugAiLog("ai.ts:generateTeacherContent", "no raw response", {
online: availability.online,
canUse: availability.canUse,
});
if (behavior.strictMode && availability.canUse && availability.online) {
throw new Error("AI generation failed");
}
}
return { content: teacherFallback(trimmed), source: "fallback", fallbackReason: lastAiFallbackReason ?? "generation_failed" };
}
export async function isOllamaAvailable(): Promise<boolean> {
try {
const userId = await getUserId();
const availability = await getAiAvailability(userId);
return availability.online;
} catch {
return false;
}
}
export async function isAiAvailable(): Promise<boolean> {
return isOllamaAvailable();
}
export async function generateChatReply(
userId: string,
input: { userMessage: string; contextBlock: string; history?: string }
): Promise<string | null> {
const behavior = await getAiBehaviorConfig(userId);
if (!behavior.enabled) return null;
const providerConfig = await getAiProviderConfig(userId);
if (!providerConfig.enabled) return null;
try {
const coreSystem = await getTemplateBody(userId, "system_core");
let roleSystem: string;
try {
roleSystem = await getTemplateBody(userId, "system_mentor");
} catch {
roleSystem = "You are a calm mentor who remembers the user's journey. Never shame. Never pretend to know what you don't. Be grounded and encouraging.";
}
const system = `${buildSystemPrompt(behavior, coreSystem, roleSystem)}\n\nPersonal context (use naturally, do not dump):\n${input.contextBlock}`;
const prompt = [
input.history ? `Recent conversation:\n${input.history}\n` : "",
`User: ${input.userMessage}`,
"Respond as the mentor in plain text (no JSON). Keep it concise for a local model.",
]
.filter(Boolean)
.join("\n");
const provider = getProvider(providerConfig.type);
const res = await provider.generateText(providerConfig, {
model: providerConfig.model,
prompt,
system,
format: "text",
temperature: behavior.creativity,
maxTokens: Math.min(providerConfig.maxTokens, 800),
timeoutMs: providerConfig.timeoutMs,
});
return res.text.trim() || null;
} catch (e) {
if (behavior.strictMode) throw e;
console.error("Chat generation failed:", e);
return null;
}
}

View File

@@ -0,0 +1,103 @@
import { and, eq } from "drizzle-orm";
import { db, readingProgress, readingLogs } from "../db";
import { todayString } from "../dates";
import { recordReadingLogAction } from "./reading/log-pages";
export async function getReadingProgress(userId: string) {
return db.select().from(readingProgress).where(eq(readingProgress.userId, userId));
}
export async function getProgressForBook(userId: string, calibreBookId: number) {
const [row] = await db
.select()
.from(readingProgress)
.where(
and(eq(readingProgress.userId, userId), eq(readingProgress.calibreBookId, calibreBookId))
);
return row ?? null;
}
export async function upsertReadingProgress(
userId: string,
calibreBookId: number,
data: {
calibreUuid?: string;
currentPage?: number;
totalPages?: number;
status?: string;
}
) {
const existing = await getProgressForBook(userId, calibreBookId);
if (existing) {
const [updated] = await db
.update(readingProgress)
.set({
currentPage: data.currentPage ?? existing.currentPage,
totalPages: data.totalPages ?? existing.totalPages,
status: data.status ?? existing.status,
lastReadDate: todayString(),
updatedAt: new Date(),
})
.where(eq(readingProgress.id, existing.id))
.returning();
return updated;
}
const [created] = await db
.insert(readingProgress)
.values({
userId,
calibreBookId,
calibreUuid: data.calibreUuid,
currentPage: data.currentPage ?? 0,
totalPages: data.totalPages ?? 300,
status: data.status ?? "reading",
lastReadDate: todayString(),
})
.returning();
return created;
}
export async function logCalibrePages(
userId: string,
calibreBookId: number,
pages: number,
date = todayString()
) {
const progress = await getProgressForBook(userId, calibreBookId);
const beforePage = progress?.currentPage ?? 0;
const totalPages = progress?.totalPages ?? 300;
const newPage = Math.min(totalPages, beforePage + pages);
const finished = newPage >= totalPages;
const updated = await upsertReadingProgress(userId, calibreBookId, {
currentPage: newPage,
status: finished ? "finished" : "reading",
});
await db.insert(readingLogs).values({
date,
pagesRead: pages,
calibreBookId,
});
const { actionEventId } = await recordReadingLogAction({
userId,
date,
pages,
summary: `Logged ${pages} pages (Calibre #${calibreBookId})`,
entityType: "reading_progress",
entityId: updated.id,
beforeState: { currentPage: beforePage, status: progress?.status ?? "reading" },
afterState: { currentPage: newPage, status: finished ? "finished" : "reading" },
metadata: { date, calibreBookId },
xpMetadata: { calibreBookId, pages },
});
return {
currentPage: newPage,
totalPages,
status: finished ? "finished" : "reading",
actionEventId,
};
}

View File

@@ -0,0 +1,268 @@
import fs from "fs";
import path from "path";
import { env } from "@/lib/config";
export type CalibreStatus = {
configured: boolean;
online: boolean;
bookCount: number;
error?: string;
lastCheckAt: string;
};
export type CalibreBook = {
id: number;
uuid: string;
title: string;
authors: string[];
tags: string[];
comment: string | null;
series: string | null;
seriesIndex: number | null;
rating: number | null;
formats: string[];
hasCover: boolean;
};
function getMetadataDbPath(): string | null {
const libraryPath = env.calibreLibraryPath;
if (!libraryPath) return null;
const explicit = env.calibreMetadataDbPath;
if (explicit) return explicit;
return path.join(libraryPath, "metadata.db");
}
function assertReadOnly() {
if (!env.calibreReadOnly) {
throw new Error("Calibre write access is disabled");
}
}
async function openDb() {
assertReadOnly();
const dbPath = getMetadataDbPath();
if (!dbPath) return null;
if (!fs.existsSync(dbPath)) {
throw new Error("Calibre metadata.db not found");
}
const Database = (await import("better-sqlite3")).default;
return new Database(dbPath, { readonly: true, fileMustExist: true, timeout: 3000 });
}
export async function getCalibreStatus(): Promise<CalibreStatus> {
const now = new Date().toISOString();
const dbPath = getMetadataDbPath();
if (!dbPath) {
return { configured: false, online: false, bookCount: 0, lastCheckAt: now };
}
try {
const db = await openDb();
if (!db) {
return { configured: false, online: false, bookCount: 0, lastCheckAt: now };
}
const row = db.prepare("SELECT COUNT(*) as c FROM books WHERE series_index IS NOT NULL OR 1=1").get() as {
c: number;
};
db.close();
return { configured: true, online: true, bookCount: row.c, lastCheckAt: now };
} catch (e) {
const msg = e instanceof Error ? e.message : "Calibre unavailable";
const locked = msg.toLowerCase().includes("locked") || msg.toLowerCase().includes("busy");
return {
configured: true,
online: false,
bookCount: 0,
error: locked ? "Calibre is using the database — try again shortly" : msg,
lastCheckAt: now,
};
}
}
export async function listCalibreBooks(options?: {
search?: string;
limit?: number;
offset?: number;
}): Promise<CalibreBook[]> {
const db = await openDb();
if (!db) return [];
const limit = options?.limit ?? 200;
const offset = options?.offset ?? 0;
const search = options?.search?.trim();
let query = `
SELECT b.id, b.uuid, b.title, b.series_index, b.has_cover, c.text as comment,
sr.name as series_name, r.rating
FROM books b
LEFT JOIN comments c ON c.book = b.id
LEFT JOIN books_series_link bsl ON bsl.book = b.id
LEFT JOIN series sr ON sr.id = bsl.series
LEFT JOIN books_ratings_link brl ON brl.book = b.id
LEFT JOIN ratings r ON r.id = brl.rating
`;
const params: unknown[] = [];
if (search) {
query += ` WHERE b.title LIKE ? OR b.id IN (
SELECT bal.book FROM books_authors_link bal
JOIN authors a ON a.id = bal.author
WHERE a.name LIKE ?
)`;
params.push(`%${search}%`, `%${search}%`);
}
query += ` ORDER BY b.timestamp DESC LIMIT ? OFFSET ?`;
params.push(limit, offset);
const rows = db.prepare(query).all(...params) as Array<{
id: number;
uuid: string;
title: string;
series_index: number | null;
has_cover: number;
comment: string | null;
series_name: string | null;
rating: number | null;
}>;
const books: CalibreBook[] = [];
for (const row of rows) {
const authors = db
.prepare(
`SELECT a.name FROM authors a
JOIN books_authors_link bal ON bal.author = a.id
WHERE bal.book = ?`
)
.all(row.id) as { name: string }[];
const tags = db
.prepare(
`SELECT t.name FROM tags t
JOIN books_tags_link btl ON btl.tag = t.id
WHERE btl.book = ?`
)
.all(row.id) as { name: string }[];
const formats = db
.prepare(`SELECT format FROM data WHERE book = ?`)
.all(row.id) as { format: string }[];
books.push({
id: row.id,
uuid: row.uuid,
title: row.title,
authors: authors.map((a) => a.name),
tags: tags.map((t) => t.name),
comment: row.comment,
series: row.series_name,
seriesIndex: row.series_index,
rating: row.rating,
formats: formats.map((f) => f.format),
hasCover: !!row.has_cover,
});
}
db.close();
return books;
}
export async function getCalibreBook(id: number): Promise<CalibreBook | null> {
const books = await listCalibreBooks({ limit: 1, offset: 0 });
void books;
const db = await openDb();
if (!db) return null;
const row = db
.prepare(
`SELECT b.id, b.uuid, b.title, b.series_index, b.has_cover, c.text as comment,
sr.name as series_name, r.rating
FROM books b
LEFT JOIN comments c ON c.book = b.id
LEFT JOIN books_series_link bsl ON bsl.book = b.id
LEFT JOIN series sr ON sr.id = bsl.series
LEFT JOIN books_ratings_link brl ON brl.book = b.id
LEFT JOIN ratings r ON r.id = brl.rating
WHERE b.id = ?`
)
.get(id) as
| {
id: number;
uuid: string;
title: string;
series_index: number | null;
has_cover: number;
comment: string | null;
series_name: string | null;
rating: number | null;
}
| undefined;
if (!row) {
db.close();
return null;
}
const authors = db
.prepare(
`SELECT a.name FROM authors a
JOIN books_authors_link bal ON bal.author = a.id
WHERE bal.book = ?`
)
.all(id) as { name: string }[];
const tags = db
.prepare(
`SELECT t.name FROM tags t
JOIN books_tags_link btl ON btl.tag = t.id
WHERE btl.book = ?`
)
.all(id) as { name: string }[];
const formats = db
.prepare(`SELECT format FROM data WHERE book = ?`)
.all(id) as { format: string }[];
db.close();
return {
id: row.id,
uuid: row.uuid,
title: row.title,
authors: authors.map((a) => a.name),
tags: tags.map((t) => t.name),
comment: row.comment,
series: row.series_name,
seriesIndex: row.series_index,
rating: row.rating,
formats: formats.map((f) => f.format),
hasCover: !!row.has_cover,
};
}
export function resolveCoverPath(calibreBookId: number): string | null {
const libraryPath = env.calibreLibraryPath;
if (!libraryPath) return null;
const dbPath = getMetadataDbPath();
if (!dbPath || !fs.existsSync(dbPath)) return null;
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require("better-sqlite3");
const db = new Database(dbPath, { readonly: true, fileMustExist: true, timeout: 3000 });
const row = db
.prepare(`SELECT path FROM books WHERE id = ?`)
.get(calibreBookId) as { path: string } | undefined;
db.close();
if (!row) return null;
const coverPath = path.join(libraryPath, row.path, "cover.jpg");
const resolved = path.resolve(coverPath);
const libraryRoot = path.resolve(libraryPath);
if (!resolved.startsWith(libraryRoot)) return null;
if (!fs.existsSync(resolved)) return null;
return resolved;
} catch {
return null;
}
}

View File

@@ -0,0 +1,43 @@
import { eq, desc } from "drizzle-orm";
import { db, userProgress, weeklyReviews } from "../db";
import { weekStartString } from "../dates";
import { getExplorations } from "./explorations";
export async function getCartographerDesk(userId: string) {
const [progress] = await db
.select()
.from(userProgress)
.where(eq(userProgress.userId, userId));
const weekOf = weekStartString();
const [review] = await db
.select()
.from(weeklyReviews)
.where(eq(weeklyReviews.userId, userId))
.orderBy(desc(weeklyReviews.weekStart))
.limit(1);
const weekExplorations = await getExplorations(userId, weekOf);
const domains = [
{ key: "consistency", label: "Consistency", score: progress?.consistencyScore ?? 0 },
{ key: "discipline", label: "Discipline", score: progress?.disciplineScore ?? 0 },
{ key: "learning", label: "Learning", score: progress?.learningScore ?? 0 },
{ key: "spiritual", label: "Spiritual", score: progress?.spiritualScore ?? 0 },
{ key: "health", label: "Health", score: progress?.healthScore ?? 0 },
{ key: "reading", label: "Reading", score: progress?.readingScore ?? 0 },
];
return {
domains,
chapter: progress?.currentChapter ?? "Prologue: Awakening",
level: progress?.level ?? 1,
horizon: {
intention: review?.userIntention ?? null,
mentorLetter: review?.mentorLetter ?? null,
},
activePaths: weekExplorations.filter((e) => e.status === "active"),
suggested: weekExplorations.filter((e) => e.status === "suggested"),
};
}

View File

@@ -0,0 +1,61 @@
import { format, subDays } from "date-fns";
import { and, eq } from "drizzle-orm";
import { db, dailyAdventures, dailyAdventureItems } from "../db";
import { getDailyAdventure } from "./adventure/materialization";
import { getReflection } from "./reflection";
import { getLogicalToday } from "../dates";
import { getDayBoundaryHour } from "./day-boundary";
export type CatchUpGap = {
date: string;
reason: "empty" | "incomplete" | "no_reflection";
};
export async function detectCatchUpGaps(userId: string, days = 7): Promise<CatchUpGap[]> {
const boundaryHour = await getDayBoundaryHour(userId);
const logicalToday = getLogicalToday(boundaryHour);
const gaps: CatchUpGap[] = [];
for (let i = 1; i <= days; i++) {
const d = format(subDays(new Date(logicalToday), i), "yyyy-MM-dd");
const [row] = await db
.select()
.from(dailyAdventures)
.where(and(eq(dailyAdventures.userId, userId), eq(dailyAdventures.date, d)));
if (!row) {
gaps.push({ date: d, reason: "empty" });
continue;
}
const items = await db
.select()
.from(dailyAdventureItems)
.where(
and(
eq(dailyAdventureItems.dailyAdventureId, row.id),
eq(dailyAdventureItems.enabled, true)
)
);
const scorable = items.filter((i) => i.type !== "note" && !i.deletedAt);
const touched = scorable.filter((i) => i.state !== "blank").length;
if (!row.isRestDay && scorable.length > 0 && touched === 0) {
gaps.push({ date: d, reason: "incomplete" });
}
}
return gaps.slice(0, 3);
}
export async function markDayLogged(userId: string, date: string, isBackfilled = false) {
const { adventure } = await getDailyAdventure(userId, date);
const logicalToday = getLogicalToday(await getDayBoundaryHour(userId));
const updates: Record<string, unknown> = {};
if (!adventure.loggedAt) updates.loggedAt = new Date();
if (isBackfilled || date !== logicalToday) updates.isBackfilled = true;
if (Object.keys(updates).length > 0) {
await db.update(dailyAdventures).set(updates).where(eq(dailyAdventures.id, adventure.id));
}
}

View File

@@ -0,0 +1,233 @@
import { format, subDays, startOfMonth } from "date-fns";
import { journeyDay } from "@adventureos/shared";
import { requireUser } from "./user";
import { getDailyAdventure, buildDaySnapshot, refreshScores } from "./adventure";
import { getReflection } from "./reflection";
import { getBooks, getReadingStreak, getWeeklyPages, getBooksCompletedCount, getTotalPagesRead } from "./reading";
import { getSuggestions } from "./explorations";
import { awardDailyVisit } from "./xp";
import { getAchievements } from "./achievements";
import { todayString, getLogicalToday, getLogicalYesterday, isWithinGraceWindow } from "../dates";
import { db, settings } from "../db";
import { and, eq } from "drizzle-orm";
import { SETTINGS_KEYS } from "@/lib/config";
import { getXpBySource, getXpInRange } from "./xp";
import { getDayBoundaryHour } from "./day-boundary";
import { detectCatchUpGaps } from "./catch-up";
export async function getDashboard(date?: string) {
const { user, progress } = await requireUser();
const boundaryHour = await getDayBoundaryHour(user.id);
const logicalToday = getLogicalToday(boundaryHour);
const activeDate = date ?? logicalToday;
await materializeAndVisit(user.id, activeDate);
const { adventure, items, todos } = await getDailyAdventure(user.id, activeDate);
const reflection = await getReflection(user.id, activeDate);
const suggestions = await getSuggestions(user.id, "quest_giver");
const bookList = await getBooks(user.id);
const streak = await getReadingStreak(user.id);
const weekStart = format(subDays(new Date(), 6), "yyyy-MM-dd");
const weeklyPages = await getWeeklyPages(user.id, weekStart, activeDate);
const catchUpGaps = await detectCatchUpGaps(user.id);
const [goalRow] = await db
.select()
.from(settings)
.where(
and(eq(settings.userId, user.id), eq(settings.key, SETTINGS_KEYS.weeklyReadingGoal))
);
const activeBooks = bookList
.filter((b) => b.status === "reading")
.map((b) => ({
id: b.id,
title: b.title,
author: b.author,
totalPages: b.totalPages,
currentPage: b.currentPage,
status: b.status as "reading" | "paused" | "finished",
progressPercent: Math.round((b.currentPage / b.totalPages) * 100),
}));
return {
user: {
id: user.id,
displayName: user.displayName,
portraitConfig: user.portraitConfig,
currentTitle: user.currentTitle,
createdAt: user.createdAt.toISOString(),
},
progress: {
totalXp: progress?.totalXp ?? 0,
level: progress?.level ?? 1,
currentChapter: progress?.currentChapter ?? "Prologue: Awakening",
graceDaysRemaining: progress?.graceDaysRemaining ?? 2,
consistencyScore: progress?.consistencyScore ?? 0,
disciplineScore: progress?.disciplineScore ?? 0,
learningScore: progress?.learningScore ?? 0,
spiritualScore: progress?.spiritualScore ?? 0,
healthScore: progress?.healthScore ?? 0,
readingScore: progress?.readingScore ?? 0,
journeyDay: journeyDay(user.createdAt),
},
today: {
id: adventure.id,
date: adventure.date,
isRestDay: adventure.isRestDay,
isCustomized: adventure.isCustomized,
workHoursTarget:
adventure.workHoursTarget != null ? Number(adventure.workHoursTarget) : null,
dayMode: (adventure.dayMode ?? "normal") as import("@adventureos/shared").DayMode,
isBackfilled: adventure.isBackfilled ?? false,
loggedAt: adventure.loggedAt?.toISOString() ?? null,
items: items
.filter((i) => i.enabled)
.map((i) => ({
id: i.id,
type: i.type,
label: i.label,
state: i.state,
value: i.value,
config: i.config,
sortOrder: i.sortOrder,
enabled: i.enabled,
isCustom: i.isCustom,
})),
todos: todos.map((t) => ({
id: t.id,
label: t.label,
done: t.done,
sortOrder: t.sortOrder,
})),
},
reflection: reflection
? {
wentWell: reflection.wentWell,
learned: reflection.learned,
improveTomorrow: reflection.improveTomorrow,
}
: null,
suggestions: suggestions.map((s) => ({
id: s.id,
role: s.role,
content: s.content as Record<string, unknown>,
generatedAt: s.generatedAt.toISOString(),
})),
reading: {
activeBooks,
streak,
weeklyPages,
weeklyGoal: (goalRow?.value as number) ?? 50,
},
dateContext: {
logicalToday,
logicalYesterday: getLogicalYesterday(boundaryHour),
calendarToday: todayString(),
boundaryHour,
isGraceWindow: isWithinGraceWindow(boundaryHour),
activeDate,
isViewingYesterday: activeDate === getLogicalYesterday(boundaryHour),
},
catchUpGaps,
};
}
async function materializeAndVisit(userId: string, date: string) {
await getDailyAdventure(userId, date);
await awardDailyVisit(userId, date);
await refreshScores(userId);
const existing = await getAchievements(userId);
if (existing.length === 0) {
const { unlockAchievement } = await import("./achievements");
await unlockAchievement(userId, "first_visit");
}
}
export async function getStatsOverview() {
const { user, progress } = await requireUser();
const monthStart = format(startOfMonth(new Date()), "yyyy-MM-dd");
const today = todayString();
const thirtyAgo = format(subDays(new Date(), 29), "yyyy-MM-dd");
const xpMonth = await getXpInRange(user.id, monthStart, today);
const xpByCategory = await getXpBySource(user.id, thirtyAgo, today);
const trend = [];
for (let i = 29; i >= 0; i--) {
const d = format(subDays(new Date(), i), "yyyy-MM-dd");
const snap = await buildDaySnapshot(user.id, d);
const rate =
snap.adventureSlots > 0
? Math.round((snap.adventureTouched / snap.adventureSlots) * 100)
: 100;
trend.push({ date: d, value: rate });
}
return {
totalXp: progress?.totalXp ?? 0,
xpThisMonth: xpMonth.reduce((s, r) => s + r.amount, 0),
booksCompleted: await getBooksCompletedCount(user.id),
pagesThisMonth: await getWeeklyPages(user.id, monthStart, today),
totalPagesRead: await getTotalPagesRead(user.id),
exerciseSessions: 0,
consistencyTrend: trend,
xpByCategory,
scores: {
consistency: progress?.consistencyScore ?? 0,
discipline: progress?.disciplineScore ?? 0,
learning: progress?.learningScore ?? 0,
spiritual: progress?.spiritualScore ?? 0,
health: progress?.healthScore ?? 0,
reading: progress?.readingScore ?? 0,
},
};
}
export async function getStatsDomain(domain: string, range = 30) {
const { user } = await requireUser();
const days = [];
for (let i = range - 1; i >= 0; i--) {
const d = format(subDays(new Date(), i), "yyyy-MM-dd");
const snap = await buildDaySnapshot(user.id, d);
days.push({ ...snap });
}
switch (domain) {
case "reading": {
const books = await getBooks(user.id);
const streak = await getReadingStreak(user.id);
return { books, streak, days: days.map((d) => ({ date: d.date, pages: d.pagesRead })) };
}
case "work":
return {
days: days.map((d) => ({
date: d.date,
hours: d.workHours,
target: d.workTarget,
})),
};
case "exercise":
return {
days: days.map((d) => ({ date: d.date, done: d.exerciseDone })),
};
case "spiritual":
return {
days: days.map((d) => ({
date: d.date,
prayer: d.prayerChecks,
litanies: d.litanyChecks,
})),
};
case "learning":
return {
days: days.map((d) => ({
date: d.date,
classes: d.classesDone,
teaching: d.teachingDone,
})),
};
default:
return { days };
}
}

View File

@@ -0,0 +1,15 @@
import { DEFAULT_DAY_BOUNDARY_HOUR } from "@adventureos/shared";
import { SETTINGS_KEYS } from "@/lib/config";
import { getSettingValue, upsertSetting } from "@/lib/repositories/settings.repository";
export async function getDayBoundaryHour(userId: string): Promise<number> {
const value = (await getSettingValue(userId, SETTINGS_KEYS.dayBoundaryHour)) as number | null;
if (value == null || value < 0 || value > 5) return DEFAULT_DAY_BOUNDARY_HOUR;
return value;
}
export async function setDayBoundaryHour(userId: string, hour: number): Promise<number> {
const clamped = Math.max(0, Math.min(5, Math.round(hour)));
await upsertSetting(userId, SETTINGS_KEYS.dayBoundaryHour, clamped);
return clamped;
}

View File

@@ -0,0 +1,76 @@
import { describe, it, expect } from "vitest";
import {
filterDuplicateExplorations,
normalizeQuestTitle,
} from "./explorations";
describe("normalizeQuestTitle", () => {
it("lowercases and trims titles", () => {
expect(normalizeQuestTitle(" How Traceroute Works ")).toBe(
"how traceroute works"
);
});
});
describe("filterDuplicateExplorations", () => {
it("removes items matching existing titles", () => {
const items = [
{ title: "How traceroute works", hook: "a", category: "tech", minutes: 20 },
{ title: "New quest", hook: "b", category: "reading", minutes: 15 },
];
const filtered = filterDuplicateExplorations(items, ["How Traceroute Works"]);
expect(filtered).toHaveLength(1);
expect(filtered[0].title).toBe("New quest");
});
it("deduplicates within generated batch", () => {
const items = [
{ title: "Read poetry", hook: "a", category: "reading", minutes: 20 },
{ title: "Read Poetry", hook: "b", category: "reading", minutes: 15 },
];
const filtered = filterDuplicateExplorations(items, []);
expect(filtered).toHaveLength(1);
});
});
describe("exploration regenerate guard", () => {
it("allows regeneration when only dismissed explorations exist", () => {
const existing = [{ status: "dismissed" }, { status: "dismissed" }];
const suggested = existing.filter((e) => e.status === "suggested");
expect(suggested.length).toBe(0);
});
it("allows regeneration when only active quests remain", () => {
const existing = [{ status: "active" }, { status: "dismissed" }];
const suggested = existing.filter((e) => e.status === "suggested");
expect(suggested.length).toBe(0);
});
it("blocks regeneration when suggested quests remain", () => {
const existing = [{ status: "suggested" }, { status: "active" }];
const suggested = existing.filter((e) => e.status === "suggested");
expect(suggested.length).toBeGreaterThan(0);
});
it("excludes denied quests from active suggestions", () => {
const explorations = [
{ id: "1", status: "suggested" },
{ id: "2", status: "dismissed" },
{ id: "3", status: "active" },
];
const suggested = explorations.filter((e) => e.status === "suggested");
const active = explorations.filter((e) => e.status === "active");
expect(suggested).toHaveLength(1);
expect(active).toHaveLength(1);
expect(
explorations.filter((e) => e.status === "dismissed")
).toHaveLength(1);
});
it("only suggested quests can be denied", () => {
const canDeny = (status: string) => status === "suggested";
expect(canDeny("suggested")).toBe(true);
expect(canDeny("active")).toBe(false);
expect(canDeny("completed")).toBe(false);
});
});

View File

@@ -0,0 +1,297 @@
import { and, eq, desc } from "drizzle-orm";
import { subDays, format } from "date-fns";
import { XP_AWARDS } from "@adventureos/shared";
import { db, explorations, aiSuggestions, weeklyReviews } from "../db";
import { weekStartString } from "../dates";
import { generateExplorations, generateMentorReview, generateQuests } from "./ai";
import { awardXp, getXpInRange } from "./xp";
import { buildDaySnapshot, refreshScores } from "./adventure";
import { buildAiContext } from "./ai-context";
import { requireUser } from "./user";
import { recordAction } from "./action-events";
import { ACTION_TYPES } from "@/lib/config";
export function normalizeQuestTitle(title: string): string {
return title.trim().toLowerCase();
}
export function filterDuplicateExplorations<
T extends { title: string },
>(items: T[], existingTitles: string[]): T[] {
const seen = new Set(existingTitles.map(normalizeQuestTitle));
return items.filter((item) => {
const key = normalizeQuestTitle(item.title);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
export async function getSuggestions(userId: string, role?: string) {
const conditions = [
eq(aiSuggestions.userId, userId),
eq(aiSuggestions.dismissed, false),
];
const rows = await db
.select()
.from(aiSuggestions)
.where(and(...conditions))
.orderBy(desc(aiSuggestions.generatedAt))
.limit(10);
if (role) return rows.filter((r) => r.role === role);
return rows;
}
export async function dismissSuggestion(id: string, userId: string) {
await db
.update(aiSuggestions)
.set({ dismissed: true })
.where(and(eq(aiSuggestions.id, id), eq(aiSuggestions.userId, userId)));
}
export async function generateDailyQuests(userId: string) {
const context = await buildAiContext(userId);
const quests = await generateQuests(context, userId);
await db.insert(aiSuggestions).values({
userId,
role: "quest_giver",
content: { quests },
});
return quests;
}
export async function generateWeeklyExplorations(userId: string) {
const weekOf = weekStartString();
const existing = await db
.select()
.from(explorations)
.where(and(eq(explorations.userId, userId), eq(explorations.weekOf, weekOf)));
const suggested = existing.filter((e) => e.status === "suggested");
const statusCounts = existing.reduce<Record<string, number>>((acc, e) => {
acc[e.status] = (acc[e.status] ?? 0) + 1;
return acc;
}, {});
// #region agent log
fetch('http://127.0.0.1:7257/ingest/abfa22e4-3d86-4176-9679-7c59b1f0489f',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'04e12c'},body:JSON.stringify({sessionId:'04e12c',location:'explorations.ts:generateWeeklyExplorations',message:'generate guard check',data:{weekOf,statusCounts,suggestedCount:suggested.length},timestamp:Date.now(),hypothesisId:'C1'})}).catch(()=>{});
// #endregion
if (suggested.length > 0) {
const visible = existing.filter((e) => e.status !== "dismissed");
return { explorations: visible, source: "existing" as const };
}
const context = await buildAiContext(userId);
const { explorations: generated, source, fallbackReason } = await generateExplorations(
context,
userId
);
const existingTitles = existing.map((e) => e.title);
const items = filterDuplicateExplorations(generated, existingTitles);
const created = [];
for (const item of items) {
const [row] = await db
.insert(explorations)
.values({
userId,
title: item.title,
description: item.hook,
category: item.category,
weekOf,
status: "suggested",
minutes: item.minutes ?? null,
})
.returning();
created.push(row);
}
// #region agent log
fetch('http://127.0.0.1:7257/ingest/abfa22e4-3d86-4176-9679-7c59b1f0489f',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'04e12c'},body:JSON.stringify({sessionId:'04e12c',location:'explorations.ts:generateWeeklyExplorations',message:'generate complete',data:{source,generatedCount:generated.length,createdCount:created.length,filteredCount:items.length},timestamp:Date.now(),hypothesisId:'C3'})}).catch(()=>{});
// #endregion
const visible = [
...existing.filter((e) => e.status !== "dismissed"),
...created,
];
return {
explorations: visible,
source,
fallbackReason: source === "fallback" ? fallbackReason : undefined,
};
}
export async function getExplorations(userId: string, weekOf?: string) {
const week = weekOf ?? weekStartString();
return db
.select()
.from(explorations)
.where(and(eq(explorations.userId, userId), eq(explorations.weekOf, week)))
.orderBy(desc(explorations.acceptedAt));
}
export async function acceptExploration(id: string, userId: string) {
const [before] = await db
.select()
.from(explorations)
.where(and(eq(explorations.id, id), eq(explorations.userId, userId)));
if (!before) throw new Error("Exploration not found");
await db
.update(explorations)
.set({ status: "active", acceptedAt: new Date() })
.where(and(eq(explorations.id, id), eq(explorations.userId, userId)));
const actionEvent = await recordAction({
userId,
actionType: ACTION_TYPES.explorationUpdate,
entityType: "exploration",
entityId: id,
summary: `Accepted exploration: ${before.title}`,
beforeState: { status: before.status, acceptedAt: before.acceptedAt, completedNote: before.completedNote },
afterState: { status: "active", acceptedAt: new Date(), completedNote: before.completedNote },
});
return { actionEventId: actionEvent.id };
}
export async function completeExploration(
id: string,
userId: string,
note: string,
date: string
) {
const [before] = await db
.select()
.from(explorations)
.where(and(eq(explorations.id, id), eq(explorations.userId, userId)));
if (!before) throw new Error("Exploration not found");
await db
.update(explorations)
.set({ status: "completed", completedNote: note })
.where(and(eq(explorations.id, id), eq(explorations.userId, userId)));
const xpResult = await awardXp(userId, date, "exploration", XP_AWARDS.exploration, {
explorationId: id,
});
const { checkAchievements } = await import("./achievements");
await checkAchievements(userId);
await refreshScores(userId);
const actionEvent = await recordAction({
userId,
actionType: ACTION_TYPES.explorationUpdate,
entityType: "exploration",
entityId: id,
summary: `Completed exploration: ${before.title}`,
beforeState: { status: before.status, acceptedAt: before.acceptedAt, completedNote: before.completedNote },
afterState: { status: "completed", acceptedAt: before.acceptedAt, completedNote: note },
metadata: { xpEventIds: xpResult.xpEventId ? [xpResult.xpEventId] : [] },
});
return { actionEventId: actionEvent.id };
}
export async function dismissExploration(id: string, userId: string) {
const [before] = await db
.select()
.from(explorations)
.where(and(eq(explorations.id, id), eq(explorations.userId, userId)));
if (!before) throw new Error("Exploration not found");
if (before.status !== "suggested") {
throw new Error("Only suggested quests can be denied");
}
await db
.update(explorations)
.set({ status: "dismissed" })
.where(and(eq(explorations.id, id), eq(explorations.userId, userId)));
const actionEvent = await recordAction({
userId,
actionType: ACTION_TYPES.explorationUpdate,
entityType: "exploration",
entityId: id,
summary: `Dismissed exploration: ${before.title}`,
beforeState: { status: before.status, acceptedAt: before.acceptedAt, completedNote: before.completedNote },
afterState: { status: "dismissed", acceptedAt: before.acceptedAt, completedNote: before.completedNote },
});
return { actionEventId: actionEvent.id };
}
export async function generateWeeklyReview(userId: string, weekStart: string) {
const [existing] = await db
.select()
.from(weeklyReviews)
.where(
and(eq(weeklyReviews.userId, userId), eq(weeklyReviews.weekStart, weekStart))
);
if (existing) return existing;
const context = await buildAiContext(userId);
const mentor = await generateMentorReview(context, userId);
const weekEnd = format(
new Date(new Date(weekStart).getTime() + 6 * 86400000),
"yyyy-MM-dd"
);
const xpRows = await getXpInRange(userId, weekStart, weekEnd);
const xpEarned = xpRows.reduce((s, r) => s + r.amount, 0);
const [review] = await db
.insert(weeklyReviews)
.values({
userId,
weekStart,
content: {
patterns: mentor.patterns,
encouragement: mentor.encouragement,
focus_suggestion: mentor.focus_suggestion,
xpEarned,
},
mentorLetter: mentor.letter,
xpEarned,
})
.returning();
return review;
}
export async function getWeeklyReview(userId: string, weekStart: string) {
const [row] = await db
.select()
.from(weeklyReviews)
.where(
and(eq(weeklyReviews.userId, userId), eq(weeklyReviews.weekStart, weekStart))
);
return row ?? null;
}
export async function setReviewIntention(
userId: string,
weekStart: string,
intention: string
) {
await db
.update(weeklyReviews)
.set({ userIntention: intention })
.where(
and(eq(weeklyReviews.userId, userId), eq(weeklyReviews.weekStart, weekStart))
);
await awardXp(userId, weekStart, "weekly_review", XP_AWARDS.weekly_review);
}
export async function getExplorationHistory(userId: string) {
return db
.select()
.from(explorations)
.where(
and(eq(explorations.userId, userId), eq(explorations.status, "completed"))
)
.orderBy(desc(explorations.acceptedAt))
.limit(50);
}

View File

@@ -0,0 +1,89 @@
import { z } from "zod";
import type { MemoryCategory, MemorySourceType } from "@adventureos/shared";
import { SENSITIVE_MEMORY_CATEGORIES } from "@adventureos/shared";
import { parseAiJson } from "@/lib/ai/parse-json";
import { getProvider } from "@/lib/ai/provider-registry";
import { getAiBehaviorConfig, getAiProviderConfig, buildSystemPrompt } from "./ai-config";
import { createSuggestion, getMemoryLearningSettings } from "./ai-memory";
const candidateSchema = z.object({
candidates: z
.array(
z.object({
category: z.string(),
title: z.string(),
content: z.string(),
confidence: z.coerce.number(),
sensitivity: z.enum(["normal", "private", "sensitive"]).optional(),
})
)
.max(2),
});
export async function extractMemoryCandidates(
userId: string,
sourceType: MemorySourceType,
sourceText: string,
sourceRef?: { type: string; id?: string; date?: string }
) {
const settings = await getMemoryLearningSettings(userId);
if (!settings.learningEnabled || !settings.autoSuggestEnabled) return [];
const behavior = await getAiBehaviorConfig(userId);
const providerConfig = await getAiProviderConfig(userId);
if (!behavior.enabled || !providerConfig.enabled) return [];
const prompt = `From this user text, suggest 0-2 personal memory facts the app could remember (only clear patterns, not guesses).
User text:
"""
${sourceText.slice(0, 1500)}
"""
Return JSON: { "candidates": [{ "category": "likes|motivators|current_goals|reading_preferences|learning_interests|worries|...", "title": "short label", "content": "one sentence fact", "confidence": 0.0-1.0, "sensitivity": "normal|private|sensitive" }] }
If nothing clear, return { "candidates": [] }.`;
try {
const coreSystem = await getTemplateBody(userId);
const provider = getProvider(providerConfig.type);
const res = await provider.generateText(providerConfig, {
model: providerConfig.model,
prompt,
system: buildSystemPrompt(behavior, coreSystem),
format: "json",
temperature: 0.3,
maxTokens: 400,
timeoutMs: providerConfig.timeoutMs,
});
const parsed = candidateSchema.safeParse(parseAiJson(res.text));
if (!parsed.success) return [];
const results = [];
for (const c of parsed.data.candidates) {
if (c.confidence < 0.6) continue;
if (!settings.allowedCategories.includes(c.category as MemoryCategory)) continue;
if (
SENSITIVE_MEMORY_CATEGORIES.includes(c.category as MemoryCategory) &&
!settings.allowSensitiveCategories
) {
continue;
}
const row = await createSuggestion(userId, {
category: c.category as MemoryCategory,
title: c.title,
content: c.content,
sourceType,
sourceRef,
confidence: c.confidence,
});
if (row) results.push(row);
}
return results;
} catch {
return [];
}
}
async function getTemplateBody(userId: string) {
const { getTemplateBody: getTpl } = await import("./ai-templates");
return getTpl(userId, "system_core");
}

View File

@@ -0,0 +1,78 @@
import { XP_AWARDS } from "@adventureos/shared";
import { getDailyAdventure } from "./adventure/materialization";
import { updateAdventureItem } from "./adventure/daily";
import { saveReflection } from "./reflection";
import { markDayLogged } from "./catch-up";
import { getLogicalToday } from "../dates";
import { getDayBoundaryHour } from "./day-boundary";
export type QuickLogInput = {
workMinutes?: number;
readingPages?: number;
exercise?: boolean;
prayerChecks?: number;
reflection?: string;
};
export async function quickLog(userId: string, date: string, input: QuickLogInput) {
const { items } = await getDailyAdventure(userId, date);
const results: string[] = [];
if (input.workMinutes != null && input.workMinutes > 0) {
const workItem = items.find((i) => i.type === "duration");
if (workItem) {
const hours = input.workMinutes / 60;
await updateAdventureItem(userId, date, workItem.id, {
value: { hours },
});
results.push(`work: ${hours.toFixed(1)}h`);
}
}
if (input.readingPages != null && input.readingPages > 0) {
const readingItem = items.find((i) => i.type === "reading");
if (readingItem) {
await updateAdventureItem(userId, date, readingItem.id, {
value: { pages: input.readingPages },
});
results.push(`reading: ${input.readingPages} pages`);
}
}
if (input.exercise === true) {
const exerciseItem = items.find(
(i) => i.type === "checkbox" && i.label.toLowerCase().includes("exercise")
);
if (exerciseItem) {
await updateAdventureItem(userId, date, exerciseItem.id, { state: "done" });
results.push("exercise: done");
}
}
if (input.prayerChecks != null && input.prayerChecks > 0) {
const prayerItem = items.find((i) => i.label === "Prayer");
if (prayerItem) {
const checks =
((prayerItem.value as Record<string, unknown>)?.checks as boolean[]) ?? [];
const updated = checks.map((_, i) => i < input.prayerChecks!);
await updateAdventureItem(userId, date, prayerItem.id, {
value: { checks: updated },
});
results.push(`prayer: ${input.prayerChecks} checks`);
}
}
if (input.reflection?.trim()) {
await saveReflection(userId, date, {
wentWell: "",
learned: input.reflection.trim(),
improveTomorrow: "",
});
results.push("reflection saved");
}
const logicalToday = getLogicalToday(await getDayBoundaryHour(userId));
await markDayLogged(userId, date, date !== logicalToday);
return { saved: results, xpNote: XP_AWARDS.reflection };
}

View File

@@ -0,0 +1,210 @@
import { and, eq, desc, sql, sum } from "drizzle-orm";
import {
XP_AWARDS,
xpForReadingPages,
} from "@adventureos/shared";
import { db, books, readingLogs } from "../db";
import { todayString } from "../dates";
import { awardXp } from "./xp";
import { checkAchievements } from "./achievements";
import { recordAction } from "./action-events";
import { ACTION_TYPES } from "@/lib/config";
export async function getBooks(userId: string) {
return db
.select()
.from(books)
.where(eq(books.userId, userId))
.orderBy(desc(books.startedAt));
}
export async function createBook(
userId: string,
data: { title: string; author?: string; totalPages: number }
) {
const [book] = await db
.insert(books)
.values({
userId,
title: data.title,
author: data.author,
totalPages: data.totalPages,
})
.returning();
return book;
}
export async function updateBook(
bookId: string,
userId: string,
data: Partial<{
title: string;
author: string;
totalPages: number;
currentPage: number;
status: string;
notes: string;
}>
) {
const [book] = await db
.update(books)
.set(data)
.where(and(eq(books.id, bookId), eq(books.userId, userId)))
.returning();
return book;
}
export async function logPages(
userId: string,
bookId: string,
pages: number,
date = todayString(),
note?: string
) {
const [book] = await db
.select()
.from(books)
.where(and(eq(books.id, bookId), eq(books.userId, userId)));
if (!book) throw new Error("Book not found");
const newPage = Math.min(book.totalPages, book.currentPage + pages);
const wasFinished = book.status === "finished";
const bookBefore = {
currentPage: book.currentPage,
status: book.status,
finishedAt: book.finishedAt,
};
const [log] = await db.insert(readingLogs).values({
bookId,
date,
pagesRead: pages,
note,
}).returning();
const status =
newPage >= book.totalPages ? "finished" : book.status === "paused" ? "paused" : "reading";
await db
.update(books)
.set({
currentPage: newPage,
status,
finishedAt: status === "finished" ? new Date() : book.finishedAt,
})
.where(eq(books.id, bookId));
const xpEventIds: string[] = [];
const xp = xpForReadingPages(pages);
if (xp > 0) {
const xpResult = await awardXp(userId, date, "reading", xp, { bookId, pages });
if (xpResult.xpEventId) xpEventIds.push(xpResult.xpEventId);
}
if (status === "finished" && !wasFinished) {
const completeXp = await awardXp(userId, date, "book_complete", XP_AWARDS.book_complete, { bookId });
if (completeXp.xpEventId) xpEventIds.push(completeXp.xpEventId);
await checkAchievements(userId);
}
const actionEvent = await recordAction({
userId,
actionType: ACTION_TYPES.readingLogPages,
entityType: "book",
entityId: bookId,
summary: `Logged ${pages} pages in ${book.title}`,
beforeState: { book: bookBefore },
afterState: { book: { currentPage: newPage, status, finishedAt: status === "finished" ? new Date() : book.finishedAt } },
metadata: { readingLogId: log.id, pages, date, xpEventIds },
});
return { currentPage: newPage, status, actionEventId: actionEvent.id };
}
export async function getReadingStreak(userId: string) {
const logs = await db
.select({ date: readingLogs.date, pages: readingLogs.pagesRead })
.from(readingLogs)
.innerJoin(books, eq(readingLogs.bookId, books.id))
.where(eq(books.userId, userId))
.orderBy(desc(readingLogs.date));
const byDate = new Map<string, number>();
for (const log of logs) {
byDate.set(log.date, (byDate.get(log.date) ?? 0) + log.pages);
}
let current = 0;
let best = 0;
let isPaused = false;
const today = todayString();
const dates = Array.from(byDate.keys()).sort().reverse();
if (dates.length === 0) {
return { current: 0, best: 0, isPaused: false };
}
const d = new Date();
for (let i = 0; i < 400; i++) {
const ds = d.toISOString().slice(0, 10);
const pages = byDate.get(ds) ?? 0;
if (pages > 0) {
current++;
} else if (i === 0 && ds === today) {
// today not read yet — don't break streak
} else if (i === 1 && !byDate.has(today) && current > 0) {
isPaused = true;
break;
} else {
break;
}
d.setDate(d.getDate() - 1);
}
let run = 0;
const allDates = Array.from(byDate.keys()).sort();
for (const date of allDates) {
if ((byDate.get(date) ?? 0) > 0) {
run++;
best = Math.max(best, run);
} else {
run = 0;
}
}
return { current, best, isPaused };
}
export async function getWeeklyPages(userId: string, from: string, to: string) {
const [row] = await db
.select({ total: sum(readingLogs.pagesRead) })
.from(readingLogs)
.innerJoin(books, eq(readingLogs.bookId, books.id))
.where(
and(
eq(books.userId, userId),
sql`${readingLogs.date} >= ${from}`,
sql`${readingLogs.date} <= ${to}`
)
);
return Number(row?.total ?? 0);
}
export async function getTotalPagesRead(userId: string) {
const [row] = await db
.select({ total: sum(readingLogs.pagesRead) })
.from(readingLogs)
.innerJoin(books, eq(readingLogs.bookId, books.id))
.where(eq(books.userId, userId));
return Number(row?.total ?? 0);
}
export async function getBooksCompletedCount(userId: string) {
const rows = await db
.select()
.from(books)
.where(and(eq(books.userId, userId), eq(books.status, "finished")));
return rows.length;
}

View File

@@ -0,0 +1,42 @@
import { awardXp } from "@/lib/services/xp";
import { xpForReadingPages } from "@adventureos/shared";
import { recordAction } from "@/lib/services/action-events";
import { ACTION_TYPES } from "@/lib/config";
export type ReadingLogActionInput = {
userId: string;
date: string;
pages: number;
summary: string;
entityType: string;
entityId: string;
beforeState: Record<string, unknown>;
afterState: Record<string, unknown>;
metadata: Record<string, unknown>;
xpMetadata: Record<string, unknown>;
};
export async function recordReadingLogAction(input: ReadingLogActionInput) {
const xp = xpForReadingPages(input.pages);
let xpEventId: string | undefined;
if (xp > 0) {
const xpResult = await awardXp(input.userId, input.date, "reading", xp, input.xpMetadata);
xpEventId = xpResult.xpEventId;
}
const actionEvent = await recordAction({
userId: input.userId,
actionType: ACTION_TYPES.readingLogPages,
entityType: input.entityType,
entityId: input.entityId,
summary: input.summary,
beforeState: input.beforeState,
afterState: input.afterState,
metadata: {
...input.metadata,
xpEventIds: xpEventId ? [xpEventId] : [],
},
});
return { xpEventId, actionEventId: actionEvent.id };
}

View File

@@ -0,0 +1,74 @@
import { describe, it, expect } from "vitest";
import {
hasMeaningfulReflectionContent,
shouldAwardReflectionXp,
} from "../reflection-utils";
describe("hasMeaningfulReflectionContent", () => {
it("returns false for all empty fields", () => {
expect(
hasMeaningfulReflectionContent({
wentWell: "",
learned: "",
improveTomorrow: "",
})
).toBe(false);
});
it("returns false for whitespace-only fields", () => {
expect(
hasMeaningfulReflectionContent({
wentWell: " ",
learned: "\t",
improveTomorrow: " \n ",
})
).toBe(false);
});
it("returns true when any field has content", () => {
expect(
hasMeaningfulReflectionContent({
wentWell: "",
learned: "Read for 20 minutes",
improveTomorrow: "",
})
).toBe(true);
});
});
describe("shouldAwardReflectionXp", () => {
const empty = { wentWell: "", learned: "", improveTomorrow: "" };
const filled = {
wentWell: "Good day",
learned: "Something new",
improveTomorrow: "Sleep earlier",
};
it("does not award for empty content", () => {
expect(shouldAwardReflectionXp(null, empty)).toBe(false);
});
it("does not award for whitespace-only content", () => {
expect(
shouldAwardReflectionXp(null, {
wentWell: " ",
learned: "",
improveTomorrow: "",
})
).toBe(false);
});
it("awards on first meaningful save", () => {
expect(shouldAwardReflectionXp(null, filled)).toBe(true);
});
it("does not award when updating already meaningful reflection", () => {
expect(shouldAwardReflectionXp(filled, { ...filled, learned: "More" })).toBe(
false
);
});
it("awards when empty row exists and user adds meaningful content", () => {
expect(shouldAwardReflectionXp(empty, filled)).toBe(true);
});
});

View File

@@ -0,0 +1,126 @@
import { and, eq, sql } from "drizzle-orm";
import { XP_AWARDS } from "@adventureos/shared";
import { db, reflections } from "../db";
import {
hasMeaningfulReflectionContent,
shouldAwardReflectionXp,
type ReflectionData,
} from "../reflection-utils";
import { awardXp } from "./xp";
import { recordAction } from "./action-events";
import { ACTION_TYPES, ENTITY_TYPES } from "@/lib/config";
export type { ReflectionData } from "../reflection-utils";
export {
hasMeaningfulReflectionContent,
shouldAwardReflectionXp,
} from "../reflection-utils";
export async function getReflection(userId: string, date: string) {
const [row] = await db
.select()
.from(reflections)
.where(and(eq(reflections.userId, userId), eq(reflections.date, date)));
return row ?? null;
}
export async function saveReflection(
userId: string,
date: string,
data: ReflectionData
) {
const existing = await getReflection(userId, date);
const meaningful = hasMeaningfulReflectionContent(data);
if (!meaningful && !existing) {
return { ...data, xpAwarded: 0 };
}
let xpAwarded = 0;
let reflectionId: string;
const beforeState = existing
? {
existed: true,
wentWell: existing.wentWell,
learned: existing.learned,
improveTomorrow: existing.improveTomorrow,
}
: { existed: false, wentWell: "", learned: "", improveTomorrow: "" };
if (existing) {
await db
.update(reflections)
.set(data)
.where(eq(reflections.id, existing.id));
reflectionId = existing.id;
if (shouldAwardReflectionXp(existing, data)) {
const result = await awardXp(
userId,
date,
"reflection",
XP_AWARDS.reflection
);
xpAwarded = result.awarded;
if (xpAwarded > 0) {
const { unlockAchievement } = await import("./achievements");
await unlockAchievement(userId, "first_reflection");
}
}
} else {
const [inserted] = await db
.insert(reflections)
.values({ userId, date, ...data })
.returning({ id: reflections.id });
reflectionId = inserted.id;
const result = await awardXp(
userId,
date,
"reflection",
XP_AWARDS.reflection
);
xpAwarded = result.awarded;
const { unlockAchievement } = await import("./achievements");
await unlockAchievement(userId, "first_reflection");
}
await recordAction({
userId,
actionType: ACTION_TYPES.reflectionSave,
entityType: ENTITY_TYPES.reflection,
entityId: reflectionId,
summary: `Saved reflection for ${date}`,
beforeState,
afterState: { ...data, existed: true },
metadata: { date, xpAwarded },
});
if (meaningful) {
const { extractMemoryCandidates } = await import("./memory-extraction");
const { getMemoryLearningSettings } = await import("./ai-memory");
const learning = await getMemoryLearningSettings(userId);
if (learning.suggestAfterReflection) {
const text = [data.wentWell, data.learned, data.improveTomorrow].filter(Boolean).join("\n");
await extractMemoryCandidates(userId, "reflection", text, { type: "reflection", date });
}
}
return { ...data, xpAwarded };
}
export async function getReflectionsInRange(
userId: string,
from: string,
to: string
) {
return db
.select()
.from(reflections)
.where(
and(
eq(reflections.userId, userId),
sql`${reflections.date} >= ${from}`,
sql`${reflections.date} <= ${to}`
)
);
}

View File

@@ -0,0 +1,57 @@
import { generateTeacherContent } from "./ai";
import { buildMentorContext, formatContextForPrompt } from "./ai-context";
import {
listTeacherContent,
findTeacherContentById,
insertTeacherContent,
updateTeacherContent,
} from "@/lib/repositories/teacher.repository";
import type { TeacherCreateBody } from "@/lib/validation/schemas";
export async function getTeacherHistory(userId: string) {
return listTeacherContent(userId);
}
export async function getTeacherLesson(userId: string, id: string) {
const row = await findTeacherContentById(id);
if (!row || row.userId !== userId) return null;
return row;
}
export async function createTeacherLesson(
userId: string,
input: TeacherCreateBody
) {
const topic = input.topic.trim();
if (!topic) {
throw new Error("Topic is required");
}
const ctx = await buildMentorContext(userId, { feature: "teacher", topic, userMessage: topic });
const { content, source, fallbackReason } = await generateTeacherContent(topic, userId, formatContextForPrompt(ctx));
const row = await insertTeacherContent({
userId,
topic,
explorationId: input.explorationId,
content,
status: "active",
});
return { ...row, source, fallbackReason };
}
export async function completeTeacherLesson(
userId: string,
id: string,
completedNote: string
) {
const row = await findTeacherContentById(id);
if (!row || row.userId !== userId) throw new Error("Lesson not found");
const updated = await updateTeacherContent(id, {
status: "completed",
completedNote,
completedAt: new Date(),
});
if (!updated) throw new Error("Lesson not found");
return updated;
}

View File

@@ -0,0 +1,30 @@
import { migrateThemeId } from "@/themes/registry";
import { SETTINGS_KEYS } from "@/lib/config";
export function normalizeSettingsTheme(
settings: Record<string, unknown>
): Record<string, unknown> {
if (settings.theme !== undefined) {
return { ...settings, theme: migrateThemeId(settings.theme as string) };
}
return settings;
}
export async function migrateUserThemeInDb(
userId: string,
currentTheme: string | undefined
): Promise<string> {
const migrated = migrateThemeId(currentTheme);
if (currentTheme && currentTheme !== migrated) {
const { db, settings } = await import("../db");
const { eq, and, sql } = await import("drizzle-orm");
await db
.insert(settings)
.values({ userId, key: SETTINGS_KEYS.theme, value: migrated })
.onConflictDoUpdate({
target: [settings.userId, settings.key],
set: { value: sql`excluded.value` },
});
}
return migrated;
}

View File

@@ -0,0 +1,41 @@
import { describe, it, expect } from "vitest";
/**
* Characterization: Calibre reading logs use actionType "reading.log_pages"
* but store beforeState as { currentPage, status } on reading_progress,
* while undo.ts expects before.book for manual books table.
*/
describe("undo reading.log_pages characterization", () => {
const manualBeforeState = {
book: { currentPage: 50, status: "reading", finishedAt: null },
};
const calibreBeforeState = {
currentPage: 50,
status: "reading",
};
it("manual path has book wrapper expected by undo handler", () => {
expect(manualBeforeState.book).toBeDefined();
expect(manualBeforeState.book.currentPage).toBe(50);
});
it("calibre path lacks book wrapper — undo handler mismatch", () => {
expect("book" in calibreBeforeState).toBe(false);
expect(calibreBeforeState.currentPage).toBe(50);
});
it("documents entity types", () => {
const manualEntity = { entityType: "book", actionType: "reading.log_pages" };
const calibreEntity = { entityType: "reading_progress", actionType: "reading.log_pages" };
expect(manualEntity.entityType).not.toBe(calibreEntity.entityType);
});
});
describe("exploration regenerate guard", () => {
it("allows regeneration when only dismissed explorations exist", () => {
const existing = [{ status: "dismissed" }, { status: "dismissed" }];
const actionable = existing.filter((e) => e.status !== "dismissed");
expect(actionable.length).toBe(0);
});
});

235
apps/web/src/lib/services/undo.ts Executable file
View File

@@ -0,0 +1,235 @@
import { eq, and, inArray } from "drizzle-orm";
import {
db,
dailyAdventureItems,
dailyAdventures,
dailyTodos,
books,
readingLogs,
reflections,
explorations,
adventureItems,
userProgress,
xpEvents,
} from "../db";
import {
getActionEvent,
markUndone,
type RecordActionInput,
} from "./action-events";
import { refreshScores } from "./adventure";
import { levelFromXp, chapterForLevel } from "@adventureos/shared";
export type UndoResult = {
ok: boolean;
summary?: string;
error?: string;
};
async function recalculateProgress(userId: string) {
const events = await db
.select()
.from(xpEvents)
.where(eq(xpEvents.userId, userId));
const active = events.filter(
(e) => !(e.metadata as Record<string, unknown>)?.revoked
);
const totalXp = active.reduce((s, e) => s + e.amount, 0);
const newLevel = levelFromXp(totalXp);
const chapter = chapterForLevel(newLevel);
await db
.update(userProgress)
.set({ totalXp, level: newLevel, currentChapter: chapter.name })
.where(eq(userProgress.userId, userId));
}
async function revokeXpEvents(userId: string, xpEventIds: string[]) {
if (xpEventIds.length === 0) return;
const events = await db
.select()
.from(xpEvents)
.where(
and(eq(xpEvents.userId, userId), inArray(xpEvents.id, xpEventIds))
);
for (const event of events) {
await db.insert(xpEvents).values({
userId,
date: event.date,
source: event.source,
amount: -event.amount,
metadata: {
revokedEventId: event.id,
actionUndo: true,
},
});
await db
.update(xpEvents)
.set({
metadata: {
...(event.metadata as Record<string, unknown>),
revoked: true,
},
})
.where(eq(xpEvents.id, event.id));
}
await recalculateProgress(userId);
}
export async function undoAction(
userId: string,
actionEventId: string
): Promise<UndoResult> {
const event = await getActionEvent(userId, actionEventId);
if (!event) return { ok: false, error: "Action not found" };
if (event.undoneAt) return { ok: false, error: "Already undone" };
if (!event.undoable) return { ok: false, error: "Action cannot be undone" };
const before = event.beforeState as Record<string, unknown>;
const meta = (event.metadata ?? {}) as Record<string, unknown>;
const xpEventIds = (meta.xpEventIds as string[]) ?? [];
try {
switch (event.actionType) {
case "adventure_item.update": {
const updates: Record<string, unknown> = {};
if ("value" in before) updates.value = before.value as Record<string, unknown>;
if ("state" in before) updates.state = before.state as string;
if ("completedAt" in before) updates.completedAt = (before.completedAt as Date | null) ?? null;
if ("label" in before) updates.label = before.label as string;
if ("enabled" in before) updates.enabled = before.enabled as boolean;
if ("config" in before) updates.config = before.config as Record<string, unknown>;
if (Object.keys(updates).length > 0) {
await db
.update(dailyAdventureItems)
.set(updates)
.where(eq(dailyAdventureItems.id, event.entityId));
}
await revokeXpEvents(userId, xpEventIds);
await refreshScores(userId);
break;
}
case "adventure_item.create": {
await db
.update(dailyAdventureItems)
.set({ deletedAt: new Date() })
.where(eq(dailyAdventureItems.id, event.entityId));
break;
}
case "adventure_item.delete": {
await db
.update(dailyAdventureItems)
.set({ deletedAt: null })
.where(eq(dailyAdventureItems.id, event.entityId));
break;
}
case "adventure.work_hours.update": {
await db
.update(dailyAdventures)
.set({ workHoursTarget: (before.workHoursTarget as string | null) ?? null })
.where(eq(dailyAdventures.id, event.entityId));
break;
}
case "daily_todo.create": {
await db
.update(dailyTodos)
.set({ deletedAt: new Date() })
.where(eq(dailyTodos.id, event.entityId));
break;
}
case "daily_todo.update": {
await db
.update(dailyTodos)
.set({
label: before.label as string,
done: before.done as boolean,
})
.where(eq(dailyTodos.id, event.entityId));
break;
}
case "daily_todo.delete": {
await db
.update(dailyTodos)
.set({ deletedAt: null })
.where(eq(dailyTodos.id, event.entityId));
break;
}
case "reading.log_pages": {
const bookBefore = before.book as Record<string, unknown>;
const logId = meta.readingLogId as string;
if (logId) {
await db.delete(readingLogs).where(eq(readingLogs.id, logId));
}
await db
.update(books)
.set({
currentPage: bookBefore.currentPage as number,
status: bookBefore.status as string,
finishedAt: (bookBefore.finishedAt as Date | null) ?? null,
})
.where(eq(books.id, event.entityId));
await revokeXpEvents(userId, xpEventIds);
break;
}
case "adventure.rest_day": {
const adventureId = meta.adventureId as string;
if (adventureId) {
await db
.update(dailyAdventures)
.set({ isRestDay: false })
.where(eq(dailyAdventures.id, adventureId));
}
await revokeXpEvents(userId, xpEventIds);
await refreshScores(userId);
break;
}
case "reflection.save": {
if (before.existed) {
await db
.update(reflections)
.set({
wentWell: before.wentWell as string,
learned: before.learned as string,
improveTomorrow: before.improveTomorrow as string,
})
.where(eq(reflections.id, event.entityId));
} else {
await db.delete(reflections).where(eq(reflections.id, event.entityId));
}
break;
}
case "exploration.update": {
await db
.update(explorations)
.set({
status: before.status as string,
acceptedAt: (before.acceptedAt as Date | null) ?? null,
completedNote: (before.completedNote as string | null) ?? null,
})
.where(eq(explorations.id, event.entityId));
await revokeXpEvents(userId, xpEventIds);
await refreshScores(userId);
break;
}
case "template_item.delete": {
await db
.update(adventureItems)
.set({ deletedAt: null })
.where(eq(adventureItems.id, event.entityId));
break;
}
default:
return { ok: false, error: `Unknown action type: ${event.actionType}` };
}
await markUndone(actionEventId);
return { ok: true, summary: event.summary };
} catch (e) {
const msg = e instanceof Error ? e.message : "Undo failed";
return { ok: false, error: msg };
}
}
export { type RecordActionInput };

View File

@@ -0,0 +1,44 @@
import { eq } from "drizzle-orm";
import { db, users, userProgress } from "../db";
export async function getUser() {
const [user] = await db.select().from(users).limit(1);
if (!user) return null;
const [progress] = await db
.select()
.from(userProgress)
.where(eq(userProgress.userId, user.id));
return { user, progress: progress ?? null };
}
export async function requireUser() {
const result = await getUser();
if (!result?.user) {
throw new Error("No user found. Run db:seed first.");
}
return result;
}
export async function updateUserProfile(
userId: string,
data: {
displayName?: string;
portraitConfig?: {
skinTone: string;
hairColor: string;
clothingColor: string;
};
currentTitle?: string;
}
) {
await db
.update(users)
.set({
...(data.displayName && { displayName: data.displayName }),
...(data.portraitConfig && { portraitConfig: data.portraitConfig }),
...(data.currentTitle !== undefined && { currentTitle: data.currentTitle }),
})
.where(eq(users.id, userId));
}

147
apps/web/src/lib/services/xp.ts Executable file
View File

@@ -0,0 +1,147 @@
import { eq, and, gte, lte, desc, sql, sum } from "drizzle-orm";
import {
applyDiminishingReturns,
chapterForLevel,
DAILY_SOFT_CAPS,
levelFromXp,
XP_AWARDS,
type XpSource,
} from "@adventureos/shared";
import { db, userProgress, xpEvents } from "../db";
export async function getDailyXpEarned(
userId: string,
date: string,
source?: XpSource
): Promise<number> {
const conditions = [
eq(xpEvents.userId, userId),
eq(xpEvents.date, date),
];
if (source) {
conditions.push(eq(xpEvents.source, source));
}
const [row] = await db
.select({ total: sum(xpEvents.amount) })
.from(xpEvents)
.where(and(...conditions));
return Number(row?.total ?? 0);
}
export async function awardXp(
userId: string,
date: string,
source: XpSource,
amount: number,
metadata?: Record<string, unknown>
): Promise<{ awarded: number; levelUp: boolean; newLevel: number; xpEventId?: string }> {
if (amount <= 0) {
const [progress] = await db
.select()
.from(userProgress)
.where(eq(userProgress.userId, userId));
return { awarded: 0, levelUp: false, newLevel: progress?.level ?? 1 };
}
const softCap = DAILY_SOFT_CAPS[source];
let finalAmount = amount;
if (softCap) {
const earned = await getDailyXpEarned(userId, date, source);
finalAmount = applyDiminishingReturns(amount, earned, softCap);
}
if (finalAmount <= 0) {
const [progress] = await db
.select()
.from(userProgress)
.where(eq(userProgress.userId, userId));
return { awarded: 0, levelUp: false, newLevel: progress?.level ?? 1 };
}
const [inserted] = await db
.insert(xpEvents)
.values({
userId,
date,
source,
amount: finalAmount,
metadata,
})
.returning();
const [progress] = await db
.select()
.from(userProgress)
.where(eq(userProgress.userId, userId));
const oldLevel = progress.level;
const newTotal = progress.totalXp + finalAmount;
const newLevel = levelFromXp(newTotal);
const chapter = chapterForLevel(newLevel);
await db
.update(userProgress)
.set({
totalXp: newTotal,
level: newLevel,
currentChapter: chapter.name,
})
.where(eq(userProgress.userId, userId));
return {
awarded: finalAmount,
levelUp: newLevel > oldLevel,
newLevel,
xpEventId: inserted?.id,
};
}
export async function awardDailyVisit(userId: string, date: string) {
const earned = await getDailyXpEarned(userId, date, "daily_visit");
if (earned > 0) return null;
return awardXp(userId, date, "daily_visit", XP_AWARDS.daily_visit);
}
export async function getXpInRange(
userId: string,
from: string,
to: string
) {
return db
.select()
.from(xpEvents)
.where(
and(
eq(xpEvents.userId, userId),
gte(xpEvents.date, from),
lte(xpEvents.date, to)
)
)
.orderBy(desc(xpEvents.date));
}
export async function getXpBySource(
userId: string,
from: string,
to: string
) {
const rows = await db
.select({
source: xpEvents.source,
total: sum(xpEvents.amount),
})
.from(xpEvents)
.where(
and(
eq(xpEvents.userId, userId),
gte(xpEvents.date, from),
lte(xpEvents.date, to)
)
)
.groupBy(xpEvents.source);
return rows.map((r) => ({
category: r.source,
amount: Number(r.total ?? 0),
}));
}

38
apps/web/src/lib/types/jsonb.ts Executable file
View File

@@ -0,0 +1,38 @@
/** Typed shapes stored in JSONB columns and action event payloads. */
export type ActionBeforeState = Record<string, unknown>;
export type BookBeforeState = {
currentPage: number;
status: string;
finishedAt: Date | null;
};
export type ReadingLogBeforeState =
| { book: BookBeforeState }
| { currentPage: number; status: string };
export type AdventureItemValueJson = {
hours?: number;
note?: string;
checks?: boolean[];
pages?: number;
done?: boolean;
};
export type AdventureItemConfigJson = {
targetHours?: number;
checklistSize?: number;
bookId?: string;
scheduledTime?: string;
note?: string;
};
export type AiConfigJson = {
personality?: string;
verbosity?: string;
frequency?: string;
creativity?: number;
enabled?: boolean;
strictMode?: boolean;
};

View File

@@ -0,0 +1,2 @@
export { parseJsonBody } from "./parse";
export * from "./schemas";

View File

@@ -0,0 +1,20 @@
import { z } from "zod";
import { ValidationError } from "@/lib/errors";
export async function parseJsonBody<T>(
request: Request,
schema: z.ZodType<T>
): Promise<T> {
let raw: unknown;
try {
raw = await request.json();
} catch {
throw new ValidationError("Invalid JSON body");
}
const result = schema.safeParse(raw);
if (!result.success) {
const msg = result.error.issues.map((i) => i.message).join("; ");
throw new ValidationError(msg || "Validation failed");
}
return result.data;
}

View File

@@ -0,0 +1,39 @@
import { describe, it, expect } from "vitest";
import { ValidationError, mapErrorToResponse } from "@/lib/errors";
import { teacherCreateSchema, normalizeTeacherCreateBody } from "@/lib/validation/schemas";
describe("teacherCreateSchema", () => {
it("requires topic", () => {
const result = teacherCreateSchema.safeParse({});
expect(result.success).toBe(false);
});
it("accepts valid topic", () => {
const result = teacherCreateSchema.safeParse({ topic: "Rust ownership" });
expect(result.success).toBe(true);
});
it("accepts null explorationId", () => {
const result = teacherCreateSchema.safeParse({
topic: "Rust ownership",
explorationId: null,
});
expect(result.success).toBe(true);
if (result.success) {
expect(normalizeTeacherCreateBody(result.data).explorationId).toBeUndefined();
}
});
});
describe("mapErrorToResponse", () => {
it("maps ValidationError to 400", () => {
const mapped = mapErrorToResponse(new ValidationError("bad input"));
expect(mapped.status).toBe(400);
expect(mapped.message).toBe("bad input");
});
it("maps Unauthorized to 401", () => {
const mapped = mapErrorToResponse(new Error("Unauthorized"));
expect(mapped.status).toBe(401);
});
});

View File

@@ -0,0 +1,81 @@
import { z } from "zod";
export const teacherCreateSchema = z.object({
topic: z.string().min(1, "Topic is required"),
explorationId: z.union([z.string().uuid(), z.null()]).optional(),
});
export type TeacherCreateBody = {
topic: string;
explorationId?: string;
};
export function normalizeTeacherCreateBody(
body: z.infer<typeof teacherCreateSchema>
): TeacherCreateBody {
return {
topic: body.topic,
explorationId: body.explorationId ?? undefined,
};
}
export const templateCreateSchema = z.object({
name: z.string().min(1),
daysOfWeek: z.array(z.number().int().min(0).max(6)).optional(),
isDefault: z.boolean().optional(),
sortPriority: z.number().int().optional(),
});
export const templateUpdateSchema = templateCreateSchema.partial();
export const templateItemSchema = z.object({
type: z.string().min(1),
label: z.string().min(1),
config: z.record(z.string(), z.unknown()).optional(),
sortOrder: z.number().int().optional(),
enabled: z.boolean().optional(),
});
export const settingsPatchSchema = z.object({
profile: z
.object({
displayName: z.string().optional(),
currentTitle: z.string().optional(),
})
.optional(),
settings: z.record(z.string(), z.unknown()).optional(),
spiritual: z
.object({
prayerLabels: z.array(z.string()).optional(),
litanyLabels: z.array(z.string()).optional(),
})
.optional(),
});
export const adventureItemUpdateSchema = z.object({
value: z.record(z.string(), z.unknown()).optional(),
state: z.enum(["blank", "started", "partial", "done"]).optional(),
label: z.string().optional(),
enabled: z.boolean().optional(),
config: z.record(z.string(), z.unknown()).optional(),
});
export const dailyTodoCreateSchema = z.object({
label: z.string().min(1),
});
export const dailyTodoUpdateSchema = z.object({
label: z.string().optional(),
done: z.boolean().optional(),
});
export const bookCreateSchema = z.object({
title: z.string().min(1),
author: z.string().optional(),
totalPages: z.number().int().positive().optional(),
});
export const logPagesSchema = z.object({
pages: z.number().int().positive(),
date: z.string().optional(),
});