- needsContinuation: a persisted problem-pose ack (terminate:true ended the turn deliberately) owes no continuation — reloads after a pose no longer wake a phantom turn; error results stay owed like any interrupted tool. - Chat header: practice sessions swap the phase chip for a quiet Practice tag (no probe→teach arc applies) and hide the coverage ring (practice never extracts — an ever-filling ring would lie). - ChatSummary gains pendingProblem (activeProblemOf at projection); the sidebar meta says 'Problem waiting' for a practice row with a posed problem. Kind threads through Chat/[id] page. - Home picker chip gains Clear (picks were otherwise irremovable).
201 lines
8.2 KiB
TypeScript
201 lines
8.2 KiB
TypeScript
/**
|
|
* ChatStore — the fs JSON adapter (one file per chat in data/chats/).
|
|
*
|
|
* Implements core/ports.ts ChatStore; the record shapes live in
|
|
* core/contracts.ts (ChatRecord/ChatSummary). Idioms: sync fs, tiny
|
|
* records, id-guarded filenames, JSON with tab indentation. Wired in the
|
|
* composition root (src/server/stores.ts); nothing else constructs it.
|
|
*/
|
|
|
|
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import { PATHS } from "../../config.ts";
|
|
import { emit } from "../core/events.ts";
|
|
import type { ChatStore } from "../core/ports.ts";
|
|
import type { ChatRecord, ChatSettings, ChatSummary, ExtractionRunUsage, PlanTree, TurnUsage } from "../core/contracts.ts";
|
|
import type { LhMessage } from "../core/native-messages.ts";
|
|
import { activeProblemOf, danglingQuestionOf } from "../core/native-messages.ts";
|
|
import { phaseAt3Lh } from "../core/phases.ts";
|
|
|
|
function chatFile(id: string): string {
|
|
if (!/^[A-Za-z0-9_-]+$/.test(id)) throw new Error(`Invalid chat id: ${id}`);
|
|
return path.join(PATHS.chats, `${id}.json`);
|
|
}
|
|
|
|
/** THE SILENCE LAW (push S6, 2026-10-02): chats-changed announces ONLY
|
|
* list-SHAPE mutations — createChat, renameChat, deleteChat (below,
|
|
* each emits post-write). The content writers (saveMessages,
|
|
* appendTurnUsage, appendExtractionUsage, saveSettings, saveTree) are
|
|
* DELIBERATELY SILENT: saveMessages fires per turn-persist and would
|
|
* spam the bus, and every list-visible change it causes (title, order,
|
|
* phase, busy, pendingQuestion) is already announced by turn-started /
|
|
* turn-settled, which the sidebar refetches on. Silence here is law,
|
|
* not oversight. */
|
|
function createChat(title: string, kind?: "practice"): ChatRecord {
|
|
const now = new Date().toISOString();
|
|
const record: ChatRecord = {
|
|
id: `chat_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`,
|
|
title: title.trim() || "Untitled session",
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
messages: [],
|
|
// Practice sessions carry the marker; teach stays ABSENT (absence
|
|
// reads as teach — pre-practice records never migrate).
|
|
...(kind === "practice" ? { kind: "practice" as const } : {}),
|
|
};
|
|
fs.mkdirSync(PATHS.chats, { recursive: true });
|
|
fs.writeFileSync(chatFile(record.id), JSON.stringify(record, null, "\t"), "utf-8");
|
|
emit("chats-changed", {});
|
|
return record;
|
|
}
|
|
|
|
function getChat(id: string): ChatRecord | null {
|
|
try {
|
|
return JSON.parse(fs.readFileSync(chatFile(id), "utf-8")) as ChatRecord;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function listChats(): ChatSummary[] {
|
|
try {
|
|
fs.mkdirSync(PATHS.chats, { recursive: true });
|
|
return fs
|
|
.readdirSync(PATHS.chats)
|
|
.filter((f) => f.endsWith(".json"))
|
|
.map((f) => getChat(f.slice(0, -5)))
|
|
.filter((r): r is ChatRecord => r !== null)
|
|
.map(({ messages, tree, turns, extractions, ...summary }) => {
|
|
// Per-record isolation (Wave B): a record whose messages fail the
|
|
// projection (e.g. pre-Wave-B UIMessage-format scratch — the
|
|
// zero-back-compat wipe makes these "corrupt") must not blank the
|
|
// whole sidebar — same isolation getChat's per-file catch gives.
|
|
try {
|
|
return {
|
|
// turns and extractions stay out of the list payload (list =
|
|
// summaries; usage aggregates ride the full record, the picker
|
|
// never needs them).
|
|
...summary,
|
|
// Normalized kind: absent record field reads as teach.
|
|
kind: summary.kind ?? "teach",
|
|
messageCount: messages.length,
|
|
// Record-derived status for the session picker: phase from the
|
|
// transcript's explicit events (probe_done concluded / verdict
|
|
// accepted — docs/probe-teach-split.md §4), pending question
|
|
// from the native dangling-call scan. Wave D: both folds read
|
|
// the NATIVE record (core/phases.ts phaseAt3Lh +
|
|
// native-messages.ts danglingQuestionOf). Liveness (busy) is
|
|
// NOT record-derived — the API route merges it (ChatListItem).
|
|
phase: phaseAt3Lh(messages),
|
|
pendingQuestion: danglingQuestionOf(messages) !== null,
|
|
};
|
|
} catch (err) {
|
|
console.error(`[chat ${summary.id}] summary projection failed:`, err);
|
|
return null;
|
|
}
|
|
})
|
|
.filter((s): s is ChatSummary => s !== null)
|
|
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/** Persist the full native message list (the record IS the agent state —
|
|
* Wave B: replay is state.messages = record.messages). SILENT by the
|
|
* events law — see the SILENCE LAW comment at createChat. */
|
|
function saveMessages(id: string, messages: LhMessage[]): void {
|
|
const record = getChat(id);
|
|
if (!record) throw new Error(`Chat not found: ${id}`);
|
|
record.messages = messages;
|
|
record.updatedAt = new Date().toISOString();
|
|
fs.writeFileSync(chatFile(id), JSON.stringify(record, null, "\t"), "utf-8");
|
|
}
|
|
|
|
/** Append one usage entry to the record's turn log — idempotent per
|
|
* turn id: each REQUEST carries its own id, so a replay of the same turn's
|
|
* persist appends once, while continuation turns (which share a response
|
|
* message id) each keep their usage. */
|
|
function appendTurnUsage(id: string, entry: TurnUsage): void {
|
|
const record = getChat(id);
|
|
if (!record) throw new Error(`Chat not found: ${id}`);
|
|
record.turns = record.turns?.some((t) => t.id === entry.id)
|
|
? record.turns
|
|
: [...(record.turns ?? []), entry];
|
|
fs.writeFileSync(chatFile(id), JSON.stringify(record, null, "\t"), "utf-8");
|
|
}
|
|
|
|
/** Append one extraction-run usage entry to the record's extractions
|
|
* log — idempotent per startedAt: a retry that reaches the append twice
|
|
* (abort replays) does not double-count spend. */
|
|
function appendExtractionUsage(id: string, entry: ExtractionRunUsage): void {
|
|
const record = getChat(id);
|
|
if (!record) throw new Error(`Chat not found: ${id}`);
|
|
record.extractions = record.extractions?.some((r) => r.startedAt === entry.startedAt)
|
|
? record.extractions
|
|
: [...(record.extractions ?? []), entry];
|
|
fs.writeFileSync(chatFile(id), JSON.stringify(record, null, "\t"), "utf-8");
|
|
}
|
|
|
|
/** Persist the per-chat model/thinking settings — replace-whole (the
|
|
* picker sends the complete object; absent = config defaults). */
|
|
function saveSettings(id: string, settings: ChatSettings): void {
|
|
const record = getChat(id);
|
|
if (!record) throw new Error(`Chat not found: ${id}`);
|
|
// Like saveTree: a settings tweak is not conversation activity, so it
|
|
// deliberately does NOT bump updatedAt (sidebar order must not churn).
|
|
record.settings = settings;
|
|
fs.writeFileSync(chatFile(id), JSON.stringify(record, null, "\t"), "utf-8");
|
|
}
|
|
|
|
/** Rename a session (the header's click-to-edit title, 2026-09-17). Like
|
|
* saveSettings, a rename is NOT conversation activity: updatedAt stays put
|
|
* so sidebar order never churns. Same fallback as createChat — the empty
|
|
* string becomes "Untitled session" (the API gate rejects it first; this
|
|
* is the honest floor for any other caller). */
|
|
function renameChat(id: string, title: string): ChatRecord {
|
|
const record = getChat(id);
|
|
if (!record) throw new Error(`Chat not found: ${id}`);
|
|
record.title = title.trim() || "Untitled session";
|
|
fs.writeFileSync(chatFile(id), JSON.stringify(record, null, "\t"), "utf-8");
|
|
emit("chats-changed", {});
|
|
return record;
|
|
}
|
|
|
|
/** Delete the session record file entirely (session-deletion wave 6,
|
|
* owner 2026-09-22). False when the id is unknown — the route turns that
|
|
* into a 404. The record's graph traces and jobs are the route's
|
|
* choreography (core/graph-deletion.ts), not the store's. */
|
|
function deleteChat(id: string): boolean {
|
|
try {
|
|
fs.unlinkSync(chatFile(id));
|
|
emit("chats-changed", {}); // only a real removal — the present-entry law
|
|
return true;
|
|
} catch {
|
|
return false; // never existed, already deleted, or unreadable — same answer
|
|
}
|
|
}
|
|
|
|
/** Persist the session plan tree (plan tools call this in execute). */
|
|
function saveTree(id: string, tree: PlanTree): void {
|
|
const record = getChat(id);
|
|
if (!record) throw new Error(`Chat not found: ${id}`);
|
|
record.tree = tree;
|
|
// Deliberately does NOT bump updatedAt: tree mutations piggyback on the
|
|
// turn's saveMessages, and sidebar ordering shouldn't churn on tree edits.
|
|
fs.writeFileSync(chatFile(id), JSON.stringify(record, null, "\t"), "utf-8");
|
|
}
|
|
|
|
export const chatStore: ChatStore = {
|
|
createChat,
|
|
getChat,
|
|
listChats,
|
|
saveMessages,
|
|
saveTree,
|
|
saveSettings,
|
|
renameChat,
|
|
deleteChat,
|
|
appendTurnUsage,
|
|
appendExtractionUsage,
|
|
};
|