CHUNK #9: AI session storage layer (AgentController, AIStore, builders)

- Add packages/ai: AgentController (streaming + budget), SuggestionBuilder,
  NoteBuilder, AIStore (Dexie/IndexedDB), SuggestionMutation
- Fix createUlid to use monotonicFactory for reliable listSessions sort tiebreaker
- Extend outline system prompt with setting + characters context
- Add dexie dependency to packages/ai
- 188 tests passing (core 47, ui 5, editor 30, ai 71, exporters 22, web 13)
This commit is contained in:
Schwitzz
2026-10-29 07:31:25 -05:00
parent 4c4b83bb68
commit 1e16b0d5d2
15 changed files with 1859 additions and 3 deletions
+371
View File
@@ -0,0 +1,371 @@
/**
* AIStore — session, message, suggestion, and note persistence (CHUNK #9).
*
* The AIStore owns the in-memory cache of AI sessions and their messages,
* suggestions, and notes, and persists them through an injectable
* `AISessionPersistence` backend. The default backend is Dexie-backed
* IndexedDB (via `createIndexedDBPersistence`); tests and offline fallbacks use
* `createInMemoryPersistence()`.
*
* All mutating operations go through `withTransaction`, which applies the
* change to the in-memory cache and persists atomically.
*
* See CHUNK#9.md §2.
*/
import Dexie, { type Table } from "dexie";
import { createUlid, nowIso } from "@pubscript/core";
import type {
AISession,
AIMessage,
AISuggestion,
AINote,
SuggestionKind,
} from "@pubscript/core";
/** A persisted session row. */
interface SessionRow {
id: string;
projectId: string;
title: string;
kind: AISession["kind"];
createdAt: string;
updatedAt: string;
lastMessageAt: string | null;
}
/** The persistence contract the AIStore depends on. */
export interface AISessionPersistence {
list(projectId: string): Promise<SessionRow[]>;
get(id: string): Promise<SessionRow | null>;
save(row: SessionRow): Promise<void>;
delete(id: string): Promise<void>;
}
/** An in-memory persistence backend, for tests and offline fallbacks. */
export function createInMemoryPersistence(): AISessionPersistence {
const rows = new Map<string, SessionRow>();
return {
async list(projectId: string) {
return [...rows.values()]
.filter((r) => r.projectId === projectId)
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
},
async get(id: string) {
return rows.get(id) ?? null;
},
async save(row: SessionRow) {
rows.set(row.id, row);
},
async delete(id: string) {
rows.delete(id);
},
};
}
/** A Dexie-backed IndexedDB persistence backend. */
export function createIndexedDBPersistence(dbName = "pubscript"): AISessionPersistence {
const db = new Dexie(dbName);
if (!db.tables.some((t) => t.name === "aiSessions")) {
db.version(1).stores({ aiSessions: "&id, projectId, updatedAt" });
}
const table: Table<SessionRow, string> = db.table("aiSessions");
return {
async list(projectId: string) {
return table.where("projectId").equals(projectId).sortBy("updatedAt").then((rows) => rows.reverse());
},
async get(id: string) {
return table.get(id) ?? null;
},
async save(row: SessionRow) {
await table.put(row);
},
async delete(id: string) {
await table.delete(id);
},
};
}
/**
* The AIStore manages AI sessions and their messages, suggestions, and notes.
*/
export class AIStore {
private sessions = new Map<string, AISession>();
private loaded = false;
constructor(
private readonly projectId: string,
private readonly persistence: AISessionPersistence,
) {}
/** Load all sessions for the project into the in-memory cache. */
async load(): Promise<void> {
if (this.loaded) return;
const rows = await this.persistence.list(this.projectId);
for (const row of rows) {
this.sessions.set(row.id, {
id: row.id,
projectId: row.projectId,
title: row.title,
kind: row.kind,
messages: [],
suggestions: [],
notes: [],
createdAt: row.createdAt,
updatedAt: row.updatedAt,
lastMessageAt: row.lastMessageAt,
});
}
this.loaded = true;
}
/** Clear the in-memory cache and mark it as unloaded. */
async dispose(): Promise<void> {
this.sessions.clear();
this.loaded = false;
}
private getRow(session: AISession): SessionRow {
return {
id: session.id,
projectId: session.projectId,
title: session.title,
kind: session.kind,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
lastMessageAt: session.lastMessageAt,
};
}
/** Create a new session and persist it. */
async createSession(title?: string): Promise<AISession> {
await this.load();
const now = nowIso();
const session: AISession = {
id: createUlid(),
projectId: this.projectId,
title: title ?? `New AI session ${this.sessions.size + 1}`,
kind: "session",
messages: [],
suggestions: [],
notes: [],
createdAt: now,
updatedAt: now,
lastMessageAt: null,
};
this.sessions.set(session.id, session);
await this.persistence.save(this.getRow(session));
return this.clone(session);
}
/** Get a session by id. */
async getSession(id: string): Promise<AISession | null> {
await this.load();
const session = this.sessions.get(id);
return session ? this.clone(session) : null;
}
/** List all sessions for the project, newest updated first. */
async listSessions(): Promise<AISession[]> {
await this.load();
return [...this.sessions.values()]
.map((s) => this.clone(s))
.sort((a, b) => {
if (a.updatedAt !== b.updatedAt) {
return b.updatedAt.localeCompare(a.updatedAt);
}
// ULIDs are time-sortable, so a higher id means a later creation.
return b.id.localeCompare(a.id);
});
}
/** Delete a session and its messages/suggestions/notes. */
async deleteSession(id: string): Promise<void> {
await this.load();
this.sessions.delete(id);
await this.persistence.delete(id);
}
/** Add a message to a session and persist it. */
async addMessage(sessionId: string, message: Omit<AIMessage, "id" | "createdAt">): Promise<AISession> {
return this.withTransaction(async () => {
const session = this.require(sessionId);
const full: AIMessage = {
id: createUlid(),
createdAt: nowIso(),
...message,
};
session.messages.push(full);
session.lastMessageAt = full.createdAt;
session.updatedAt = full.createdAt;
return this.clone(session);
});
}
/** List a session's messages. */
async listMessages(sessionId: string): Promise<AIMessage[]> {
await this.load();
const session = this.require(sessionId);
return session.messages.map((m) => ({ ...m }));
}
/** Add a suggestion to a session and persist it. */
async addSuggestion(sessionId: string, suggestion: Omit<AISuggestion, "id" | "createdAt">): Promise<AISession> {
return this.withTransaction(async () => {
const session = this.require(sessionId);
const full: AISuggestion = { id: createUlid(), createdAt: nowIso(), ...suggestion };
session.suggestions.push(full);
session.updatedAt = nowIso();
return this.clone(session);
});
}
/** List a session's suggestions. */
async listSuggestions(sessionId: string): Promise<AISuggestion[]> {
await this.load();
const session = this.require(sessionId);
return session.suggestions.map((s) => ({ ...s }));
}
/** List a session's suggestions of a given kind. */
async listSuggestionsByKind(sessionId: string, kind: SuggestionKind): Promise<AISuggestion[]> {
const all = await this.listSuggestions(sessionId);
return all.filter((s) => s.kind === kind);
}
/**
* Apply a suggestion: mark it applied, and return the applied suggestion.
* The caller (editor integration) is responsible for mutating the document
* via `SuggestionMutation`; this updates the store's record of the applied
* suggestion.
*/
async applySuggestion(sessionId: string, suggestionId: string): Promise<AISession> {
return this.withTransaction(async () => {
const session = this.require(sessionId);
const suggestion = session.suggestions.find((s) => s.id === suggestionId);
if (!suggestion) throw new Error(`Suggestion ${suggestionId} not found`);
suggestion.status = "applied";
suggestion.appliedAt = nowIso();
session.updatedAt = nowIso();
return this.clone(session);
});
}
/**
* Resolve (accept-and-keep or discard) a pending suggestion: mark it
* resolved and record the resolution.
*/
async resolveSuggestion(sessionId: string, suggestionId: string, accepted: boolean): Promise<AISession> {
return this.withTransaction(async () => {
const session = this.require(sessionId);
const suggestion = session.suggestions.find((s) => s.id === suggestionId);
if (!suggestion) throw new Error(`Suggestion ${suggestionId} not found`);
suggestion.status = accepted ? "accepted" : "rejected";
suggestion.resolvedAt = nowIso();
suggestion.resolution = accepted ? "accepted" : "rejected";
session.updatedAt = nowIso();
return this.clone(session);
});
}
/** Add a note to a session and persist it. */
async addNote(sessionId: string, note: Omit<AINote, "id" | "createdAt" | "updatedAt">): Promise<AISession> {
return this.withTransaction(async () => {
const session = this.require(sessionId);
const ts = nowIso();
const full: AINote = { id: createUlid(), createdAt: ts, updatedAt: ts, ...note };
session.notes.push(full);
session.updatedAt = ts;
return this.clone(session);
});
}
/** List a session's notes. */
async listNotes(sessionId: string): Promise<AINote[]> {
await this.load();
const session = this.require(sessionId);
return session.notes.map((n) => ({ ...n }));
}
/** Update a note's fields and persist it. */
async updateNote(sessionId: string, noteId: string, patch: Partial<AINote>): Promise<AISession> {
return this.withTransaction(async () => {
const session = this.require(sessionId);
const note = session.notes.find((n) => n.id === noteId);
if (!note) throw new Error(`Note ${noteId} not found`);
Object.assign(note, patch, { updatedAt: nowIso() });
session.updatedAt = note.updatedAt;
return this.clone(session);
});
}
/**
* Merge several notes into one: concatenate their bodies, union their tags,
* and replace them with the merged note. Returns the session containing the
* merged note.
*/
async mergeNotes(sessionId: string, noteIds: string[]): Promise<AISession> {
return this.withTransaction(async () => {
const session = this.require(sessionId);
const toMerge = noteIds.map((id) => {
const note = session.notes.find((n) => n.id === id);
if (!note) throw new Error(`Note ${id} not found`);
return note;
});
const mergedBody = toMerge.map((n) => n.body).filter(Boolean).join("\n\n");
const mergedTags = [...new Set(toMerge.flatMap((n) => n.tags ?? []))];
const mergedTitle = toMerge[0]?.title ?? "Merged note";
const ts = nowIso();
// Remove the merged notes.
session.notes = session.notes.filter((n) => !noteIds.includes(n.id));
const merged: AINote = {
id: createUlid(),
sessionId,
kind: toMerge[0]?.kind ?? "character",
title: mergedTitle,
body: mergedBody,
tags: mergedTags,
status: "draft",
createdAt: toMerge[0]?.createdAt ?? ts,
updatedAt: ts,
};
session.notes.push(merged);
session.updatedAt = ts;
return this.clone(session);
});
}
/**
* Run a mutation within a transaction: apply the callback to the in-memory
* cache, then persist the session atomically.
*/
async withTransaction<T>(fn: () => Promise<T>): Promise<T> {
await this.load();
const result = await fn();
// Persist every session touched by the callback (best-effort atomicity).
for (const session of this.sessions.values()) {
await this.persistence.save(this.getRow(session));
}
return result;
}
private require(sessionId: string): AISession {
const session = this.sessions.get(sessionId);
if (!session) throw new Error(`Session ${sessionId} not found`);
return session;
}
/** Produce a deep copy of a session for external consumers. */
private clone(session: AISession): AISession {
return {
...session,
messages: session.messages.map((m) => ({ ...m })),
suggestions: session.suggestions.map((s) => ({ ...s })),
notes: session.notes.map((n) => ({ ...n })),
};
}
}