Server: ui-stream.ts is verbatim pi AgentEvent passthrough (SSE data lines + [DONE]); only app-level events are the loop-level error and turn-settled terminal; onError carries raw provider text (owner ruling). Client: useChat + the AI-SDK transport replaced by components/session-core.ts (SessionModel: 409 identity-retry, optimistic c_ appends, absorbed() adoption, needsContinuation, shared consume for POST + reattach) and the session.ts hook; the golden-master grouping canon moved client-side into core/view.ts (projectView); scanners read the native record (phaseAt3Lh, danglingQuestionOf, graph toolParts, plan-blocks, runner, extraction). Coverage: the native message is the unit (fork 3) with the call+result inseparability bond rule; view-line-less messages (markers, error results) are invisible to coverage and never checkpointed. Deletes: chat-transport, projection, ui-message, their tests (ported to tests/session-core.test.ts), and the ai + @ai-sdk/react dependencies — the Vercel SDK is fully out of the tree. Wire pins in chat-flow rewritten onto the AgentEvent dialect (eventsOf parser). Gates: tsc clean, 994/994 tests, eslint clean, build green; live smoke on PORT=3004 (verbatim wire → native record).
131 lines
5.7 KiB
TypeScript
131 lines
5.7 KiB
TypeScript
/**
|
|
* The chat-turn services — one named, error-contained seam per turn
|
|
* responsibility, extracted from the chat-handler so it composes instead of
|
|
* implementing. Each takes its dependencies explicitly (storage ports, the
|
|
* job runner) — no module here imports an adapter.
|
|
*
|
|
* persistTurn — the canonical state write (Wave B: native history;
|
|
* id stamping, placeholder filtering, plan-block
|
|
* emission, abort-partial persistence)
|
|
* triggerExtraction — the graph write channel: fires the extraction
|
|
* pipeline (the sole graph writer) once a chat
|
|
* crosses the token-budget threshold.
|
|
*
|
|
* Logs are the minimal-observability baseline: one line per failure, tagged
|
|
* [chat <id>] — structured logging lands behind these seams later.
|
|
*/
|
|
|
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
|
import type { LhMessage } from "../core/native-messages.ts";
|
|
import { newMessageId } from "../core/native-messages.ts";
|
|
import { QUESTION_TOOLS } from "../core/pending.ts";
|
|
import { planBlocksFor } from "../core/plan-blocks.ts";
|
|
import { coverageOf } from "../core/coverage.ts";
|
|
import type { ChatStore, GraphStore } from "../core/ports.ts";
|
|
import type { ExtractionPipeline } from "../agent/subagents/extraction.ts";
|
|
import { ModelsNotConfiguredError, ProviderKeyMissingError } from "../core/settings.ts";
|
|
|
|
/**
|
|
* The canonical state write (Wave B, owner-approved 2026-09-26): append the
|
|
* turn's own messages — the agent loop's native history, provider/model/
|
|
* usage/thinkingSignature stamped by pi itself. Persists even when aborted
|
|
* — the partial transcript (including a pending question's unanswered
|
|
* call) is what makes resume work.
|
|
*
|
|
* - Id stamping: pi messages carry no id; the record's identity needs one
|
|
* per message (client keys, append-only adoption, the continuation
|
|
* seam). Minted here, at the single canonical write.
|
|
* - Placeholder filtering: a cleanly-posed question's synthetic
|
|
* "question posed" result is an in-memory loop device (Wave A's
|
|
* terminate) — NEVER persisted (fork 1: the record ends at the
|
|
* unanswered call; the learner's answer arrives as the real tool
|
|
* result). Errored question calls DO persist — the model's retry
|
|
* context.
|
|
* - Plan blocks: one frozen snapshot per successful plan_init call,
|
|
* idempotent (persist replays are safe).
|
|
*
|
|
* Returns the stamped new messages (usage provenance reads them).
|
|
*/
|
|
export function persistTurn(
|
|
chatStore: ChatStore,
|
|
chatId: string,
|
|
baseCount: number,
|
|
turnMessages: readonly AgentMessage[],
|
|
isAborted: boolean,
|
|
): LhMessage[] {
|
|
try {
|
|
const record = chatStore.getChat(chatId);
|
|
if (!record) return [];
|
|
const stamped: LhMessage[] = [];
|
|
for (const m of turnMessages.slice(baseCount)) {
|
|
const message = m as LhMessage;
|
|
// The placeholder question result: loop device, never a record fact.
|
|
if (
|
|
(message as { role?: string }).role === "toolResult" &&
|
|
(QUESTION_TOOLS as readonly string[]).includes((message as ToolResultMessage).toolName) &&
|
|
!(message as ToolResultMessage).isError
|
|
) {
|
|
continue;
|
|
}
|
|
stamped.push(message.id ? message : ({ ...message, id: newMessageId() } as LhMessage));
|
|
}
|
|
const tree = record.tree;
|
|
const blocks = planBlocksFor(record.messages, stamped, tree, [
|
|
...record.messages,
|
|
...stamped,
|
|
]);
|
|
chatStore.saveMessages(chatId, [...record.messages, ...stamped, ...blocks]);
|
|
return stamped;
|
|
} catch (err) {
|
|
console.error(`[chat ${chatId}] persist failed:`, err, { isAborted });
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The graph write channel (vision §1 construction): the extraction pipeline
|
|
* (extractor → auditor → merger — the sole graph writer) runs ASYNC once a
|
|
* chat's UNCOVERED transcript tokens reach the chunk budget
|
|
* (vision-extraction-coverage.md, 2026-09-15). Coverage reads through the
|
|
* projection (the record is native history; the extraction view stays
|
|
* client-shaped until Wave D). Fire-and-forget — never blocks the turn;
|
|
* failures are logged by the pipeline and leave coverage untouched, so the
|
|
* next trigger (or the manual extract button) re-runs what's left.
|
|
*
|
|
* Deliberately isolated in this one seam — the pipeline is
|
|
* trigger-agnostic. Skipped under tests: scripted chats are fixtures, not
|
|
* knowledge (the pipeline itself is tested directly with mock models).
|
|
*/
|
|
export function triggerExtraction(
|
|
deps: { chatStore: ChatStore; graphStore: GraphStore },
|
|
extraction: ExtractionPipeline,
|
|
chatId: string,
|
|
chunkTokens: number,
|
|
): void {
|
|
if (process.env.NODE_ENV === "test") return;
|
|
try {
|
|
const record = deps.chatStore.getChat(chatId);
|
|
if (!record) return;
|
|
const covered = coverageOf(record.messages, deps.graphStore.readCoverage(), chatId);
|
|
if (covered.uncoveredTokens < chunkTokens) return;
|
|
void extraction.runExtraction(chatId).catch((err) => {
|
|
// BYOK (owner, 2026-09-22) + key presence (provider-key interlude,
|
|
// 2026-09-25): an unconfigured OR keyless write channel SKIPS (the
|
|
// coverage ledger keeps the messages uncovered — the next trigger
|
|
// retries once slots/keys are set); only real failures log.
|
|
if (err instanceof ModelsNotConfiguredError) {
|
|
console.info(`[chat ${chatId}] extraction skipped — ${err.envelope.error} (missing: ${err.envelope.missing.join(", ")})`);
|
|
return;
|
|
}
|
|
if (err instanceof ProviderKeyMissingError) {
|
|
console.info(`[chat ${chatId}] extraction skipped — ${err.envelope.error} (provider: ${err.envelope.provider})`);
|
|
return;
|
|
}
|
|
console.error(`[chat ${chatId}] extraction pipeline failed:`, err);
|
|
});
|
|
} catch (err) {
|
|
console.error(`[chat ${chatId}] extraction trigger failed:`, err);
|
|
}
|
|
}
|