+ {/* Bottom-left stack (2026-09-17 merge): the graph-pipeline cost
+ pill + its expandable panel, and the running-pipeline pills —
+ one container so they stack instead of overlapping. The pill
+ shows cumulative pipeline spend (dollars headline, inferred
+ read-time — never stored); click toggles the breakdown. */}
+ {(costs?.runs.length ?? 0) > 0 || pipelineRuns.length > 0 ? (
+
+ {costsOpen && costs &&
}
+ {costs && costs.runs.length > 0 && (
+
setCostsOpen((open) => !open)}
+ className="flex items-center gap-2 rounded-full border bg-card/85 px-3 py-1.5 text-xs text-muted-foreground shadow-lg backdrop-blur transition-colors hover:bg-accent"
+ aria-expanded={costsOpen}
+ >
+
+ ${costs.totalDollars.toFixed(2)}
+
+ )}
{pipelineRuns.map((run) => (
))}
- )}
+ ) : null}
{selected && !isEmpty && (
void,
): Promise<{ key: string; label: string }[]> {
let submitted: { key: string; label: string }[] | undefined;
await runSubagent(
@@ -98,6 +100,7 @@ async function nameClusters(
),
maxTurns: 4,
abortSignal: signal,
+ onAssistantMessage: (message) => onStep?.(callUsage(message.usage), message.model),
shouldStop: (_message, toolResults) =>
toolResults.some((r) => r.toolName === "submit_labels" && !r.isError),
},
@@ -156,23 +159,49 @@ async function doRefresh(deps: {
if (unnamed.length === 0) return { detected: communities.length, claimed, named: 0, appended: 0 };
- const labels = await nameClusters(engine, signal, unnamed);
+ // Money channel (2026-09 graph cost metrics): the naming call is the
+ // pipeline's third billable stage — capture per-step usage + model as
+ // they bill, then persist ONE record for the run, success or failure
+ // (money spent either way, same discipline as extraction runs).
+ const startedAt = Date.now();
+ const t0 = performance.now();
+ const steps: NamingRunUsage["steps"] = [];
+ let model = "unknown";
let appended = 0;
- const seen = new Set();
- for (const { key, members } of unnamed) {
- if (seen.has(key)) continue;
- const label = labels.find((l) => l.key === key)?.label?.trim();
- if (!label) continue;
- seen.add(key);
- graphStore.appendClusterLabel({
- id: clusterContentId(members),
- label,
- core: members,
- createdAt: new Date().toISOString(),
+ try {
+ const labels = await nameClusters(engine, signal, unnamed, (usage, modelId) => {
+ steps.push(usage);
+ model = modelId;
+ });
+ const seen = new Set();
+ for (const { key, members } of unnamed) {
+ if (seen.has(key)) continue;
+ const label = labels.find((l) => l.key === key)?.label?.trim();
+ if (!label) continue;
+ seen.add(key);
+ graphStore.appendClusterLabel({
+ id: clusterContentId(members),
+ label,
+ core: members,
+ createdAt: new Date().toISOString(),
+ });
+ appended++;
+ }
+ return { detected: communities.length, claimed, named: unnamed.length, appended };
+ } finally {
+ // The finally (not just success) keeps failed/aborted runs billed
+ // too — zero appended, steps as far as they got. Runs whose model
+ // never billed a step (aborted before the first message) still
+ // record: the idempotency key is this run's startedAt.
+ graphStore.appendNamingUsage({
+ startedAt,
+ at: new Date().toISOString(),
+ durationMs: Math.round(performance.now() - t0),
+ model,
+ steps,
+ stats: { detected: communities.length, claimed, named: unnamed.length, appended },
});
- appended++;
}
- return { detected: communities.length, claimed, named: unnamed.length, appended };
}
let namingTail: Promise = Promise.resolve();
diff --git a/src/agent/subagents/extraction.ts b/src/agent/subagents/extraction.ts
index 5427c18..5588c46 100644
--- a/src/agent/subagents/extraction.ts
+++ b/src/agent/subagents/extraction.ts
@@ -588,6 +588,22 @@ export interface ExtractionPipeline {
importNotes(input: NotesImportInput): Promise;
}
+/** pi's per-step usage, flattened to the persisted CallUsage fields
+ * (the money channel's capture shape, contracts/chat.ts). Exported for
+ * the cluster namer — the same capture shape (2026-09 graph cost
+ * metrics wave). Module scope: no closure deps, pure projection. */
+export function callUsage(usage: Usage): CallUsage {
+ return {
+ inputTokens: usage.input,
+ outputTokens: usage.output,
+ cacheReadTokens: usage.cacheRead,
+ cacheWriteTokens: usage.cacheWrite,
+ // Optional in pi's Usage (providers that never emit reasoning
+ // tokens leave it unset) — persisted as 0, never dropped.
+ reasoningTokens: usage.reasoning ?? 0,
+ };
+}
+
export function createExtractionPipeline(deps: ExtractionPipelineDeps): ExtractionPipeline {
const { chatStore, graphStore, embedder: embed } = deps;
// THE SHARED REGISTRIES (runs() above — globalThis): every pipeline
@@ -619,19 +635,6 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
run.usage[stage]!.steps.push(usage);
}
- /** pi's per-step usage, flattened to the persisted CallUsage fields
- * (the money channel's capture shape, contracts/chat.ts). */
- function callUsage(usage: Usage): CallUsage {
- return {
- inputTokens: usage.input,
- outputTokens: usage.output,
- cacheReadTokens: usage.cacheRead,
- cacheWriteTokens: usage.cacheWrite,
- // Optional in pi's Usage (providers that never emit reasoning
- // tokens leave it unset) — persisted as 0, never dropped.
- reasoningTokens: usage.reasoning ?? 0,
- };
- }
async function extract(chatId: string, view: string): Promise {
const signal = runningExtractions.get(chatId)?.controller.signal;
diff --git a/src/core/contracts/graph.ts b/src/core/contracts/graph.ts
index 1ca8a75..f57642e 100644
--- a/src/core/contracts/graph.ts
+++ b/src/core/contracts/graph.ts
@@ -20,7 +20,7 @@
/** Two relations only (DECIDED 2026-09-02): follows = b is derivable from a;
* supports = a grounds b without entailing it. No scalars, no encoded
* strength — strength is derived at read time from topology. */
-import type { ExtractionRunUsage } from "./chat.ts";
+import type { CallUsage, ExtractionRunUsage } from "./chat.ts";
export type EdgeType = "follows" | "supports";
@@ -65,6 +65,41 @@ export interface NotesSource {
extractions?: ExtractionRunUsage[];
}
+/** What one cluster-naming refresh did — the namer's ClusterRefreshStats,
+ * contract-shaped so the agent layer needs no contracts import.
+ * Structural twins stay interchangeable. */
+export interface NamingRunStats {
+ /** Communities the current graph divides into (all sizes). */
+ detected: number;
+ /** Communities that kept a label via core-survival. */
+ claimed: number;
+ /** Communities sent to the namer (unnamed, size >= MIN_CLUSTER_SIZE). */
+ named: number;
+ /** Labeling records actually appended (idempotent dedupe may shrink). */
+ appended: number;
+}
+
+/** One cluster-naming run's usage record — the money channel's THIRD stage
+ * (graph cost metrics, 2026-09). The namer is graph-global (tied to no
+ * chat or source), so runs live in their own append-only ledger
+ * (config.LEDGER_FILES.namingRuns). Appended when a run actually called
+ * the model; skipped refreshes (nothing to name) never bill and never
+ * append. Idempotent per startedAt — same discipline as every usage
+ * channel. Cost is INFERRED at read time from steps + model
+ * (core/prices.ts) — dollars are never stored. */
+export interface NamingRunUsage {
+ /** ms epoch — the idempotency key. */
+ startedAt: number;
+ /** ISO — wall-clock time of the run. */
+ at: string;
+ durationMs: number;
+ /** The labeler role's model id. */
+ model: string;
+ /** Every billed call (usually ONE — the namer is single-submit). */
+ steps: CallUsage[];
+ stats: NamingRunStats;
+}
+
export interface GraphNode {
/** Opaque stable id, store-assigned: "n_" + nanoid-like. */
id: string;
diff --git a/src/core/cost-summary.ts b/src/core/cost-summary.ts
new file mode 100644
index 0000000..2c29ff5
--- /dev/null
+++ b/src/core/cost-summary.ts
@@ -0,0 +1,185 @@
+/**
+ * Graph-pipeline cost summary (2026-09 graph cost metrics wave) — the pure
+ * fold behind the graph view's cost pill. Three usage channels in: chat
+ * extraction runs (ChatRecord.extractions), notes-source runs
+ * (NotesSource.extractions), and cluster-naming runs (naming-runs.jsonl).
+ * Out: per-stage totals (extraction / audit / cluster-naming) + a capped
+ * recent-runs list.
+ *
+ * MONEY CHANNEL LAW: dollars are INFERRED here at read time via
+ * core/prices.ts — never stored anywhere. An unpriced model contributes
+ * ZERO dollars but is counted in `unpricedRuns` so the UI can render
+ * "— unpriced" honestly, never a silent zero. Tokens always sum.
+ *
+ * Client-safe: pure types + math, no node imports; exposed via the core
+ * barrel so the pill/panel components carry no cost math.
+ */
+
+import type { CallUsage, EmissionStats, ExtractionRunUsage, NamingRunUsage } from "./contracts.ts";
+import { callCost, sumSteps } from "./prices.ts";
+
+/** One pipeline stage's cumulative cost across every run. */
+export interface StageCost {
+ /** Inferred USD summed over PRICED runs (partial when unpricedRuns > 0). */
+ dollars: number;
+ /** Stage-runs whose model is missing from PRICES (render "—", never zero). */
+ unpricedRuns: number;
+ /** Runs that billed this stage. */
+ runs: number;
+ /** Token totals across every run. */
+ tokens: CallUsage;
+}
+
+/** One run in the recent-runs list (extraction or naming). */
+export interface RecentRun {
+ startedAt: number;
+ /** ISO wall-clock. */
+ at: string;
+ durationMs: number;
+ /** "extraction" covers both front doors (chat + notes). */
+ kind: "extraction" | "naming";
+ /** Chat title / source file path / fixed label for naming runs. */
+ title: string;
+ /** Whole-run inferred USD; null only when EVERY stage is unpriced. */
+ dollars: number | null;
+ tokens: CallUsage;
+ /** What the run merged into the graph; absent on failed runs. */
+ merged?: EmissionStats;
+ /** Naming runs: labeling records appended (extraction runs omit it). */
+ labels?: number;
+}
+
+/** The cost pill's whole payload: stage totals + recent runs. */
+export interface GraphCostSummary {
+ /** Inferred USD across all priced stages of all runs. */
+ totalDollars: number;
+ /** Runs where EVERY stage was unpriced (totalDollars excludes them). */
+ unpricedRuns: number;
+ /** The three billable pipeline stages (merger/embedder are free). */
+ stages: {
+ extraction: StageCost;
+ audit: StageCost;
+ naming: StageCost;
+ };
+ /** Newest first, capped at `recentCap` (default 10). */
+ runs: RecentRun[];
+}
+
+/** What the fold reads: already-collected run records, front-door-tagged. */
+export interface CostSummaryInput {
+ /** Chat-front-door runs: chat title + the record's usage log. */
+ chats: { title: string; extractions: ExtractionRunUsage[] }[];
+ /** Notes-front-door runs: imported file path + the source's usage log. */
+ sources: { filePath: string; extractions: ExtractionRunUsage[] }[];
+ /** Cluster-naming runs (graph-global ledger). */
+ naming: NamingRunUsage[];
+ recentCap?: number;
+}
+
+const ZERO_TOKENS: CallUsage = {
+ inputTokens: undefined,
+ outputTokens: undefined,
+ cacheReadTokens: undefined,
+ cacheWriteTokens: undefined,
+ reasoningTokens: undefined,
+};
+
+const emptyStage = (): StageCost => ({ dollars: 0, unpricedRuns: 0, runs: 0, tokens: { ...ZERO_TOKENS } });
+
+/** One stage's dollars over its steps; null when the model is unpriced
+ * (all-or-nothing: a stage runs one model). */
+function stageDollars(model: string, steps: readonly CallUsage[]): number | null {
+ let total = 0;
+ for (const step of steps) {
+ const c = callCost(step, model);
+ if (c === null) return null;
+ total += c;
+ }
+ return total;
+}
+
+/** Merge one step-set's tokens into an accumulating CallUsage. */
+function mergeTokens(into: CallUsage, steps: readonly CallUsage[]): void {
+ const summed = sumSteps(steps);
+ into.inputTokens = (into.inputTokens ?? 0) + (summed.inputTokens ?? 0);
+ into.outputTokens = (into.outputTokens ?? 0) + (summed.outputTokens ?? 0);
+ into.cacheReadTokens = (into.cacheReadTokens ?? 0) + (summed.cacheReadTokens ?? 0);
+ into.cacheWriteTokens = (into.cacheWriteTokens ?? 0) + (summed.cacheWriteTokens ?? 0);
+ into.reasoningTokens = (into.reasoningTokens ?? 0) + (summed.reasoningTokens ?? 0);
+}
+
+/** Fold every usage channel into the pill's payload. Pure: same input,
+ * same output — no clock, no fs, no store reads. */
+export function graphCostSummary(input: CostSummaryInput): GraphCostSummary {
+ const extraction = emptyStage();
+ const audit = emptyStage();
+ const naming = emptyStage();
+ const runs: RecentRun[] = [];
+
+ const foldExtractionRun = (run: ExtractionRunUsage, title: string) => {
+ const stages = run.stages ?? { extractor: { model: "unknown", steps: [] }, auditor: { model: "unknown", steps: [] } };
+ // Per-stage: tokens always count; dollars only when priced.
+ extraction.runs++;
+ mergeTokens(extraction.tokens, stages.extractor.steps);
+ const extDollars = stageDollars(stages.extractor.model, stages.extractor.steps);
+ if (extDollars === null) extraction.unpricedRuns++;
+ else extraction.dollars += extDollars;
+
+ audit.runs++;
+ mergeTokens(audit.tokens, stages.auditor.steps);
+ const audDollars = stageDollars(stages.auditor.model, stages.auditor.steps);
+ if (audDollars === null) audit.unpricedRuns++;
+ else audit.dollars += audDollars;
+
+ // Run-level: null only when BOTH stages are unpriced.
+ const priced = extDollars ?? 0;
+ const runDollars = extDollars === null && audDollars === null ? null : priced + (audDollars ?? 0);
+ const allSteps = [...stages.extractor.steps, ...stages.auditor.steps];
+ runs.push({
+ startedAt: run.startedAt,
+ at: run.at,
+ durationMs: run.durationMs,
+ kind: "extraction",
+ title,
+ dollars: runDollars,
+ tokens: sumSteps(allSteps),
+ ...(run.stats ? { merged: run.stats } : {}),
+ });
+ };
+
+ for (const chat of input.chats) {
+ for (const run of chat.extractions) foldExtractionRun(run, chat.title);
+ }
+ for (const source of input.sources) {
+ for (const run of source.extractions) foldExtractionRun(run, source.filePath);
+ }
+ for (const run of input.naming) {
+ naming.runs++;
+ mergeTokens(naming.tokens, run.steps);
+ const dollars = stageDollars(run.model, run.steps);
+ if (dollars === null) naming.unpricedRuns++;
+ else naming.dollars += dollars;
+ runs.push({
+ startedAt: run.startedAt,
+ at: run.at,
+ durationMs: run.durationMs,
+ kind: "naming",
+ title: "Cluster naming",
+ dollars,
+ tokens: sumSteps(run.steps),
+ labels: run.stats.appended,
+ });
+ }
+
+ runs.sort((a, b) => b.startedAt - a.startedAt);
+ const capped = runs.slice(0, input.recentCap ?? 10);
+
+ // A run is "unpriced" when it billed nothing countable at all.
+ const unpricedRuns = runs.filter((r) => r.dollars === null).length;
+ return {
+ totalDollars: extraction.dollars + audit.dollars + naming.dollars,
+ unpricedRuns,
+ stages: { extraction, audit, naming },
+ runs: capped,
+ };
+}
diff --git a/src/core/index.ts b/src/core/index.ts
index 6796a9e..a288244 100644
--- a/src/core/index.ts
+++ b/src/core/index.ts
@@ -34,6 +34,7 @@ export * from "./math-delims.ts";
export * from "./ui-message.ts";
export * from "./tool-parts.ts";
export * from "./prices.ts";
+export * from "./cost-summary.ts";
export * from "./fuzzy.ts";
// settings.ts + settings-resolution.ts (settings-storage wave, 2026-09-19):
// the user-settings record's shape/sanitizer (pure) and its fully-resolved
diff --git a/src/core/ports.ts b/src/core/ports.ts
index 079d643..1784803 100644
--- a/src/core/ports.ts
+++ b/src/core/ports.ts
@@ -28,6 +28,7 @@ import type {
TurnUsage,
NotesSource,
ClusterLabeling,
+ NamingRunUsage,
EmbeddingsLedger,
} from "./contracts.ts";
import type { UserSettings } from "./settings.ts";
@@ -101,6 +102,11 @@ export interface GraphStore {
appendClusterLabel(labeling: ClusterLabeling): void;
readClusterLabels(): ClusterLabeling[];
+ /** Append one cluster-naming run's usage (graph-global money channel) —
+ * idempotent per startedAt. */
+ appendNamingUsage(entry: NamingRunUsage): void;
+ readNamingRuns(): NamingRunUsage[];
+
/** Upsert one imported-notes source record (config.SOURCES_FILE — a
* JSON map, not a ledger; an upsert keeps the existing usage log). */
upsertSource(source: NotesSource): void;
diff --git a/src/server/graph.ts b/src/server/graph.ts
index d175488..79e3d29 100644
--- a/src/server/graph.ts
+++ b/src/server/graph.ts
@@ -1,4 +1,6 @@
import type { GraphSnapshot } from "../core/contracts.ts";
+import type { GraphCostSummary } from "../core/cost-summary.ts";
+import { graphCostSummary } from "../core/cost-summary.ts";
import { graphSnapshot } from "../core/graph.ts";
import { stores } from "./stores.ts";
@@ -11,3 +13,25 @@ import { stores } from "./stores.ts";
export function graphView(): GraphSnapshot {
return graphSnapshot(stores);
}
+
+/** The graph view's cost surface (GET /api/graph/costs — the 2026-09 cost
+ * pill): fold every usage channel into stage totals + recent runs. A pure
+ * read over the chat records (chat-front-door runs), the notes sources
+ * (notes-front-door runs), and the naming ledger (graph-global runs);
+ * dollars are inferred by the pure core fold (core/cost-summary.ts +
+ * prices.ts) — nothing here stores or caches money. */
+export function graphCosts(): GraphCostSummary {
+ const chats = stores.chatStore
+ .listChats()
+ .flatMap((summary) => {
+ const record = stores.chatStore.getChat(summary.id);
+ return record?.extractions?.length
+ ? [{ title: record.title ?? summary.id, extractions: record.extractions }]
+ : [];
+ });
+ const sources = stores.graphStore
+ .readSources()
+ .filter((s) => s.extractions?.length)
+ .map((s) => ({ filePath: s.filePath, extractions: s.extractions! }));
+ return graphCostSummary({ chats, sources, naming: stores.graphStore.readNamingRuns() });
+}
diff --git a/src/store/graph.ts b/src/store/graph.ts
index b18e6de..c5da267 100644
--- a/src/store/graph.ts
+++ b/src/store/graph.ts
@@ -44,6 +44,7 @@ import type {
ProvenancePoint,
CoverageEntry,
SessionCheckpoint,
+ NamingRunUsage,
} from "../core/contracts.ts";
import type { GraphStore } from "../core/ports.ts";
@@ -90,6 +91,19 @@ function readValid(file: string, isValid: (v: unknown) => v is T): T[] {
const isProvenancePoint = (v: unknown): v is ProvenancePoint =>
isRecord(v) && isString(v.chatId) && isString(v.messageId);
+const isNamingStats = (v: unknown): v is NamingRunUsage["stats"] =>
+ isRecord(v) &&
+ ["detected", "claimed", "named", "appended"].every((k) => typeof v[k] === "number");
+
+const isNamingRun = (v: unknown): v is NamingRunUsage =>
+ isRecord(v) &&
+ typeof v.startedAt === "number" &&
+ isString(v.at) &&
+ typeof v.durationMs === "number" &&
+ isString(v.model) &&
+ Array.isArray(v.steps) &&
+ isNamingStats(v.stats);
+
const isClusterLabeling = (v: unknown): v is ClusterLabeling =>
isRecord(v) &&
isString(v.id) &&
@@ -198,6 +212,18 @@ export const graphStore: GraphStore = {
return readValid(LEDGER_FILES.clusterLabels, isClusterLabeling);
},
+ // ── cluster-naming run usage (config.LEDGER_FILES.namingRuns) ──
+
+ /** Idempotent per startedAt — the same discipline as every usage
+ * channel (chats.appendExtractionUsage, appendSourceUsage). */
+ appendNamingUsage(entry: NamingRunUsage): void {
+ if (this.readNamingRuns().some((r) => r.startedAt === entry.startedAt)) return;
+ appendLine(LEDGER_FILES.namingRuns, entry);
+ },
+ readNamingRuns(): NamingRunUsage[] {
+ return readValid(LEDGER_FILES.namingRuns, isNamingRun);
+ },
+
// ── imported-notes bookkeeping (config.SOURCES_FILE — a JSON map) ──
/** Upsert: keeps the existing record's usage log when re-importing. */
diff --git a/tests/cost-summary.test.ts b/tests/cost-summary.test.ts
new file mode 100644
index 0000000..411a276
--- /dev/null
+++ b/tests/cost-summary.test.ts
@@ -0,0 +1,126 @@
+/**
+ * Tests for src/core/cost-summary.ts — the graph cost pill's pure fold.
+ * Same laws as the money channel everywhere: dollars INFERRED at read
+ * time (core/prices.ts), unpriced models excluded-but-counted (render
+ * "—", never a silent zero), tokens always sum.
+ */
+
+import assert from "node:assert/strict";
+import { describe, it as test } from "node:test";
+import {
+ graphCostSummary,
+ type CostSummaryInput,
+ type GraphCostSummary,
+} from "../src/core/cost-summary.ts";
+import type { CallUsage, ExtractionRunUsage, NamingRunUsage } from "../src/core/contracts.ts";
+import { PRICES, callCost } from "../src/core/prices.ts";
+
+/** A model with full published pricing (from the generated registry). */
+const PRICED = "z-ai/glm-5.3-flash";
+assert.ok(PRICES[PRICED], "fixture model must exist in PRICES");
+
+const step = (input: number, output: number): CallUsage => ({
+ inputTokens: input,
+ outputTokens: output,
+ cacheReadTokens: 0,
+ cacheWriteTokens: undefined,
+ reasoningTokens: 0,
+});
+
+const extractionRun = (
+ startedAt: number,
+ extractorModel: string,
+ auditorModel: string,
+ stats?: ExtractionRunUsage["stats"],
+): ExtractionRunUsage => ({
+ startedAt,
+ at: new Date(startedAt).toISOString(),
+ durationMs: 10_000,
+ stages: {
+ extractor: { model: extractorModel, steps: [step(1000, 200)] },
+ auditor: { model: auditorModel, steps: [step(2000, 100)] },
+ },
+ ...(stats ? { stats } : {}),
+});
+
+const namingRun = (startedAt: number, model: string, appended: number): NamingRunUsage => ({
+ startedAt,
+ at: new Date(startedAt).toISOString(),
+ durationMs: 4_000,
+ model,
+ steps: [step(500, 50)],
+ stats: { detected: 8, claimed: 6, named: 2, appended },
+});
+
+describe("graphCostSummary (the cost pill's fold)", () => {
+ test("folds all three channels: stage totals, tokens, newest-first runs", () => {
+ const input: CostSummaryInput = {
+ chats: [{ title: "Testing session", extractions: [extractionRun(100, PRICED, PRICED)] }],
+ sources: [{ filePath: "/vault/modal.md", extractions: [extractionRun(200, PRICED, PRICED)] }],
+ naming: [namingRun(300, PRICED, 2)],
+ };
+ const s = graphCostSummary(input);
+ const extStep = callCost(step(1000, 200), PRICED)!;
+ const audStep = callCost(step(2000, 100), PRICED)!;
+ assert.equal(s.stages.extraction.dollars, extStep * 2);
+ assert.equal(s.stages.audit.dollars, audStep * 2);
+ assert.equal(s.stages.naming.dollars, callCost(step(500, 50), PRICED));
+ assert.equal(s.stages.extraction.runs, 2);
+ assert.equal(s.stages.naming.runs, 1);
+ assert.equal(s.stages.extraction.tokens.inputTokens, 2000);
+ assert.equal(s.stages.audit.tokens.outputTokens, 200);
+ assert.equal(s.totalDollars, extStep * 2 + audStep * 2 + callCost(step(500, 50), PRICED)!);
+ // All three runs, newest first, titles joined per front door.
+ assert.deepEqual(
+ s.runs.map((r) => r.title),
+ ["Cluster naming", "/vault/modal.md", "Testing session"],
+ );
+ assert.equal(s.runs[0]!.labels, 2);
+ assert.equal(s.runs[0]!.kind, "naming");
+ assert.equal(s.unpricedRuns, 0);
+ });
+
+ test("unpriced models: excluded dollars, counted honestly, run null only when BOTH stages unpriced", () => {
+ const s: GraphCostSummary = graphCostSummary({
+ chats: [{ title: "partial", extractions: [extractionRun(100, PRICED, "unknown-model")] }],
+ sources: [{ filePath: "/vault/x.md", extractions: [extractionRun(200, "unknown-model", "unknown-model")] }],
+ naming: [],
+ });
+ assert.equal(s.stages.audit.unpricedRuns, 2);
+ assert.equal(s.stages.audit.dollars, 0); // never a silent zero: unpricedRuns says it
+ assert.equal(s.stages.extraction.unpricedRuns, 1);
+ // The partial run still prices its extractor side; the fully
+ // unpriced run renders "—". Newest first: 200 (both unpriced), 100.
+ assert.equal(s.runs[0]!.dollars, null);
+ assert.notEqual(s.runs[1]!.dollars, null);
+ assert.equal(s.unpricedRuns, 1); // run-level: only the both-stages run
+ });
+
+ test("recent runs cap: newest survive", () => {
+ const extractions = [1, 2, 3, 4].map((n) => extractionRun(n * 100, PRICED, PRICED));
+ const s = graphCostSummary({
+ chats: [{ title: "chat", extractions }],
+ sources: [],
+ naming: [],
+ recentCap: 2,
+ });
+ assert.equal(s.runs.length, 2);
+ assert.deepEqual(s.runs.map((r) => r.startedAt), [400, 300]);
+ // stage totals still count EVERY run, cap is presentational.
+ assert.equal(s.stages.extraction.runs, 4);
+ });
+
+ test("failed runs carry no merged stats; empty input folds to zero", () => {
+ const s = graphCostSummary({
+ chats: [{ title: "failed", extractions: [extractionRun(100, PRICED, PRICED)] }],
+ sources: [],
+ naming: [],
+ });
+ assert.equal(s.runs[0]!.merged, undefined);
+ const empty = graphCostSummary({ chats: [], sources: [], naming: [] });
+ assert.equal(empty.totalDollars, 0);
+ assert.equal(empty.runs.length, 0);
+ assert.equal(empty.stages.naming.runs, 0);
+ assert.equal(empty.stages.extraction.tokens.inputTokens, undefined);
+ });
+});
diff --git a/tests/extraction.test.ts b/tests/extraction.test.ts
index b032778..df2919a 100644
--- a/tests/extraction.test.ts
+++ b/tests/extraction.test.ts
@@ -33,6 +33,7 @@ import type {
GraphEdge,
GraphNode,
LedgerEvent,
+ NamingRunUsage,
NodeMapping,
NotesSource,
SessionCheckpoint,
@@ -70,6 +71,7 @@ function memoryLedgers() {
const coverage: CoverageEntry[] = [];
const sources = new Map();
const clusterLabels: ClusterLabeling[] = [];
+ const namingRuns: NamingRunUsage[] = [];
const positions = new Map();
const embeddings = {
model: "",
@@ -111,6 +113,11 @@ function memoryLedgers() {
if (!clusterLabels.some((x) => x.id === l.id)) clusterLabels.push(l);
},
readClusterLabels: () => clusterLabels.map((l) => ({ ...l, core: [...l.core] })),
+ // Cluster-naming run usage — mirrors the real store: dedupe per startedAt.
+ appendNamingUsage: (entry: NamingRunUsage) => {
+ if (!namingRuns.some((r) => r.startedAt === entry.startedAt)) namingRuns.push(entry);
+ },
+ readNamingRuns: () => namingRuns.map((r) => ({ ...r, steps: [...r.steps], stats: { ...r.stats } })),
// The layout ledger — settleGraphLayout writes it after each merge.
readPositions: () => Object.fromEntries([...positions].map(([id, p]) => [id, { ...p }])),
writePositions: (next) => {
@@ -799,6 +806,18 @@ describe("cluster naming (the post-extraction garnish)", () => {
assert.equal(again.appended, 0);
assert.ok(again.claimed >= 1);
assert.equal(ledgers.readClusterLabels().length, 1, "no duplicate records");
+ // Money channel (2026-09 cost metrics): the naming call billed —
+ // ONE run record with the faux model, deterministic > 0 usage, and
+ // the refresh stats. The second refresh named nothing (no model
+ // call — early return), so no second record.
+ const namingRuns = ledgers.readNamingRuns();
+ assert.equal(namingRuns.length, 1, "one naming run billed");
+ assert.equal(namingRuns[0]!.model, labeler.model.id);
+ assert.ok(namingRuns[0]!.steps.length >= 1, "at least one billed step");
+ assert.ok((namingRuns[0]!.steps[0]!.inputTokens ?? 0) > 0, "usage estimated > 0");
+ assert.equal(namingRuns[0]!.stats.appended, 1);
+ assert.equal(namingRuns[0]!.stats.named, 1);
+ assert.ok(namingRuns[0]!.durationMs >= 0);
});
it("naming failure never fails the run", async () => {
diff --git a/tests/graph.test.ts b/tests/graph.test.ts
index 7f7f908..c75073a 100644
--- a/tests/graph.test.ts
+++ b/tests/graph.test.ts
@@ -18,7 +18,7 @@ import assert from "node:assert/strict";
import * as fs from "node:fs";
import { after, describe, it } from "node:test";
import type { UIMessage } from "ai";
-import type { AuditResult } from "../src/core/contracts.ts";
+import type { AuditResult, NamingRunUsage } from "../src/core/contracts.ts";
import type { QuestionAnswerData } from "../src/core/question-core.ts";
import { clusterContentId, getGraph, graphSnapshot, mergeFindings, type GraphDeps } from "../src/core/graph.ts";
import { graphStore } from "../src/store/graph.ts";
@@ -592,6 +592,29 @@ describe("graph ledger store", () => {
assert.deepEqual(nodes[0]!.provenance, [{ chatId: "chat_c", messageId: "m1" }]);
});
+ it("appendNamingUsage is idempotent per startedAt and strict-reads foreign shapes", () => {
+ const run: NamingRunUsage = {
+ startedAt: 1789800000000,
+ at: "2026-09-17T00:00:00.000Z",
+ durationMs: 1234,
+ model: "z-ai/glm-5.3-flash",
+ steps: [{ inputTokens: 10, outputTokens: 2, cacheReadTokens: 0, cacheWriteTokens: undefined, reasoningTokens: 0 }],
+ stats: { detected: 4, claimed: 3, named: 1, appended: 1 },
+ };
+ graphStore.appendNamingUsage(run);
+ graphStore.appendNamingUsage(run); // re-append: same startedAt — no-op
+ // A torn append + a foreign shape (missing stats) — skipped by the guard.
+ fs.appendFileSync(
+ LEDGER_FILES.namingRuns,
+ `{not json}\n${JSON.stringify({ startedAt: 1789800000001, at: "x", durationMs: 1, model: "m", steps: [] })}\n`,
+ "utf-8",
+ );
+ const runs = graphStore.readNamingRuns().filter((r) => r.startedAt >= 1789800000000);
+ assert.equal(runs.length, 1, "one record — idempotent append, corrupt/foreign lines skipped");
+ assert.deepEqual(runs[0]!.stats, { detected: 4, claimed: 3, named: 1, appended: 1 });
+ assert.equal(runs[0]!.steps.length, 1);
+ });
+
it("returns RAW node lines — repeated ids (link-appends) are each their own line; getGraph merges", () => {
const line = (msgId: string) =>
JSON.stringify({