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:
@@ -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 })),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user