Merge branch 'feat/edge-glyph'
This commit is contained in:
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/graph/costs — the graph view's cost pill (2026-09 graph cost
|
||||||
|
* metrics wave): what the graph PIPELINE cost, folded read-time from the
|
||||||
|
* three usage channels (chat extraction runs, notes-source runs,
|
||||||
|
* cluster-naming runs). Thin transport over src/server's graphCosts();
|
||||||
|
* the fold itself is pure core (core/cost-summary.ts). Dollars are
|
||||||
|
* inferred from PRICES at read time — never stored, so a price sync
|
||||||
|
* re-prices the whole history for free.
|
||||||
|
*
|
||||||
|
* Scope (user-decided): the graph pipeline ONLY — teaching turns and
|
||||||
|
* sub-agent jobs have their own cost channels elsewhere.
|
||||||
|
*/
|
||||||
|
import { graphCosts } from "@/src/server";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return Response.json(graphCosts());
|
||||||
|
}
|
||||||
@@ -102,7 +102,7 @@ export function GraphCoverageControl({ refreshKey = 0 }: { refreshKey?: number }
|
|||||||
title="Extraction coverage — what the knowledge graph has covered"
|
title="Extraction coverage — what the knowledge graph has covered"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
onClick={() => setOpen((o) => !o)}
|
onClick={() => setOpen((o) => !o)}
|
||||||
className={`flex size-11 items-center justify-center rounded-xl border bg-card/85 shadow-lg backdrop-blur transition-colors hover:bg-accent ${
|
className={`flex size-9 items-center justify-center rounded-xl border bg-card/85 shadow-lg backdrop-blur transition-colors hover:bg-accent ${
|
||||||
open ? "text-foreground" : "text-muted-foreground hover:text-foreground"
|
open ? "text-foreground" : "text-muted-foreground hover:text-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
+210
-37
@@ -61,10 +61,10 @@
|
|||||||
* camera never moves.
|
* camera never moves.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { FileTextIcon, Loader2Icon, SearchIcon, SettingsIcon, UploadIcon, WaypointsIcon } from "lucide-react";
|
import { FileTextIcon, Loader2Icon, SearchIcon, SettingsIcon, UploadIcon, WaypointsIcon } from "lucide-react";
|
||||||
import { incrementalLayout, type GraphNode, type GraphSnapshot } from "@/src/core";
|
import { incrementalLayout, type GraphCostSummary, type GraphNode, type GraphSnapshot } from "@/src/core";
|
||||||
import { GraphCanvasRenderer, type GraphRenderSettings } from "./graph-canvas.ts";
|
import { GraphCanvasRenderer, type GraphRenderSettings } from "./graph-canvas.ts";
|
||||||
import { GraphCoverageControl } from "./graph-coverage.tsx";
|
import { GraphCoverageControl } from "./graph-coverage.tsx";
|
||||||
import { usePopoverDismiss } from "@/components/popover-dismiss";
|
import { usePopoverDismiss } from "@/components/popover-dismiss";
|
||||||
@@ -209,6 +209,131 @@ function SettingSlider({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Compact token display: 45231 → "45.2k"; absent → "—". */
|
||||||
|
function fmtTokens(n: number | undefined): string {
|
||||||
|
if (n === undefined) return "—";
|
||||||
|
if (n < 1000) return String(Math.round(n));
|
||||||
|
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
|
||||||
|
return `${(n / 1_000_000).toFixed(2)}M`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Relative time for run rows: "now", "12m", "3h", "2d". */
|
||||||
|
function timeAgo(startedAt: number): string {
|
||||||
|
const mins = Math.round((Date.now() - startedAt) / 60_000);
|
||||||
|
if (mins < 1) return "now";
|
||||||
|
if (mins < 60) return `${mins}m`;
|
||||||
|
const hours = Math.round(mins / 60);
|
||||||
|
if (hours < 24) return `${hours}h`;
|
||||||
|
return `${Math.round(hours / 24)}d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run/stage cost at 3 decimals (pipeline pennies are real money at this
|
||||||
|
* scale); headline totals use 2. Null (unpriced model) renders "—". */
|
||||||
|
function fmtRunCost(dollars: number | null): string {
|
||||||
|
return dollars === null ? "—" : `$${dollars.toFixed(3)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The graph-pipeline cost control (2026-09-20 placement wave): a bare
|
||||||
|
* cumulative-dollars number — no pill chrome (owner: "just a number"),
|
||||||
|
* hover-highlighted, click toggles the breakdown panel. Same dismiss
|
||||||
|
* pattern as the settings gear: outside click or Escape closes. The
|
||||||
|
* panel is viewport-capped + scrollable like the coverage dropdown.
|
||||||
|
*/
|
||||||
|
function GraphCostControl({ costs }: { costs: GraphCostSummary }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
// Click anywhere outside (or Escape) closes the panel — the root
|
||||||
|
// wraps button + panel, so the toggle itself keeps working.
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
usePopoverDismiss(rootRef, open, () => setOpen(false));
|
||||||
|
return (
|
||||||
|
<div ref={rootRef} className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Graph pipeline costs — click for the breakdown"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
className={`rounded-lg px-2 py-1 text-xs tabular-nums transition-colors hover:bg-accent ${
|
||||||
|
open ? "text-foreground" : "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
${costs.totalDollars.toFixed(2)}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute right-0 top-full mt-2 max-h-[60vh] overflow-y-auto">
|
||||||
|
<GraphCostPanel costs={costs} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cost control's expanded panel: stage totals (extraction / audit /
|
||||||
|
* cluster-naming — dollars headline, tokens secondary) + the recent-runs
|
||||||
|
* list. Presentational: the fold happens server-side
|
||||||
|
* (core/cost-summary.ts); dollars are inferred read-time, never stored.
|
||||||
|
*/
|
||||||
|
function GraphCostPanel({ costs }: { costs: GraphCostSummary }) {
|
||||||
|
const stageRows: [string, GraphCostSummary["stages"]["extraction"]][] = [
|
||||||
|
["Extraction", costs.stages.extraction],
|
||||||
|
["Audit", costs.stages.audit],
|
||||||
|
["Cluster naming", costs.stages.naming],
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className="w-80 max-w-[calc(100vw-1.5rem)] rounded-xl border bg-card/95 p-3 text-xs shadow-lg backdrop-blur">
|
||||||
|
<div className="mb-2 flex items-baseline justify-between">
|
||||||
|
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
|
Graph pipeline costs
|
||||||
|
</span>
|
||||||
|
<span className="text-foreground tabular-nums">
|
||||||
|
${costs.totalDollars.toFixed(2)}
|
||||||
|
{costs.unpricedRuns > 0 && (
|
||||||
|
<span className="text-muted-foreground"> · {costs.unpricedRuns} unpriced</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{stageRows.map(([label, stage]) => (
|
||||||
|
<div key={label} className="flex items-baseline justify-between gap-2 text-[11px]">
|
||||||
|
<span className="shrink-0 text-muted-foreground">{label}</span>
|
||||||
|
<span className="min-w-0 text-right tabular-nums">
|
||||||
|
{fmtRunCost(stage.runs === 0 || (stage.unpricedRuns > 0 && stage.dollars === 0) ? null : stage.dollars)}
|
||||||
|
{stage.unpricedRuns > 0 && stage.dollars > 0 && (
|
||||||
|
<span className="text-muted-foreground"> +{stage.unpricedRuns} unpriced</span>
|
||||||
|
)}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{" "}
|
||||||
|
· {fmtTokens(stage.tokens.inputTokens)} in / {fmtTokens(stage.tokens.outputTokens)} out
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-col gap-0.5 border-t pt-2">
|
||||||
|
{costs.runs.map((run) => (
|
||||||
|
<div key={run.startedAt} className="flex items-baseline justify-between gap-2 text-[11px]">
|
||||||
|
<span className="min-w-0 flex-1 truncate text-muted-foreground">
|
||||||
|
<span className="text-foreground/70">{timeAgo(run.startedAt)}</span>{" "}
|
||||||
|
{run.title}
|
||||||
|
{run.merged && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{" "}
|
||||||
|
+{run.merged.nodes}n
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{run.kind === "naming" && (
|
||||||
|
<span className="text-muted-foreground"> {run.labels ?? 0} labels</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 tabular-nums">{fmtRunCost(run.dollars)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The rendering-settings gear + its collapsible panel (owner, graph-view
|
* The rendering-settings gear + its collapsible panel (owner, graph-view
|
||||||
* round 2): its own container at the top right, separate from the search
|
* round 2): its own container at the top right, separate from the search
|
||||||
@@ -236,7 +361,7 @@ function GraphSettingsControl({
|
|||||||
title="Rendering settings"
|
title="Rendering settings"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
onClick={() => setOpen((o) => !o)}
|
onClick={() => setOpen((o) => !o)}
|
||||||
className={`flex size-11 items-center justify-center rounded-xl border bg-card/85 shadow-lg backdrop-blur transition-colors hover:bg-accent ${
|
className={`flex size-9 items-center justify-center rounded-xl border bg-card/85 shadow-lg backdrop-blur transition-colors hover:bg-accent ${
|
||||||
open ? "text-foreground" : "text-muted-foreground hover:text-foreground"
|
open ? "text-foreground" : "text-muted-foreground hover:text-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -248,8 +373,14 @@ function GraphSettingsControl({
|
|||||||
// floor of free space above the window bottom at any height,
|
// floor of free space above the window bottom at any height,
|
||||||
// and the overflow-y-auto column (which also zeroes the flex
|
// and the overflow-y-auto column (which also zeroes the flex
|
||||||
// item's min-height) scrolls instead of stretching the page.
|
// item's min-height) scrolls instead of stretching the page.
|
||||||
<div className="absolute right-0 top-full mt-2 flex max-h-[60vh] w-64 flex-col rounded-xl border bg-card/85 p-2.5 shadow-lg backdrop-blur">
|
// w-72 + inner pr (owner, 2026-09-20): the scrollbar used to
|
||||||
<div className="flex flex-col gap-2 overflow-y-auto">
|
// press against the content — the panel widens so the scroll
|
||||||
|
// gutter gets real air. Spacing concentric: content -> 10px
|
||||||
|
// (pr-2.5) -> scrollbar -> 10px (p-2.5) -> panel edge. The
|
||||||
|
// scrollbar itself does NOT move: it stays pinned at the same
|
||||||
|
// distance from the panel's right border.
|
||||||
|
<div className="absolute right-0 top-full mt-2 flex max-h-[60vh] w-72 flex-col rounded-xl border bg-card/85 p-2.5 shadow-lg backdrop-blur">
|
||||||
|
<div className="flex flex-col gap-2 overflow-y-auto pr-2.5">
|
||||||
{/* Size/zoom/region sliders: LADDER-mapped 0-100, step 10 (11
|
{/* Size/zoom/region sliders: LADDER-mapped 0-100, step 10 (11
|
||||||
stops, every DEFAULT dead-center at 50, every value a nice
|
stops, every DEFAULT dead-center at 50, every value a nice
|
||||||
number — src/core/render-settings.ts). Physics keeps the
|
number — src/core/render-settings.ts). Physics keeps the
|
||||||
@@ -751,6 +882,28 @@ export function GraphView() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Graph pipeline costs (2026-09 cost metrics wave): the cumulative fold
|
||||||
|
// of every usage channel (chat + notes extraction runs, cluster naming)
|
||||||
|
// behind the bottom-left cost pill. Fetched once on mount and refetched
|
||||||
|
// when a pipeline run finishes (running -> none) so fresh spend lands
|
||||||
|
// without a manual reload. Best-effort: a fetch failure never blocks
|
||||||
|
// the view — the pill just stays on its last numbers.
|
||||||
|
const [costs, setCosts] = useState<GraphCostSummary | null>(null);
|
||||||
|
const refreshCosts = useCallback(() => {
|
||||||
|
fetch("/api/graph/costs")
|
||||||
|
.then((res) => res.json() as Promise<GraphCostSummary>)
|
||||||
|
.then(setCosts)
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
refreshCosts();
|
||||||
|
}, [refreshCosts]);
|
||||||
|
const costsHadRunning = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (costsHadRunning.current && pipelineRuns.length === 0) refreshCosts();
|
||||||
|
costsHadRunning.current = pipelineRuns.length > 0;
|
||||||
|
}, [pipelineRuns, refreshCosts]);
|
||||||
|
|
||||||
// Layout (Option A): the positions ledger (written by the server's
|
// Layout (Option A): the positions ledger (written by the server's
|
||||||
// incremental settle after every merge) IS the layout — the renderer
|
// incremental settle after every merge) IS the layout — the renderer
|
||||||
// receives it as frozen data. The local one-shot forceAtlas2 pass runs
|
// receives it as frozen data. The local one-shot forceAtlas2 pass runs
|
||||||
@@ -987,23 +1140,40 @@ export function GraphView() {
|
|||||||
coverage, import, gear — coverage sits between search and
|
coverage, import, gear — coverage sits between search and
|
||||||
import; the gear keeps the corner. items-start (not center)
|
import; the gear keeps the corner. items-start (not center)
|
||||||
so the search container's no-match note can grow downward
|
so the search container's no-match note can grow downward
|
||||||
without shifting its siblings — every button is 44px tall,
|
without shifting its siblings — every control is 36px tall
|
||||||
top alignment pins the row. Coverage renders even when the
|
(header band, 2026-09-20), top alignment pins the row.
|
||||||
graph is EMPTY — an empty graph with unextracted sessions is
|
Coverage renders even when the graph is EMPTY — an empty
|
||||||
exactly when it matters (extract → the graph gains nodes).
|
graph with unextracted sessions is exactly when it matters
|
||||||
|
(extract → the graph gains nodes).
|
||||||
lh-no-drag (2026-09-17): home.tsx's floating drag strip
|
lh-no-drag (2026-09-17): home.tsx's floating drag strip
|
||||||
underlays this row — Electron's drag mask ignores z-index,
|
underlays this row — Electron's drag mask ignores z-index,
|
||||||
so without an explicit carve-out the controls were
|
so without an explicit carve-out the controls were
|
||||||
unclickable in the desktop app (owner report). max-w + flex-wrap
|
unclickable in the desktop app (owner report). The old
|
||||||
(narrow-window wave, 2026-09-17): below the sidebar auto-close
|
flex-wrap (narrow-window wave, 2026-09-17) folded the row
|
||||||
threshold the row wraps to a second line instead of overflowing
|
onto two lines below the sidebar threshold — owner rejected
|
||||||
left over the header toggle cluster (92px gutter + 36px button);
|
it (2026-09-20): one row always, the search shrinks (see
|
||||||
9rem keeps one clear column of air alongside it. */}
|
the header-band comment on the column below). */}
|
||||||
<div className="lh-no-drag absolute right-3 top-3 z-10 flex max-w-[calc(100%-9rem)] flex-wrap items-start gap-2">
|
{/* Header band (owner, 2026-09-20): the controls sit IN the
|
||||||
|
header's visual row — top-2 aligns with the sidebar toggle's
|
||||||
|
band (header py-2, toggle h-9), every control slimmed to 36px.
|
||||||
|
The width cap lives HERE on the absolute element (100% resolves
|
||||||
|
against the full-bleed parent) — the row NEVER wraps (owner
|
||||||
|
rejected the two-row fold); in narrow windows the search is
|
||||||
|
the flexible element and shrinks instead. */}
|
||||||
|
<div className="lh-no-drag absolute right-2 top-2 z-10 flex max-w-[calc(100%-5.5rem)] flex-col items-end gap-2">
|
||||||
|
{/* z-20 (2026-09-20): the row paints above the cost number below
|
||||||
|
it — without this, the later-in-DOM cost control layered over
|
||||||
|
the settings/coverage dropdowns (owner report), still hoverable
|
||||||
|
and clickable on top of them. The row is a stacking context, so
|
||||||
|
its open dropdowns cover the number; clicks land on the panel. */}
|
||||||
|
<div className="relative z-20 flex min-w-0 items-start gap-2">
|
||||||
{!isEmpty && (
|
{!isEmpty && (
|
||||||
<>
|
<>
|
||||||
{/* Search — its own container (icon + input + no-match note). */}
|
{/* Search — its own container (icon + input + no-match note).
|
||||||
<div className="w-56 rounded-xl border bg-card/85 p-1.5 shadow-lg backdrop-blur">
|
The header band's flexible element: w-56 normally, shrinks
|
||||||
|
(min-w-0) when the column's width cap binds in narrow
|
||||||
|
windows — the row's other controls are shrink-0. */}
|
||||||
|
<div className="w-56 min-w-0 shrink rounded-xl border bg-card/85 px-1 py-[3px] shadow-lg backdrop-blur">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<SearchIcon className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
<SearchIcon className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||||
<input
|
<input
|
||||||
@@ -1012,7 +1182,7 @@ export function GraphView() {
|
|||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="Search nodes…"
|
placeholder="Search nodes…"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
className="h-8 w-full rounded-lg border bg-background pl-8 text-xs outline-none placeholder:text-muted-foreground focus:border-ring"
|
className="h-7 w-full rounded-lg border bg-background pl-8 text-xs outline-none placeholder:text-muted-foreground focus:border-ring"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{snapshot !== null && visibleIds.size === 0 && query.trim() !== "" && (
|
{snapshot !== null && visibleIds.size === 0 && query.trim() !== "" && (
|
||||||
@@ -1028,11 +1198,11 @@ export function GraphView() {
|
|||||||
with unextracted sessions is exactly when it matters (extract
|
with unextracted sessions is exactly when it matters (extract
|
||||||
→ the graph gains nodes). The panel re-reads when background
|
→ the graph gains nodes). The panel re-reads when background
|
||||||
runs settle (refreshTick). */}
|
runs settle (refreshTick). */}
|
||||||
<GraphCoverageControl refreshKey={refreshTick} />
|
<div className="shrink-0"><GraphCoverageControl refreshKey={refreshTick} /></div>
|
||||||
{!isEmpty && (
|
{!isEmpty && (
|
||||||
<>
|
<>
|
||||||
{/* Import notes — its own container. */}
|
{/* Import notes — its own container (36px, header band). */}
|
||||||
<div className="rounded-xl border bg-card/85 p-1.5 shadow-lg backdrop-blur">
|
<div className="shrink-0 rounded-xl border bg-card/85 px-1 py-[3px] shadow-lg backdrop-blur">
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
@@ -1048,7 +1218,7 @@ export function GraphView() {
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={importing}
|
disabled={importing}
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
className="flex h-8 items-center gap-1.5 rounded-lg px-3 text-xs text-muted-foreground transition-colors hover:bg-accent disabled:opacity-50"
|
className="flex h-7 items-center gap-1.5 rounded-lg px-2.5 text-xs text-muted-foreground transition-colors hover:bg-accent disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<UploadIcon className="size-3" />
|
<UploadIcon className="size-3" />
|
||||||
{importing ? "Importing…" : "Import notes…"}
|
{importing ? "Importing…" : "Import notes…"}
|
||||||
@@ -1062,22 +1232,25 @@ export function GraphView() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Background pipeline status: what is currently running. */}
|
{/* Top-right tail (owner, 2026-09-20): the pipeline cost number —
|
||||||
{pipelineRuns.length > 0 && (
|
just a number, hover-highlighted, dropdown breakdown — then
|
||||||
<div className="absolute bottom-3 left-3 z-10 flex flex-col gap-1.5">
|
the running-pipeline pills under it. Lives BELOW the actions
|
||||||
{pipelineRuns.map((run) => (
|
row so narrow windows can never push it over the composer
|
||||||
<div
|
the way the old bottom-left stack did; the whole top-right
|
||||||
key={run.chatId}
|
cluster is one column, right-aligned. */}
|
||||||
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"
|
{costs && costs.runs.length > 0 && <GraphCostControl costs={costs} />}
|
||||||
>
|
{pipelineRuns.map((run) => (
|
||||||
<Loader2Icon className="size-3.5 shrink-0 animate-spin" />
|
<div
|
||||||
<span className="capitalize">{run.phase}</span>
|
key={run.chatId}
|
||||||
<span className="max-w-48 truncate text-foreground">{run.title}</span>
|
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"
|
||||||
<span className="tabular-nums">{Math.max(0, Math.round((Date.now() - run.startedAt) / 1000))}s</span>
|
>
|
||||||
</div>
|
<Loader2Icon className="size-3.5 shrink-0 animate-spin" />
|
||||||
))}
|
<span className="capitalize">{run.phase}</span>
|
||||||
|
<span className="max-w-48 truncate text-foreground">{run.title}</span>
|
||||||
|
<span className="tabular-nums">{Math.max(0, Math.round((Date.now() - run.startedAt) / 1000))}s</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{selected && !isEmpty && (
|
{selected && !isEmpty && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -115,6 +115,9 @@ export const LEDGER_FILES = {
|
|||||||
coverage: path.join(PATHS.graph, "coverage.jsonl"),
|
coverage: path.join(PATHS.graph, "coverage.jsonl"),
|
||||||
/** LLM-named community records (contracts/graph.ts ClusterLabeling). */
|
/** LLM-named community records (contracts/graph.ts ClusterLabeling). */
|
||||||
clusterLabels: path.join(PATHS.graph, "cluster-labels.jsonl"),
|
clusterLabels: path.join(PATHS.graph, "cluster-labels.jsonl"),
|
||||||
|
/** Cluster-naming run usage (contracts/graph.ts NamingRunUsage) — the
|
||||||
|
* money channel's third stage; graph-global, so its own ledger. */
|
||||||
|
namingRuns: path.join(PATHS.graph, "naming-runs.jsonl"),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ import { z } from "zod";
|
|||||||
import { lhTool, toPiTools } from "../lh-tool.ts";
|
import { lhTool, toPiTools } from "../lh-tool.ts";
|
||||||
import type { PiEngine } from "../models.ts";
|
import type { PiEngine } from "../models.ts";
|
||||||
import { runSubagent } from "./pi-run.ts";
|
import { runSubagent } from "./pi-run.ts";
|
||||||
import type { GraphNode } from "../../core/contracts.ts";
|
import type { CallUsage, GraphNode, NamingRunUsage } from "../../core/contracts.ts";
|
||||||
|
import { callUsage } from "./extraction.ts";
|
||||||
import { getGraph, clusterContentId } from "../../core/graph.ts";
|
import { getGraph, clusterContentId } from "../../core/graph.ts";
|
||||||
import { matchClusterLabelings, MIN_CLUSTER_SIZE, type Community } from "../../core/clusters.ts";
|
import { matchClusterLabelings, MIN_CLUSTER_SIZE, type Community } from "../../core/clusters.ts";
|
||||||
import type { GraphStore } from "../../core/ports.ts";
|
import type { GraphStore } from "../../core/ports.ts";
|
||||||
@@ -68,6 +69,7 @@ async function nameClusters(
|
|||||||
engine: PiEngine | undefined,
|
engine: PiEngine | undefined,
|
||||||
signal: AbortSignal | undefined,
|
signal: AbortSignal | undefined,
|
||||||
unnamed: UnnamedCluster[],
|
unnamed: UnnamedCluster[],
|
||||||
|
onStep?: (usage: CallUsage, model: string) => void,
|
||||||
): Promise<{ key: string; label: string }[]> {
|
): Promise<{ key: string; label: string }[]> {
|
||||||
let submitted: { key: string; label: string }[] | undefined;
|
let submitted: { key: string; label: string }[] | undefined;
|
||||||
await runSubagent(
|
await runSubagent(
|
||||||
@@ -98,6 +100,7 @@ async function nameClusters(
|
|||||||
),
|
),
|
||||||
maxTurns: 4,
|
maxTurns: 4,
|
||||||
abortSignal: signal,
|
abortSignal: signal,
|
||||||
|
onAssistantMessage: (message) => onStep?.(callUsage(message.usage), message.model),
|
||||||
shouldStop: (_message, toolResults) =>
|
shouldStop: (_message, toolResults) =>
|
||||||
toolResults.some((r) => r.toolName === "submit_labels" && !r.isError),
|
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 };
|
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;
|
let appended = 0;
|
||||||
const seen = new Set<string>();
|
try {
|
||||||
for (const { key, members } of unnamed) {
|
const labels = await nameClusters(engine, signal, unnamed, (usage, modelId) => {
|
||||||
if (seen.has(key)) continue;
|
steps.push(usage);
|
||||||
const label = labels.find((l) => l.key === key)?.label?.trim();
|
model = modelId;
|
||||||
if (!label) continue;
|
});
|
||||||
seen.add(key);
|
const seen = new Set<string>();
|
||||||
graphStore.appendClusterLabel({
|
for (const { key, members } of unnamed) {
|
||||||
id: clusterContentId(members),
|
if (seen.has(key)) continue;
|
||||||
label,
|
const label = labels.find((l) => l.key === key)?.label?.trim();
|
||||||
core: members,
|
if (!label) continue;
|
||||||
createdAt: new Date().toISOString(),
|
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<unknown> = Promise.resolve();
|
let namingTail: Promise<unknown> = Promise.resolve();
|
||||||
|
|||||||
@@ -588,6 +588,22 @@ export interface ExtractionPipeline {
|
|||||||
importNotes(input: NotesImportInput): Promise<EmissionStats>;
|
importNotes(input: NotesImportInput): Promise<EmissionStats>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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 {
|
export function createExtractionPipeline(deps: ExtractionPipelineDeps): ExtractionPipeline {
|
||||||
const { chatStore, graphStore, embedder: embed } = deps;
|
const { chatStore, graphStore, embedder: embed } = deps;
|
||||||
// THE SHARED REGISTRIES (runs() above — globalThis): every pipeline
|
// THE SHARED REGISTRIES (runs() above — globalThis): every pipeline
|
||||||
@@ -619,19 +635,6 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
|||||||
run.usage[stage]!.steps.push(usage);
|
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<SessionFindings> {
|
async function extract(chatId: string, view: string): Promise<SessionFindings> {
|
||||||
const signal = runningExtractions.get(chatId)?.controller.signal;
|
const signal = runningExtractions.get(chatId)?.controller.signal;
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
/** Two relations only (DECIDED 2026-09-02): follows = b is derivable from a;
|
/** Two relations only (DECIDED 2026-09-02): follows = b is derivable from a;
|
||||||
* supports = a grounds b without entailing it. No scalars, no encoded
|
* supports = a grounds b without entailing it. No scalars, no encoded
|
||||||
* strength — strength is derived at read time from topology. */
|
* 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";
|
export type EdgeType = "follows" | "supports";
|
||||||
|
|
||||||
@@ -65,6 +65,41 @@ export interface NotesSource {
|
|||||||
extractions?: ExtractionRunUsage[];
|
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 {
|
export interface GraphNode {
|
||||||
/** Opaque stable id, store-assigned: "n_" + nanoid-like. */
|
/** Opaque stable id, store-assigned: "n_" + nanoid-like. */
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ export * from "./math-delims.ts";
|
|||||||
export * from "./ui-message.ts";
|
export * from "./ui-message.ts";
|
||||||
export * from "./tool-parts.ts";
|
export * from "./tool-parts.ts";
|
||||||
export * from "./prices.ts";
|
export * from "./prices.ts";
|
||||||
|
export * from "./cost-summary.ts";
|
||||||
export * from "./fuzzy.ts";
|
export * from "./fuzzy.ts";
|
||||||
// settings.ts + settings-resolution.ts (settings-storage wave, 2026-09-19):
|
// settings.ts + settings-resolution.ts (settings-storage wave, 2026-09-19):
|
||||||
// the user-settings record's shape/sanitizer (pure) and its fully-resolved
|
// the user-settings record's shape/sanitizer (pure) and its fully-resolved
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import type {
|
|||||||
TurnUsage,
|
TurnUsage,
|
||||||
NotesSource,
|
NotesSource,
|
||||||
ClusterLabeling,
|
ClusterLabeling,
|
||||||
|
NamingRunUsage,
|
||||||
EmbeddingsLedger,
|
EmbeddingsLedger,
|
||||||
} from "./contracts.ts";
|
} from "./contracts.ts";
|
||||||
import type { UserSettings } from "./settings.ts";
|
import type { UserSettings } from "./settings.ts";
|
||||||
@@ -101,6 +102,11 @@ export interface GraphStore {
|
|||||||
appendClusterLabel(labeling: ClusterLabeling): void;
|
appendClusterLabel(labeling: ClusterLabeling): void;
|
||||||
readClusterLabels(): ClusterLabeling[];
|
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
|
/** Upsert one imported-notes source record (config.SOURCES_FILE — a
|
||||||
* JSON map, not a ledger; an upsert keeps the existing usage log). */
|
* JSON map, not a ledger; an upsert keeps the existing usage log). */
|
||||||
upsertSource(source: NotesSource): void;
|
upsertSource(source: NotesSource): void;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { GraphSnapshot } from "../core/contracts.ts";
|
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 { graphSnapshot } from "../core/graph.ts";
|
||||||
import { stores } from "./stores.ts";
|
import { stores } from "./stores.ts";
|
||||||
|
|
||||||
@@ -11,3 +13,25 @@ import { stores } from "./stores.ts";
|
|||||||
export function graphView(): GraphSnapshot {
|
export function graphView(): GraphSnapshot {
|
||||||
return graphSnapshot(stores);
|
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() });
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import type {
|
|||||||
ProvenancePoint,
|
ProvenancePoint,
|
||||||
CoverageEntry,
|
CoverageEntry,
|
||||||
SessionCheckpoint,
|
SessionCheckpoint,
|
||||||
|
NamingRunUsage,
|
||||||
} from "../core/contracts.ts";
|
} from "../core/contracts.ts";
|
||||||
import type { GraphStore } from "../core/ports.ts";
|
import type { GraphStore } from "../core/ports.ts";
|
||||||
|
|
||||||
@@ -90,6 +91,19 @@ function readValid<T>(file: string, isValid: (v: unknown) => v is T): T[] {
|
|||||||
const isProvenancePoint = (v: unknown): v is ProvenancePoint =>
|
const isProvenancePoint = (v: unknown): v is ProvenancePoint =>
|
||||||
isRecord(v) && isString(v.chatId) && isString(v.messageId);
|
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 =>
|
const isClusterLabeling = (v: unknown): v is ClusterLabeling =>
|
||||||
isRecord(v) &&
|
isRecord(v) &&
|
||||||
isString(v.id) &&
|
isString(v.id) &&
|
||||||
@@ -198,6 +212,18 @@ export const graphStore: GraphStore = {
|
|||||||
return readValid(LEDGER_FILES.clusterLabels, isClusterLabeling);
|
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) ──
|
// ── imported-notes bookkeeping (config.SOURCES_FILE — a JSON map) ──
|
||||||
|
|
||||||
/** Upsert: keeps the existing record's usage log when re-importing. */
|
/** Upsert: keeps the existing record's usage log when re-importing. */
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -33,6 +33,7 @@ import type {
|
|||||||
GraphEdge,
|
GraphEdge,
|
||||||
GraphNode,
|
GraphNode,
|
||||||
LedgerEvent,
|
LedgerEvent,
|
||||||
|
NamingRunUsage,
|
||||||
NodeMapping,
|
NodeMapping,
|
||||||
NotesSource,
|
NotesSource,
|
||||||
SessionCheckpoint,
|
SessionCheckpoint,
|
||||||
@@ -70,6 +71,7 @@ function memoryLedgers() {
|
|||||||
const coverage: CoverageEntry[] = [];
|
const coverage: CoverageEntry[] = [];
|
||||||
const sources = new Map<string, NotesSource>();
|
const sources = new Map<string, NotesSource>();
|
||||||
const clusterLabels: ClusterLabeling[] = [];
|
const clusterLabels: ClusterLabeling[] = [];
|
||||||
|
const namingRuns: NamingRunUsage[] = [];
|
||||||
const positions = new Map<string, { x: number; y: number }>();
|
const positions = new Map<string, { x: number; y: number }>();
|
||||||
const embeddings = {
|
const embeddings = {
|
||||||
model: "",
|
model: "",
|
||||||
@@ -111,6 +113,11 @@ function memoryLedgers() {
|
|||||||
if (!clusterLabels.some((x) => x.id === l.id)) clusterLabels.push(l);
|
if (!clusterLabels.some((x) => x.id === l.id)) clusterLabels.push(l);
|
||||||
},
|
},
|
||||||
readClusterLabels: () => clusterLabels.map((l) => ({ ...l, core: [...l.core] })),
|
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.
|
// The layout ledger — settleGraphLayout writes it after each merge.
|
||||||
readPositions: () => Object.fromEntries([...positions].map(([id, p]) => [id, { ...p }])),
|
readPositions: () => Object.fromEntries([...positions].map(([id, p]) => [id, { ...p }])),
|
||||||
writePositions: (next) => {
|
writePositions: (next) => {
|
||||||
@@ -799,6 +806,18 @@ describe("cluster naming (the post-extraction garnish)", () => {
|
|||||||
assert.equal(again.appended, 0);
|
assert.equal(again.appended, 0);
|
||||||
assert.ok(again.claimed >= 1);
|
assert.ok(again.claimed >= 1);
|
||||||
assert.equal(ledgers.readClusterLabels().length, 1, "no duplicate records");
|
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 () => {
|
it("naming failure never fails the run", async () => {
|
||||||
|
|||||||
+24
-1
@@ -18,7 +18,7 @@ import assert from "node:assert/strict";
|
|||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import { after, describe, it } from "node:test";
|
import { after, describe, it } from "node:test";
|
||||||
import type { UIMessage } from "ai";
|
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 type { QuestionAnswerData } from "../src/core/question-core.ts";
|
||||||
import { clusterContentId, getGraph, graphSnapshot, mergeFindings, type GraphDeps } from "../src/core/graph.ts";
|
import { clusterContentId, getGraph, graphSnapshot, mergeFindings, type GraphDeps } from "../src/core/graph.ts";
|
||||||
import { graphStore } from "../src/store/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" }]);
|
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", () => {
|
it("returns RAW node lines — repeated ids (link-appends) are each their own line; getGraph merges", () => {
|
||||||
const line = (msgId: string) =>
|
const line = (msgId: string) =>
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
|
|||||||
Reference in New Issue
Block a user