server: reconcile ai_sessions persistence to Chunk 9 AISession shape

Reshape the ai_sessions table (migration 003) and mapping/routes to the
full AISession contract: title, kind, notes, created_at, last_message_at;
drop legacy role/model/settings. toAISession now maps the new columns and
routes create/patch/messages handlers use the new shape.
This commit is contained in:
Schwitzz
2026-10-29 15:03:32 -05:00
parent fc8eaa86c3
commit a1b5587fcb
15 changed files with 181 additions and 60 deletions
+3 -3
View File
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AgentController, isRateLimitError } from "./index.js"; import { AgentController, isRateLimitError } from "./index.js";
import type { ResolvedProvider } from "./index.js"; import type { CompletionUsage, ResolvedProvider } from "./index.js";
/** Build a `text/event-stream` Response from a list of SSE event payloads. */ /** Build a `text/event-stream` Response from a list of SSE event payloads. */
function sseResponse(events: string[], status = 200): Response { function sseResponse(events: string[], status = 200): Response {
@@ -78,7 +78,7 @@ describe("AgentController.stream", () => {
])) as unknown as typeof fetch; ])) as unknown as typeof fetch;
const agent = new AgentController(config); const agent = new AgentController(config);
let seen: (typeof config) | null = null; let seen: CompletionUsage | null = null;
for await (const chunk of agent.stream([{ role: "user", content: "hi" }])) { for await (const chunk of agent.stream([{ role: "user", content: "hi" }])) {
if (chunk.type === "done" && chunk.usage) { if (chunk.type === "done" && chunk.usage) {
seen = chunk.usage; seen = chunk.usage;
@@ -132,7 +132,7 @@ describe("AgentController.collect", () => {
describe("thinking-param mapping", () => { describe("thinking-param mapping", () => {
it("maps thinking config onto the request body", async () => { it("maps thinking config onto the request body", async () => {
const calls: Array<RequestInit> = []; const calls: Array<RequestInit> = [];
globalThis.fetch = ((url: string, init: RequestInit) => { globalThis.fetch = ((_url: string, init: RequestInit) => {
calls.push(init); calls.push(init);
return Promise.resolve( return Promise.resolve(
sseResponse([JSON.stringify({ choices: [{ delta: { content: "ok" } }] })]), sseResponse([JSON.stringify({ choices: [{ delta: { content: "ok" } }] })]),
+8 -4
View File
@@ -143,11 +143,15 @@ export class AgentController {
extraBody: { ...opts.extraBody, ...thinking }, extraBody: { ...opts.extraBody, ...thinking },
})) { })) {
if (chunk.type === "text") { if (chunk.type === "text") {
cb.onText?.(chunk.text); if (chunk.text !== undefined) {
buffered.push(chunk); cb.onText?.(chunk.text);
buffered.push(chunk);
}
} else if (chunk.type === "reasoning") { } else if (chunk.type === "reasoning") {
cb.onReasoning?.(chunk.reasoning); if (chunk.reasoning !== undefined) {
buffered.push(chunk); cb.onReasoning?.(chunk.reasoning);
buffered.push(chunk);
}
} else if (chunk.type === "done") { } else if (chunk.type === "done") {
if (chunk.usage) { if (chunk.usage) {
this.usage = chunk.usage; this.usage = chunk.usage;
+3 -6
View File
@@ -1,17 +1,18 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AIStore } from "./index.js"; import { AIStore } from "./index.js";
import type { SessionRow } from "./index.js";
import type { AISessionPersistence } from "./index.js"; import type { AISessionPersistence } from "./index.js";
/** A simple in-memory persistence backend for tests. */ /** A simple in-memory persistence backend for tests. */
function memoryPersistence(): AISessionPersistence { function memoryPersistence(): AISessionPersistence {
const rows = new Map<string, unknown>(); const rows = new Map<string, SessionRow>();
return { return {
list: async (projectId) => list: async (projectId) =>
Array.from(rows.values()) Array.from(rows.values())
.filter((r) => r.projectId === projectId) .filter((r) => r.projectId === projectId)
.map((r) => ({ ...r })), .map((r) => ({ ...r })),
get: async (id) => (rows.has(id) ? { ...rows.get(id) } : null), get: async (id) => (rows.has(id) ? ({ ...rows.get(id) } as SessionRow) : null),
save: async (session) => { save: async (session) => {
rows.set(session.id, { ...session }); rows.set(session.id, { ...session });
}, },
@@ -62,10 +63,8 @@ describe("AIStore", () => {
it("adds a message to a session", async () => { it("adds a message to a session", async () => {
const session = await store.createSession(); const session = await store.createSession();
const updated = await store.addMessage(session.id, { const updated = await store.addMessage(session.id, {
id: "m1",
role: "user", role: "user",
content: "hello", content: "hello",
createdAt: "2026-01-01T00:00:00.000Z",
}); });
expect(updated.messages).toHaveLength(1); expect(updated.messages).toHaveLength(1);
expect(updated.messages[0]?.role).toBe("user"); expect(updated.messages[0]?.role).toBe("user");
@@ -74,14 +73,12 @@ describe("AIStore", () => {
it("adds a suggestion and lists it", async () => { it("adds a suggestion and lists it", async () => {
const session = await store.createSession(); const session = await store.createSession();
const updated = await store.addSuggestion(session.id, { const updated = await store.addSuggestion(session.id, {
id: "s1",
sessionId: session.id, sessionId: session.id,
kind: "brainstorm", kind: "brainstorm",
title: "Ideas", title: "Ideas",
text: "some ideas", text: "some ideas",
summary: "summary", summary: "summary",
status: "pending", status: "pending",
createdAt: "2026-01-01T00:00:00.000Z",
}); });
expect(updated.suggestions).toHaveLength(1); expect(updated.suggestions).toHaveLength(1);
const list = await store.listSuggestions(session.id); const list = await store.listSuggestions(session.id);
+3 -2
View File
@@ -24,7 +24,7 @@ import type {
} from "@pubscript/core"; } from "@pubscript/core";
/** A persisted session row. */ /** A persisted session row. */
interface SessionRow { export interface SessionRow {
id: string; id: string;
projectId: string; projectId: string;
title: string; title: string;
@@ -76,7 +76,8 @@ export function createIndexedDBPersistence(dbName = "pubscript"): AISessionPersi
return table.where("projectId").equals(projectId).sortBy("updatedAt").then((rows) => rows.reverse()); return table.where("projectId").equals(projectId).sortBy("updatedAt").then((rows) => rows.reverse());
}, },
async get(id: string) { async get(id: string) {
return table.get(id) ?? null; const row = await table.get(id);
return row ?? null;
}, },
async save(row: SessionRow) { async save(row: SessionRow) {
await table.put(row); await table.put(row);
+1 -1
View File
@@ -97,7 +97,7 @@ describe("NoteBuilder", () => {
const init = calls[0] as RequestInit; const init = calls[0] as RequestInit;
const body = JSON.parse(init.body as string) as Record<string, unknown>; const body = JSON.parse(init.body as string) as Record<string, unknown>;
const messages = body.messages as Array<{ content: string }>; const messages = body.messages as Array<{ content: string; role?: string }>;
const system = messages.find((m) => m.role === "system")?.content ?? ""; const system = messages.find((m) => m.role === "system")?.content ?? "";
expect(system).toContain("The Stolen Relic"); expect(system).toContain("The Stolen Relic");
}); });
+1 -1
View File
@@ -135,7 +135,7 @@ export class NoteBuilder {
// Extract tags from #word patterns and a leading "tags:" line. // Extract tags from #word patterns and a leading "tags:" line.
const tagMatches = [...cleaned.matchAll(/#([\p{L}[\]_][\p{L}[\]0-9 ]{1,20})/gu)]; const tagMatches = [...cleaned.matchAll(/#([\p{L}[\]_][\p{L}[\]0-9 ]{1,20})/gu)];
const tags = [...new Set(tagMatches.map((m) => m[1].trim().toLowerCase()))].slice(0, 8); const tags = [...new Set(tagMatches.map((m) => m[1]!.trim().toLowerCase()))].slice(0, 8);
// Drop the title line and any tags line from the body. // Drop the title line and any tags line from the body.
const body = lines const body = lines
@@ -78,8 +78,6 @@ describe("SuggestionMutation", () => {
const mut = new SuggestionMutation(suggestion({ const mut = new SuggestionMutation(suggestion({
originalText: "The cat sat", originalText: "The cat sat",
newText: "The dog barked", newText: "The dog barked",
anchorIndex: undefined,
spanLength: undefined,
})); }));
const result = mut.apply(doc); const result = mut.apply(doc);
expect(result.success).toBe(true); expect(result.success).toBe(true);
@@ -91,8 +89,6 @@ describe("SuggestionMutation", () => {
const mut = new SuggestionMutation(suggestion({ const mut = new SuggestionMutation(suggestion({
originalText: "cat", originalText: "cat",
newText: "dog", newText: "dog",
anchorIndex: undefined,
spanLength: 3,
})); }));
const result = mut.apply(doc); const result = mut.apply(doc);
expect(result.success).toBe(true); expect(result.success).toBe(true);
+1 -1
View File
@@ -87,7 +87,7 @@ describe("SuggestionBuilder", () => {
const init = calls[0] as RequestInit; const init = calls[0] as RequestInit;
const body = JSON.parse(init.body as string) as Record<string, unknown>; const body = JSON.parse(init.body as string) as Record<string, unknown>;
const messages = body.messages as Array<{ content: string }>; const messages = body.messages as Array<{ content: string; role?: string }>;
const system = messages.find((m) => m.role === "system")?.content ?? ""; const system = messages.find((m) => m.role === "system")?.content ?? "";
expect(system).toContain("The Return"); expect(system).toContain("The Return");
expect(system).toContain("the capital"); expect(system).toContain("the capital");
+1 -1
View File
@@ -164,7 +164,7 @@ export class SuggestionBuilder {
const cleaned = text.trim(); const cleaned = text.trim();
const firstLine = cleaned.split(/\r?\n/).find((l) => l.trim().length > 0) ?? ""; const firstLine = cleaned.split(/\r?\n/).find((l) => l.trim().length > 0) ?? "";
const title = firstLine.replace(/^#+\s*/, "").trim().slice(0, 120) || `${kind} suggestion`; const title = firstLine.replace(/^#+\s*/, "").trim().slice(0, 120) || `${kind} suggestion`;
const summary = cleaned.split(/\r?\n\r?\n/)[0].replace(/^#+\s*/, "").trim().slice(0, 300); const summary = cleaned.split(/\r?\n\r?\n/)[0]!.replace(/^#+\s*/, "").trim().slice(0, 300);
return { title, summary, text: cleaned }; return { title, summary, text: cleaned };
} }
} }
+6 -3
View File
@@ -200,11 +200,14 @@ export function createServerClient(opts: ServerClientOptions): StorageAdapter {
async saveAISession(s: AISession): Promise<void> { async saveAISession(s: AISession): Promise<void> {
await req("POST", `/api/projects/${s.projectId}/ai/sessions`, { await req("POST", `/api/projects/${s.projectId}/ai/sessions`, {
id: s.id, id: s.id,
role: s.role, title: s.title,
model: s.model, kind: s.kind,
settings: s.settings,
messages: s.messages, messages: s.messages,
suggestions: s.suggestions, suggestions: s.suggestions,
notes: s.notes,
createdAt: s.createdAt,
updatedAt: s.updatedAt,
lastMessageAt: s.lastMessageAt,
}); });
}, },
+4 -2
View File
@@ -96,7 +96,8 @@ function mockServerFetch(): typeof fetch {
} }
m = u.pathname.match(/^\/api\/projects\/([^/]+)\/ai\/sessions$/); m = u.pathname.match(/^\/api\/projects\/([^/]+)\/ai\/sessions$/);
if (method === "POST" && m) { if (method === "POST" && m) {
const s = { id: body.id ?? id(), projectId: m[1]!, role: body.role, model: body.model, settings: body.settings ?? {}, messages: body.messages ?? [], suggestions: body.suggestions ?? [] }; const now = new Date().toISOString();
const s = { id: body.id ?? id(), projectId: m[1]!, title: body.title ?? "", kind: body.kind ?? "session", messages: body.messages ?? [], suggestions: body.suggestions ?? [], notes: body.notes ?? [], createdAt: body.createdAt ?? now, updatedAt: body.updatedAt ?? now, lastMessageAt: body.lastMessageAt ?? null };
sessions.set(s.id, s); sessions.set(s.id, s);
return json({ session: s }); return json({ session: s });
} }
@@ -131,7 +132,8 @@ async function runScenario(adapter: StorageAdapter) {
edges: [], edges: [],
}); });
await adapter.saveAISession({ await adapter.saveAISession({
id: "sess-1", projectId: project.id, role: "editor", model: "m", settings: {}, messages: [], suggestions: [], id: "sess-1", projectId: project.id, title: "Session", kind: "session",
messages: [], suggestions: [], notes: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), lastMessageAt: null,
}); });
const unsub = adapter.onEvent(() => {}); const unsub = adapter.onEvent(() => {});
+79 -8
View File
@@ -211,13 +211,23 @@ export interface Note {
linkedTo?: string; linkedTo?: string;
} }
export type SuggestionKind = /** Editor inline-review suggestion kinds. */
export type EditorSuggestionKind =
| "replace" | "replace"
| "insert" | "insert"
| "delete" | "delete"
| "entity-update" | "entity-update"
| "draft"; | "draft";
/** AI suggestion kinds produced by the AI provider layer. */
export type SuggestionKind =
| "brainstorm"
| "outline"
| "research"
| "review"
| "expand"
| "summarize";
export type SuggestionStatus = "pending" | "accepted" | "rejected" | "pinned"; export type SuggestionStatus = "pending" | "accepted" | "rejected" | "pinned";
export interface SuggestionTarget { export interface SuggestionTarget {
@@ -234,20 +244,81 @@ export interface Suggestion {
before: string; before: string;
after: string; after: string;
rationale: string; rationale: string;
kind: SuggestionKind; kind: EditorSuggestionKind;
status: SuggestionStatus; status: SuggestionStatus;
} }
export type AISessionRole = "editor" | "reader" | "publisher" | "co-author"; /** Kinds of AI-backed sessions. */
export type SessionKind = "session" | "agent";
/** Role of a message within an AI session. */
export type AIMessageRole = "system" | "user" | "assistant";
/** A single message in an AI session. */
export interface AIMessage {
id: string;
createdAt: string;
role: AIMessageRole;
content: string;
}
/** Kind of an AI-generated note. */
export type NoteKind = "character" | "world" | "plot" | "theme" | "general";
/** Lifecycle status of an AI-generated note. */
export type NoteStatus = "draft" | "published";
/** An AI-generated note attached to a session. */
export interface AINote {
id: string;
sessionId: string;
kind: NoteKind;
title?: string;
body: string;
tags?: string[];
status: NoteStatus;
createdAt: string;
updatedAt: string;
}
/** Lifecycle status of an AI-generated suggestion. */
export type AISuggestionStatus =
| "pending"
| "applied"
| "accepted"
| "rejected";
/** An AI-generated suggestion attached to a session. */
export interface AISuggestion {
id: string;
sessionId: string;
createdAt: string;
kind: SuggestionKind;
title: string;
text: string;
summary: string;
originalText?: string;
newText?: string;
anchorIndex?: number;
spanLength?: number;
status: AISuggestionStatus;
appliedAt?: string;
resolvedAt?: string;
resolution?: "accepted" | "rejected";
}
/** An AI-backed session with its messages, suggestions, and notes. */
export interface AISession { export interface AISession {
id: string; id: string;
projectId: string; projectId: string;
role: AISessionRole; title: string;
model: string; kind: SessionKind;
settings: Record<string, unknown>; messages: AIMessage[];
messages: Array<{ role: "system" | "user" | "assistant"; content: string }>; suggestions: AISuggestion[];
suggestions: Suggestion[]; notes: AINote[];
createdAt: string;
updatedAt: string;
lastMessageAt: string | null;
} }
/** A reference to a document that can be saved through the adapter. */ /** A reference to a document that can be saved through the adapter. */
+12 -7
View File
@@ -1,6 +1,5 @@
import type { import type {
AISession, AISession,
AISessionRole,
AuditLogEntry, AuditLogEntry,
Board, Board,
Book, Book,
@@ -11,6 +10,7 @@ import type {
PlotBeat, PlotBeat,
Project, Project,
ProjectMeta, ProjectMeta,
SessionKind,
User, User,
WorldItem, WorldItem,
} from "@pubscript/core"; } from "@pubscript/core";
@@ -98,12 +98,14 @@ export interface PlotBeatRow {
export interface AISessionRow { export interface AISessionRow {
id: string; id: string;
project_id: string; project_id: string;
role: string; title: string;
model: string; kind: string;
settings: string;
messages: string; messages: string;
suggestions: string; suggestions: string;
notes: string;
created_at: string;
updated_at: string; updated_at: string;
last_message_at: string | null;
} }
// ---- Row -> core types ---- // ---- Row -> core types ----
@@ -235,11 +237,14 @@ export function toAISession(r: AISessionRow): AISession {
return { return {
id: r.id, id: r.id,
projectId: r.project_id, projectId: r.project_id,
role: r.role as AISessionRole, title: r.title,
model: r.model, kind: r.kind as SessionKind,
settings: JSON.parse(r.settings),
messages: JSON.parse(r.messages), messages: JSON.parse(r.messages),
suggestions: JSON.parse(r.suggestions), suggestions: JSON.parse(r.suggestions),
notes: JSON.parse(r.notes),
createdAt: r.created_at,
updatedAt: r.updated_at,
lastMessageAt: r.last_message_at,
}; };
} }
+24
View File
@@ -0,0 +1,24 @@
-- Chunk #9: reshape ai_sessions to the full AISession shape.
-- Adds title, kind, notes, created_at, last_message_at; drops role, model, settings.
CREATE TABLE ai_sessions_new (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
title TEXT NOT NULL DEFAULT '',
kind TEXT NOT NULL DEFAULT 'session',
messages TEXT NOT NULL DEFAULT '[]',
suggestions TEXT NOT NULL DEFAULT '[]',
notes TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_message_at TEXT
);
INSERT INTO ai_sessions_new (id, project_id, title, kind, messages, suggestions, notes, created_at, updated_at, last_message_at)
SELECT id, project_id, '', 'session', messages, suggestions, '[]', updated_at, updated_at, NULL
FROM ai_sessions;
DROP TABLE ai_sessions;
ALTER TABLE ai_sessions_new RENAME TO ai_sessions;
CREATE INDEX IF NOT EXISTS idx_ai_sessions_project ON ai_sessions(project_id);
+35 -17
View File
@@ -92,11 +92,14 @@ const beatCreate = z.object({
}); });
const aiSessionCreate = z.object({ const aiSessionCreate = z.object({
id: z.string().optional(), id: z.string().optional(),
role: z.string().min(1), title: z.string().default(""),
model: z.string().min(1), kind: z.enum(["session", "agent"]).default("session"),
settings: z.record(z.unknown()).default({}), messages: z.array(z.any()).default([]),
messages: z.array(z.any()).optional(), suggestions: z.array(z.any()).default([]),
suggestions: z.array(z.any()).optional(), notes: z.array(z.any()).default([]),
createdAt: z.string().optional(),
updatedAt: z.string().optional(),
lastMessageAt: z.string().nullable().optional(),
}); });
const aiMessageAppend = z.object({ role: z.string(), content: z.string() }); const aiMessageAppend = z.object({ role: z.string(), content: z.string() });
const aiSuggestionAdd = z.object({ kind: z.string(), text: z.string() }); const aiSuggestionAdd = z.object({ kind: z.string(), text: z.string() });
@@ -565,14 +568,15 @@ export function registerRoutes(app: FastifyInstance): void {
const sid = body.id ?? ulid(); const sid = body.id ?? ulid();
const t = now(); const t = now();
db.prepare( db.prepare(
`INSERT INTO ai_sessions (id, project_id, role, model, settings, messages, suggestions, updated_at) `INSERT INTO ai_sessions (id, project_id, title, kind, messages, suggestions, notes, created_at, updated_at, last_message_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
project_id = excluded.project_id, role = excluded.role, model = excluded.model, title = excluded.title, kind = excluded.kind,
settings = excluded.settings, messages = excluded.messages, suggestions = excluded.suggestions, messages = excluded.messages, suggestions = excluded.suggestions, notes = excluded.notes,
updated_at = excluded.updated_at`, updated_at = excluded.updated_at, last_message_at = excluded.last_message_at`,
).run(sid, id, body.role, body.model, JSON.stringify(body.settings), ).run(sid, id, body.title, body.kind, JSON.stringify(body.messages),
JSON.stringify(body.messages ?? []), JSON.stringify(body.suggestions ?? []), t); JSON.stringify(body.suggestions), JSON.stringify(body.notes),
body.createdAt ?? t, t, body.lastMessageAt ?? null);
const row = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(sid) as AISessionRow; const row = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(sid) as AISessionRow;
return { session: toAISession(row) }; return { session: toAISession(row) };
}); });
@@ -590,11 +594,25 @@ export function registerRoutes(app: FastifyInstance): void {
const row = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow | undefined; const row = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow | undefined;
if (!row) throw app.httpErrors.create(404, { error: "not_found" }); if (!row) throw app.httpErrors.create(404, { error: "not_found" });
requireProjectAccess(app, req, row.project_id, true); requireProjectAccess(app, req, row.project_id, true);
const body = z.object({ settings: z.record(z.unknown()).optional(), model: z.string().optional() }).parse(req.body); const body = z.object({
const settings = body.settings ? JSON.stringify(body.settings) : row.settings; title: z.string().optional(),
const model = body.model ?? row.model; kind: z.enum(["session", "agent"]).optional(),
messages: z.array(z.any()).optional(),
suggestions: z.array(z.any()).optional(),
notes: z.array(z.any()).optional(),
lastMessageAt: z.string().nullable().optional(),
}).parse(req.body);
const t = now(); const t = now();
db.prepare("UPDATE ai_sessions SET settings = ?, model = ?, updated_at = ? WHERE id = ?").run(settings, model, t, id); db.prepare(
`UPDATE ai_sessions SET title = ?, kind = ?, messages = ?, suggestions = ?, notes = ?, last_message_at = ?, updated_at = ? WHERE id = ?`
).run(
body.title ?? row.title, body.kind ?? row.kind,
JSON.stringify(body.messages ?? JSON.parse(row.messages)),
JSON.stringify(body.suggestions ?? JSON.parse(row.suggestions)),
JSON.stringify(body.notes ?? JSON.parse(row.notes)),
body.lastMessageAt ?? row.last_message_at,
t, id,
);
const updated = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow; const updated = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow;
return { session: toAISession(updated) }; return { session: toAISession(updated) };
}); });
@@ -617,7 +635,7 @@ export function registerRoutes(app: FastifyInstance): void {
const messages = JSON.parse(row.messages) as any[]; const messages = JSON.parse(row.messages) as any[];
messages.push({ role, content, at: now() }); messages.push({ role, content, at: now() });
const t = now(); const t = now();
db.prepare("UPDATE ai_sessions SET messages = ?, updated_at = ? WHERE id = ?").run(JSON.stringify(messages), t, id); db.prepare("UPDATE ai_sessions SET messages = ?, last_message_at = ?, updated_at = ? WHERE id = ?").run(JSON.stringify(messages), t, t, id);
const updated = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow; const updated = db.prepare("SELECT * FROM ai_sessions WHERE id = ?").get(id) as AISessionRow;
return { session: toAISession(updated) }; return { session: toAISession(updated) };
}); });