diff --git a/packages/ai/src/agent.test.ts b/packages/ai/src/agent.test.ts index 7f4832b..ab780e7 100644 --- a/packages/ai/src/agent.test.ts +++ b/packages/ai/src/agent.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 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. */ function sseResponse(events: string[], status = 200): Response { @@ -78,7 +78,7 @@ describe("AgentController.stream", () => { ])) as unknown as typeof fetch; 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" }])) { if (chunk.type === "done" && chunk.usage) { seen = chunk.usage; @@ -132,7 +132,7 @@ describe("AgentController.collect", () => { describe("thinking-param mapping", () => { it("maps thinking config onto the request body", async () => { const calls: Array = []; - globalThis.fetch = ((url: string, init: RequestInit) => { + globalThis.fetch = ((_url: string, init: RequestInit) => { calls.push(init); return Promise.resolve( sseResponse([JSON.stringify({ choices: [{ delta: { content: "ok" } }] })]), diff --git a/packages/ai/src/agent.ts b/packages/ai/src/agent.ts index 398a1d1..8dc71b0 100644 --- a/packages/ai/src/agent.ts +++ b/packages/ai/src/agent.ts @@ -143,11 +143,15 @@ export class AgentController { extraBody: { ...opts.extraBody, ...thinking }, })) { if (chunk.type === "text") { - cb.onText?.(chunk.text); - buffered.push(chunk); + if (chunk.text !== undefined) { + cb.onText?.(chunk.text); + buffered.push(chunk); + } } else if (chunk.type === "reasoning") { - cb.onReasoning?.(chunk.reasoning); - buffered.push(chunk); + if (chunk.reasoning !== undefined) { + cb.onReasoning?.(chunk.reasoning); + buffered.push(chunk); + } } else if (chunk.type === "done") { if (chunk.usage) { this.usage = chunk.usage; diff --git a/packages/ai/src/ai-session.test.ts b/packages/ai/src/ai-session.test.ts index 6f99f8c..83c8442 100644 --- a/packages/ai/src/ai-session.test.ts +++ b/packages/ai/src/ai-session.test.ts @@ -1,17 +1,18 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { AIStore } from "./index.js"; +import type { SessionRow } from "./index.js"; import type { AISessionPersistence } from "./index.js"; /** A simple in-memory persistence backend for tests. */ function memoryPersistence(): AISessionPersistence { - const rows = new Map(); + const rows = new Map(); return { list: async (projectId) => Array.from(rows.values()) .filter((r) => r.projectId === projectId) .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) => { rows.set(session.id, { ...session }); }, @@ -62,10 +63,8 @@ describe("AIStore", () => { it("adds a message to a session", async () => { const session = await store.createSession(); const updated = await store.addMessage(session.id, { - id: "m1", role: "user", content: "hello", - createdAt: "2026-01-01T00:00:00.000Z", }); expect(updated.messages).toHaveLength(1); expect(updated.messages[0]?.role).toBe("user"); @@ -74,14 +73,12 @@ describe("AIStore", () => { it("adds a suggestion and lists it", async () => { const session = await store.createSession(); const updated = await store.addSuggestion(session.id, { - id: "s1", sessionId: session.id, kind: "brainstorm", title: "Ideas", text: "some ideas", summary: "summary", status: "pending", - createdAt: "2026-01-01T00:00:00.000Z", }); expect(updated.suggestions).toHaveLength(1); const list = await store.listSuggestions(session.id); diff --git a/packages/ai/src/ai-session.ts b/packages/ai/src/ai-session.ts index c8fd8a8..c216ad2 100644 --- a/packages/ai/src/ai-session.ts +++ b/packages/ai/src/ai-session.ts @@ -24,7 +24,7 @@ import type { } from "@pubscript/core"; /** A persisted session row. */ -interface SessionRow { +export interface SessionRow { id: string; projectId: 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()); }, async get(id: string) { - return table.get(id) ?? null; + const row = await table.get(id); + return row ?? null; }, async save(row: SessionRow) { await table.put(row); diff --git a/packages/ai/src/notes.test.ts b/packages/ai/src/notes.test.ts index 14f19ae..648d396 100644 --- a/packages/ai/src/notes.test.ts +++ b/packages/ai/src/notes.test.ts @@ -97,7 +97,7 @@ describe("NoteBuilder", () => { const init = calls[0] as RequestInit; const body = JSON.parse(init.body as string) as Record; - 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 ?? ""; expect(system).toContain("The Stolen Relic"); }); diff --git a/packages/ai/src/notes.ts b/packages/ai/src/notes.ts index 098a354..5dd2ab6 100644 --- a/packages/ai/src/notes.ts +++ b/packages/ai/src/notes.ts @@ -135,7 +135,7 @@ export class NoteBuilder { // Extract tags from #word patterns and a leading "tags:" line. 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. const body = lines diff --git a/packages/ai/src/suggestion-mutation.test.ts b/packages/ai/src/suggestion-mutation.test.ts index a5af357..b1ef2ee 100644 --- a/packages/ai/src/suggestion-mutation.test.ts +++ b/packages/ai/src/suggestion-mutation.test.ts @@ -78,8 +78,6 @@ describe("SuggestionMutation", () => { const mut = new SuggestionMutation(suggestion({ originalText: "The cat sat", newText: "The dog barked", - anchorIndex: undefined, - spanLength: undefined, })); const result = mut.apply(doc); expect(result.success).toBe(true); @@ -91,8 +89,6 @@ describe("SuggestionMutation", () => { const mut = new SuggestionMutation(suggestion({ originalText: "cat", newText: "dog", - anchorIndex: undefined, - spanLength: 3, })); const result = mut.apply(doc); expect(result.success).toBe(true); diff --git a/packages/ai/src/suggestions.test.ts b/packages/ai/src/suggestions.test.ts index d6fcfa9..005d1ee 100644 --- a/packages/ai/src/suggestions.test.ts +++ b/packages/ai/src/suggestions.test.ts @@ -87,7 +87,7 @@ describe("SuggestionBuilder", () => { const init = calls[0] as RequestInit; const body = JSON.parse(init.body as string) as Record; - 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 ?? ""; expect(system).toContain("The Return"); expect(system).toContain("the capital"); diff --git a/packages/ai/src/suggestions.ts b/packages/ai/src/suggestions.ts index 926278e..e60db6e 100644 --- a/packages/ai/src/suggestions.ts +++ b/packages/ai/src/suggestions.ts @@ -164,7 +164,7 @@ export class SuggestionBuilder { const cleaned = text.trim(); const firstLine = cleaned.split(/\r?\n/).find((l) => l.trim().length > 0) ?? ""; 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 }; } } diff --git a/packages/core/src/storage-server.ts b/packages/core/src/storage-server.ts index d7ae9c7..96127d6 100644 --- a/packages/core/src/storage-server.ts +++ b/packages/core/src/storage-server.ts @@ -200,11 +200,14 @@ export function createServerClient(opts: ServerClientOptions): StorageAdapter { async saveAISession(s: AISession): Promise { await req("POST", `/api/projects/${s.projectId}/ai/sessions`, { id: s.id, - role: s.role, - model: s.model, - settings: s.settings, + title: s.title, + kind: s.kind, messages: s.messages, suggestions: s.suggestions, + notes: s.notes, + createdAt: s.createdAt, + updatedAt: s.updatedAt, + lastMessageAt: s.lastMessageAt, }); }, diff --git a/packages/core/src/storage.contract.test.ts b/packages/core/src/storage.contract.test.ts index ce1ca60..e04f382 100644 --- a/packages/core/src/storage.contract.test.ts +++ b/packages/core/src/storage.contract.test.ts @@ -96,7 +96,8 @@ function mockServerFetch(): typeof fetch { } m = u.pathname.match(/^\/api\/projects\/([^/]+)\/ai\/sessions$/); 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); return json({ session: s }); } @@ -131,7 +132,8 @@ async function runScenario(adapter: StorageAdapter) { edges: [], }); 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(() => {}); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 15245f5..2ca61a1 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -211,13 +211,23 @@ export interface Note { linkedTo?: string; } -export type SuggestionKind = +/** Editor inline-review suggestion kinds. */ +export type EditorSuggestionKind = | "replace" | "insert" | "delete" | "entity-update" | "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 interface SuggestionTarget { @@ -234,20 +244,81 @@ export interface Suggestion { before: string; after: string; rationale: string; - kind: SuggestionKind; + kind: EditorSuggestionKind; 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 { id: string; projectId: string; - role: AISessionRole; - model: string; - settings: Record; - messages: Array<{ role: "system" | "user" | "assistant"; content: string }>; - suggestions: Suggestion[]; + title: string; + kind: SessionKind; + messages: AIMessage[]; + suggestions: AISuggestion[]; + notes: AINote[]; + createdAt: string; + updatedAt: string; + lastMessageAt: string | null; } /** A reference to a document that can be saved through the adapter. */ diff --git a/server/src/mapping.ts b/server/src/mapping.ts index a9be3ad..8f45e11 100644 --- a/server/src/mapping.ts +++ b/server/src/mapping.ts @@ -1,6 +1,5 @@ import type { AISession, - AISessionRole, AuditLogEntry, Board, Book, @@ -11,6 +10,7 @@ import type { PlotBeat, Project, ProjectMeta, + SessionKind, User, WorldItem, } from "@pubscript/core"; @@ -98,12 +98,14 @@ export interface PlotBeatRow { export interface AISessionRow { id: string; project_id: string; - role: string; - model: string; - settings: string; + title: string; + kind: string; messages: string; suggestions: string; + notes: string; + created_at: string; updated_at: string; + last_message_at: string | null; } // ---- Row -> core types ---- @@ -235,11 +237,14 @@ export function toAISession(r: AISessionRow): AISession { return { id: r.id, projectId: r.project_id, - role: r.role as AISessionRole, - model: r.model, - settings: JSON.parse(r.settings), + title: r.title, + kind: r.kind as SessionKind, messages: JSON.parse(r.messages), suggestions: JSON.parse(r.suggestions), + notes: JSON.parse(r.notes), + createdAt: r.created_at, + updatedAt: r.updated_at, + lastMessageAt: r.last_message_at, }; } diff --git a/server/src/migrations/003_ai_sessions.sql b/server/src/migrations/003_ai_sessions.sql new file mode 100644 index 0000000..b08ff1b --- /dev/null +++ b/server/src/migrations/003_ai_sessions.sql @@ -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); diff --git a/server/src/routes.ts b/server/src/routes.ts index c453965..222f2d8 100644 --- a/server/src/routes.ts +++ b/server/src/routes.ts @@ -92,11 +92,14 @@ const beatCreate = z.object({ }); const aiSessionCreate = z.object({ id: z.string().optional(), - role: z.string().min(1), - model: z.string().min(1), - settings: z.record(z.unknown()).default({}), - messages: z.array(z.any()).optional(), - suggestions: z.array(z.any()).optional(), + title: z.string().default(""), + kind: z.enum(["session", "agent"]).default("session"), + messages: z.array(z.any()).default([]), + suggestions: z.array(z.any()).default([]), + 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 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 t = now(); db.prepare( - `INSERT INTO ai_sessions (id, project_id, role, model, settings, messages, suggestions, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `INSERT INTO ai_sessions (id, project_id, title, kind, messages, suggestions, notes, created_at, updated_at, last_message_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET - project_id = excluded.project_id, role = excluded.role, model = excluded.model, - settings = excluded.settings, messages = excluded.messages, suggestions = excluded.suggestions, - updated_at = excluded.updated_at`, - ).run(sid, id, body.role, body.model, JSON.stringify(body.settings), - JSON.stringify(body.messages ?? []), JSON.stringify(body.suggestions ?? []), t); + title = excluded.title, kind = excluded.kind, + messages = excluded.messages, suggestions = excluded.suggestions, notes = excluded.notes, + updated_at = excluded.updated_at, last_message_at = excluded.last_message_at`, + ).run(sid, id, body.title, body.kind, JSON.stringify(body.messages), + 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; 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; if (!row) throw app.httpErrors.create(404, { error: "not_found" }); 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 settings = body.settings ? JSON.stringify(body.settings) : row.settings; - const model = body.model ?? row.model; + const body = z.object({ + title: z.string().optional(), + 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(); - 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; return { session: toAISession(updated) }; }); @@ -617,7 +635,7 @@ export function registerRoutes(app: FastifyInstance): void { const messages = JSON.parse(row.messages) as any[]; messages.push({ role, content, at: 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; return { session: toAISession(updated) }; });