Extraction is real spend and background work; the dock needs to see and stop it. User decisions: persist all cost data (chat record), per-stage usage granularity, and TRUE cancellation for abort. USAGE CAPTURE (live + persisted): - extraction.ts: per-call onStepFinish captures each billed step's usage (SDK LanguageModelUsage -> CallUsage: input/output/cache read+write/reasoning) into the registry's live per-stage usage (extractor/auditor; the merger is deterministic — no model call). - /api/graph/status runs now carry usage (model + steps per stage); cost/context folds are the consumer's (core/prices.ts — nothing stores dollars). - ChatRecord.extractions: append-only per-run usage log (mirrors the turns log): startedAt key, at, durationMs, merge stats when the merger ran, stages. Persisted on success AND failure — a failed run still spent money. Idempotent per startedAt; the list payload (ChatSummary) never leaks it. - EmissionStats moved to contracts/graph.ts (its contract home); core/graph.ts re-exports for existing importers. - Verified end-to-end against the real pipeline (backfill one chat): per-step token counts, reasoning tokens, and the auditor's growing prompt-cache reads captured and persisted. TRUE ABORT (the dock's abort button, user decision): - RunningExtraction owns one AbortController per run, shared across phases; abortExtraction(chatId) fires it and returns whether a run was aborted (idempotent no-op otherwise). The pending model call receives the signal — the provider cancels the request, spend stops. throwIfAborted() at phase boundaries ends runs aborted in sync gaps. - Abort is a deliberate stop, not a failure: same ledger discipline (nothing merged, nothing checkpointed, partial usage persisted) but the run reports zero emissions instead of throwing. Exported via src/server/observability.ts for master's /api/activity/abort route. - Tests: mid-run abort (hanging mock that rejects on the signal), live-accumulation + persisted-record assertions, store-seam idempotency, and the summary-payload leak guard.
183 lines
8.0 KiB
TypeScript
183 lines
8.0 KiB
TypeScript
/**
|
|
* Chat-session contracts — records, summaries, UI extension points
|
|
*
|
|
* Part of the domain-split contracts (src/core/contracts/): each domain file
|
|
* is owned outright by its stream — cross-domain schema changes go through
|
|
* master. Pure types + protocol docs + tiny pure helpers; type-only imports
|
|
* of "ai" (safe everywhere: server, client, tests). See docs/vision.md.
|
|
*/
|
|
import type { UIMessage } from "ai";
|
|
import type { Phase3 } from "../phases.ts";
|
|
import type { PlanNodeStatus, PlanTree } from "./plan.ts";
|
|
import type { EmissionStats } from "./graph.ts";
|
|
|
|
// ─── Chat records (the session store's on-disk format) ───────────────────────
|
|
|
|
/** One persisted session: data/chats/<id>.json. The stored messages are full
|
|
* AI SDK UIMessages (parts included) — the canonical session record: a
|
|
* pending quiz is a tool part in `input-available` state, which is exactly
|
|
* what makes resume-mid-question free. */
|
|
export interface ChatRecord {
|
|
id: string;
|
|
title: string;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
messages: UIMessage[];
|
|
/** The session's plan tree (vision §2) — written by plan tools via saveTree. */
|
|
tree?: PlanTree;
|
|
/** Append-only usage log (observability, 2026-09): one entry per model
|
|
* turn. DELIBERATELY a parallel log, not per-message metadata — the client
|
|
* re-POSTs its message list every turn and its copies never carry
|
|
* server-stamped fields, so anything inside message objects gets clobbered
|
|
* by the next persist. Keyed by messageId so the transcript view can fold
|
|
* each entry onto its message; aggregates are folds over this log
|
|
* (core/prices.ts). */
|
|
turns?: TurnUsage[];
|
|
/** Append-only background-extraction usage log (2026-09 observability):
|
|
* one entry per finished pipeline run on THIS chat — extraction is real
|
|
* spend too. Same parallel-log discipline as `turns` (nothing rides
|
|
* inside messages). Keyed by startedAt; idempotent appends. */
|
|
extractions?: ExtractionRunUsage[];
|
|
}
|
|
|
|
// ─── Background-extraction usage (the money channel, extraction side) ────────
|
|
|
|
/** One pipeline stage's model usage — the stage's model + every step it
|
|
* billed (CallUsage per agent step). Steps, not a sum: cost and context
|
|
* folds happen at read time (core/prices.ts). */
|
|
export interface ExtractionStageUsage {
|
|
/** The OpenRouter model id that ran this stage. */
|
|
model: string;
|
|
steps: CallUsage[];
|
|
}
|
|
|
|
/** One finished (or failed mid-flight) extraction run's usage record.
|
|
* Appended to the chat's `extractions` log when the run ends — success or
|
|
* failure, because money was spent either way; `stats` is present only
|
|
* when the merger actually ran. */
|
|
export interface ExtractionRunUsage {
|
|
/** The run's registry startedAt (ms) — the idempotency key. */
|
|
startedAt: number;
|
|
at: string;
|
|
durationMs: number;
|
|
/** What the run merged into the graph; absent when it failed first. */
|
|
stats?: EmissionStats;
|
|
/** Model usage per stage, in pipeline order (merger is deterministic —
|
|
* no model call, no entry). */
|
|
stages: {
|
|
extractor: ExtractionStageUsage;
|
|
auditor: ExtractionStageUsage;
|
|
};
|
|
}
|
|
|
|
// ─── Usage accounting (the money channel) ─────────────────────────────────────
|
|
|
|
/** One model call's token accounting — the SDK's LanguageModelUsage
|
|
* flattened to the fields we persist (undefined = provider didn't report;
|
|
* renderers show absent, never zero). */
|
|
export interface CallUsage {
|
|
/** Total input (prompt) tokens, cached portion included. */
|
|
inputTokens: number | undefined;
|
|
outputTokens: number | undefined;
|
|
/** Cached input tokens READ (billed at the cache-read rate, if any). */
|
|
cacheReadTokens: number | undefined;
|
|
/** Input tokens written to the cache this call (OpenRouter bills writes
|
|
* for some providers; undefined = not reported/not billed). */
|
|
cacheWriteTokens: number | undefined;
|
|
/** The reasoning portion of the output tokens. */
|
|
reasoningTokens: number | undefined;
|
|
}
|
|
|
|
/** One billing turn: one full invocation of the teacher (a client turn or a
|
|
* wake turn — same handler, same log). A turn with tool calls spans several
|
|
* agent steps, and each step bills separately. Capture lands at the persist
|
|
* seam (server); cost is INFERRED at read time from core/prices.ts. */
|
|
export interface TurnUsage {
|
|
/** The assistant message the turn produced — keys this entry to the
|
|
* transcript (the block this turn "belongs" to). */
|
|
messageId: string;
|
|
at: string;
|
|
/** The OpenRouter model id that ran the turn. */
|
|
model: string;
|
|
/** One entry per agent step (model call) in this turn. */
|
|
steps: CallUsage[];
|
|
}
|
|
|
|
// ─── Plan blocks (the plan lives in the chat) ────────────────────────────────
|
|
|
|
/** Payload of the persisted `data-plan-block` UI part (2026-09 design): a
|
|
* SNAPSHOT of the plan at the moment it changed, rendered as a block in the
|
|
* chat flow. Attached to the assistant message that carries the plan_init
|
|
* tool call (kind "full") or the answered check quiz part (kind
|
|
* "neighborhood"). Data parts never reach the model (convertToModelMessages
|
|
* drops them) — the block is pure UI history, reappearing identically on
|
|
* reload because it lives in the record.
|
|
*
|
|
* Rendering contract: a "full" block renders the whole DAG borderless,
|
|
* horizontally filling the chat column, natural height; a "neighborhood"
|
|
* block renders current node + direct parents + direct children in its own
|
|
* small container, the current node glowing — clearly a fragment, not the
|
|
* whole plan. Clicking either opens the full plan in a popup. */
|
|
export interface PlanBlockData {
|
|
kind: "full" | "neighborhood";
|
|
at: string;
|
|
/** The current node at snapshot time (the glow target; null = none). */
|
|
current: string | null;
|
|
/** Included nodes: all of them (full) or the one-level-up/one-level-down
|
|
* neighborhood of `current` (neighborhood). Statuses are the snapshot's,
|
|
* derived at emission time — the block never re-derives at render. */
|
|
nodes: Array<{ id: string; title: string; status: PlanNodeStatus }>;
|
|
edges: Array<{ from: string; to: string }>;
|
|
}
|
|
|
|
/** The list view's shape (messageCount instead of full messages) + the
|
|
* per-session status the picker renders. `phase` and `pendingQuestion`
|
|
* derive from the stored record (tree + messages); the store computes
|
|
* them in listChats. Liveness (`busy`) is NOT record-derived — the API
|
|
* route merges it in (see ChatListItem).
|
|
*
|
|
* Phase semantics (three phases, data-only): probe = no plan yet, plan =
|
|
* tree exists but no node occupied, teach = a node is occupied
|
|
* (core/phases.ts phaseOfTree).
|
|
*/
|
|
export interface ChatSummary {
|
|
id: string;
|
|
title: string;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
messageCount: number;
|
|
/** The session's current phase, derived from the stored plan tree. */
|
|
phase: Phase3;
|
|
/** An unanswered quiz/ask question is waiting for the learner. */
|
|
pendingQuestion: boolean;
|
|
}
|
|
|
|
/** The sidebar's session row: a summary + live liveness. The chats list
|
|
* route attaches `busy` from the turn-liveness registry and the job store
|
|
* (a running turn OR a background job = the session is working). The store
|
|
* itself can't see liveness (import law: store never imports server),
|
|
* hence the split at exactly the record/liveness seam. */
|
|
export interface ChatListItem extends ChatSummary {
|
|
busy: boolean;
|
|
}
|
|
|
|
// ─── Artifact renderer registry (client extension point) ────────────────────
|
|
|
|
/**
|
|
* components/chat.tsx renders any tool part it doesn't specially handle via
|
|
* components/artifacts/index.ts's registry, keyed by tool name ("research",
|
|
* "demo", "visual", …). Workers add renderers there — chat.tsx is off-limits
|
|
* to artifact work. Renderer receives the raw tool part; it typically renders
|
|
* an activity card while state is input-available (poll /api/jobs) and the
|
|
* artifact (iframe / svg) when output-available.
|
|
*/
|
|
export interface ArtifactPartProps {
|
|
part: {
|
|
type: string;
|
|
state: string;
|
|
toolCallId: string;
|
|
input?: unknown;
|
|
output?: unknown;
|
|
};
|
|
}
|