Commit Graph
20 Commits
Author SHA1 Message Date
amosblomqvist 814c2ad412 delete the batch practice-problems module (superseded by practice sessions)
Owner ruling 2026-10-04: the module is a sunk cost, retrievable from
history if ever revisited. Removed: core practice contracts + pipeline
helpers, the PracticeStore port + adapter + data dir path, server
service + API routes (sets, clusters picker, practice aborts), the
practice pages + dialog/exam/list UI, chain-table's Generate handoff,
sidebar nav, activity-dock practice rows, and the partitioner/
problemGenerator/grader model roles (BACKGROUND_MODEL_ROLES 8 → 5).

Survivors, moved to core/chains.ts (pure + browser-safe): ReasoningChain,
chainsForSelection, formatChainLine — the chain table and graph
emission still consume them.
2026-10-04 21:34:24 +03:00
amosblomqvist e7f4923159 deletion screen minimal + 'node' as THE UI word (owner ruling)
Owner 2026-09-30: the confirm dialog's pre-flight impact read had to
go — what full death takes is obvious, the per-open fetch flickered,
and richer facts would only confuse as deletion semantics evolve
(rewiring is on the table). The screen is now: Delete node? / Delete
N nodes?, the names in the mono block, one permanence line, Cancel /
Delete. No loading state, no gone state (a 404 mid-run lands in the
honest failure line), no sessions join — the joins prop died too.

The dead path is deleted end to end: GET /api/graph/nodes/[id],
graphNodeImpact (server), nodeDeletionImpact (core) and their tests.

WORDING: node/node(s) is THE UI word for graph objects, standardized
across every surface's copy (owner, same ruling): the dialog titles,
the chain table's counts/search/summary/delete aria-labels, the
practice dialog's cluster counts, selection line, and empty-cluster
hint. Internal identifiers (selection.propositions) unchanged.

Rewritten dialog pins (tests/delete-node-dialog.test.tsx): minimal
contract — no facts/loading/fetch copy, singular/plural titles, the
cap, 409 dock phrasing, busy states, no 'proposition' left in the
dialog.
2026-09-30 01:19:03 +03:00
amosblomqvist f39e1b4294 practice fix — creation is graph-domain: cluster picker, nodeIds-only API (owner ruling 2026-09-29)
Owner ruling: 'problem sets always take in nodes — graph objects' —
practice is graph-domain, not chat-domain. No session-created sets.

API: POST /api/practice/sets takes {nodeIds, params, title?} ONLY —
the chatId branch, UnknownChatError, and the any-provenance filter are
deleted; the selector is nodeIds (readNodes filtered to the selection,
unknown ids drop, empty live intersection → 400 empty_selection);
title optional from the UI (the picker's cluster label), dated default
stays. chatStore leaves the pipeline deps entirely (labels resolve
store-side in the clusters service).

GET /api/graph/clusters (new): the birth-session groups the graph view
renders — graphSnapshot's clusters verbatim (same ordering, same
MIN_CLUSTER_SIZE presentation filter), mapped to {key, kind
chat|notes, label (null when the naming record is gone — honest,
never guessed), nodeIds, nodeCount}; corrupt/empty graph → empty
list. GraphClusterGroup contract in core/contracts/graph.ts (client-
safe — the dialog imports it).

practice-dialog: session picker → cluster picker (rows show label +
proposition count, notes clusters marked); Create posts the cluster's
nodeIds + label as title + params. 400 empty-selection copy reworded
for the vanished-nodes edge ('Nothing practicable is left in that
cluster — the graph changed.'); 503 deep link unchanged. Preselected
mode gone — the dialog is the only creation entry for now (the graph-
view cluster entry comes later with free-form selection).

Sidebar: session right-click 'Practice this session's propositions'
removed (item + state + dialog wiring); the /practice nav row stays.

Tests: pipeline — chatId/any-provenance tests die, nodeIds selection
pins added (unknown-id filtering, dated-default title, title hint
rides, empty/vanished → EmptySelectionError); render — cluster picker
pins (label+count rows, notes kind, unlabeled cluster muted, empty
graph line), preselected pins removed; 16 render + 13 pipeline.

Docs: architecture.md §7 API+UI paragraph rewritten (graph-domain
ruling, /api/graph/clusters, cluster picker as the only entry, no
404-unknown-chat); README creation-flow line updated.

1130/1130, tsc 0, lint 0, build 0.
2026-09-29 19:26:37 +03:00
amosblomqvist 4001c3465b Graph view: unique pill keys for chunk jobs (second duplicate-key consumer)
The graph page's pipeline pills keyed rows by run.chatId — the source id,
shared by every chunk job of a fan-out (the dock got the same fix in
8f66f47; this consumer was missed). /api/graph/status rows now carry an
id (the registry job key: source id, or 'src#i'), and the pills key by
it. chatId stays the SOURCE id — the reveal refetch and per-run cost
refresh track chatId SETS, which dedupe naturally and fire when the
source's LAST row leaves the feed.
2026-09-29 01:26:23 +03:00
amosblomqvist d047e7b2dc Per-chunk jobs: parallel extracts under a 3-slot semaphore, serialized audits (owner 2026-09-28)
Every notes chunk and chat coverage batch is its own job — registry row,
usage record, failure domain. Extracts fan out process-wide behind a
3-permit extract semaphore on globalThis (new 'queued' phase); audits and
merges stay serialized behind the existing FIFO gate ('waiting'), so each
auditor explores the graph after every earlier job's merge.

- ExtractionPhase gains 'queued'; RunningExtraction gains part {i,n};
  the feed shows one row per job with a '· i/N' suffix (status route)
- abortExtraction is source-wide: every job of a fan-out dies together;
  merged jobs keep their coverage checkpoints
- failure isolation at the fan-out (Promise.allSettled + aggregation):
  a failed chunk no longer kills its siblings — the run returns what
  actually merged; job-level aborts log, pre-fan-out errors still throw
- usage ledgers: one record per job (source-keyed), failed jobs bill
  their partial spend
- runJob/aggregateJobs helpers; setPhase/recordStep/extract/audit/
  notesExtract key the registry by jobKey (src#i) — notesExtract's
  signal lookup was the one real bug the new tests caught (source-id
  lookup meant fan-out aborts never reached the model call)
- semaphore is handoff-correct (fresh takes increment; handed-off
  permits don't double-count — an inflated counter deadlocks at 3)
- tests: isolation pins rewritten (stage failure now resolves zeros),
  single-flight/registry tails updated, per-job usage counts, and a new
  describe pinning the fan-out: 3 extracting + 5 queued, parts 1..8,
  source-wide abort, per-job usage on failure, extract peak == 3,
  audit peak == 1
- docs/architecture.md: serialized-audit paragraph rewritten for
  per-chunk jobs; two-front-doors wording aligned
2026-09-29 00:34:21 +03:00
amosblomqvist e4049e4bf8 Key-presence refusal at every gate: a pinned model is not enough
Owner-diagnosed: the turn gate checked only model coordinates, so a
pinned slot whose provider key was deleted passed the gate and died
mid-stream masked to 'An error occurred.' The key is now gated where
the model is — by what would RUN (env-first keyValue truth, so an
env-shadowed key passes).

- core/settings.ts: the provider_key_missing envelope family (code,
  provider, slots, human line naming the provider from AUTH_PROVIDER_KEYS
  labels) + ProviderKeyMissingError + isProviderKeyRefusal matcher.
- chat-handler turn gate: refuses 503 before beginTurn when the resolved
  conversation's provider key is gone (slots: [strong]).
- Job runner: same check via an injected dep (agent layer cannot read
  the key store) — a keyless engine refuses the spawn through the
  existing failure-shape convention, no phantom run.
- Extraction pipeline: refuseIfStagesUnready refuses BEFORE any batch
  (first offender: extractor before auditor); the trigger skips info-
  level, /api/graph/import surfaces the 503 envelope per file.
- Tests: 8 new pins (turn gate both ways, runner refusal, pipeline
  first-offender + pass-through, envelope factory, matcher); the three
  turn-driving suites seed OPENROUTER_API_KEY like any real setup.
- Doc fix: stale EXTRACTION.chunkTokens comment in the manual-extract
  route (left over from eed4b4e).

Known residual (pre-existing, flagged for master): the manual extract
route logs BOTH config refusals as console.error (fire-and-forget path);
the coverage ledger keeps everything uncovered either way.
2026-09-24 22:51:12 +03:00
amosblomqvist 3327309e8b The all-sessions coverage GET dies: the per-session block is the coverage surface
Extraction-UX close-out interlude (master dispatch 2026-09-24, off UI's
ff1ec7a confirmation): nothing client-side consumes GET /api/graph/coverage
— the sidebar block and the chat-bar ring both read GET /api/chats/[id]'s
coverage block. The dead route (app/api/graph/coverage/route.ts) is
deleted; the manual-extract front door (POST /api/graph/coverage/extract)
stays by design.

No server helper was orphaned — the route was composition-only over
shared seams (chatStore, extractionStatuses, stores, coverageOf). The
sessionCoverage doc + the chats/[id] route comment now point at the
per-session block as THE coverage surface.

Pin (tests/coverage.test.ts): the route file's absence (prompt-overrides'
structural-pin precedent) + the Extract POST's survival — a merge cannot
resurrect the dead surface silently.
2026-09-24 15:29:01 +03:00
amosblomqvist 2d133f1133 Single-node deletion: full death by node id (node interlude, owner-locked)
The remedy for undeletable nodes (preserve-graph orphans from session
deletion; notes-import nodes with no chat behind them): DELETE
/api/graph/nodes/[id], full-death semantics — the user pointing at the
node IS the intent; references from other sessions do NOT preserve it.

- core/graph-deletion.ts deleteNodeTraces: the node's every line, every
  edge touching it, every quiz/observation event naming it, its mapping
  lines, and its resting position — one synchronous atomic rewriteLedgers
  call; only the ledgers that changed rewrite (mtimes stay honest). Blast
  radius is exactly the rows naming the node id; notes source records stay
  (deleting a proposition is not deleting the file). Derived data
  self-heals on read: the birth cluster shrinks below MIN_CLUSTER_SIZE and
  stops rendering.
- nodeDeletionImpact (GET /api/graph/nodes/[id]): the confirm dialog's
  pre-flight facts — edge count, quiz/observation history, referencing
  sessions. The snapshot's edges + nodeSessions cover part of this, but
  the raw event history was nowhere on the client — hence the read.
- Guards (server/graph.ts deleteGraphNode), decided + documented: NO
  turn/jobs gates (they never write graph ledgers; the orchestration is
  synchronous end to end, nothing interleaves). THE EXTRACTION GATE: 409
  while ANY pipeline run is registered — a mid-flight run holds
  pre-deletion findings and could re-reference the dying node. The run is
  never aborted (its spend belongs to another session — outside this
  deletion's blast radius; the UI retries once the dock drains. A run
  started after the deletion is legitimate re-learning: fresh ledgers,
  fresh node id, and mergeFindings' existingIds guard ignores linkTo ids
  that no longer exist — no dangling edges, no resurrection.

Live-verified on dev data: impact read, full-death stats, honest 404 on
repeat, and the file cluster fell 3→2 and unrendered itself.

859/859 tests, typecheck/lint/build green.
EOF
)
2026-09-23 16:28:24 +03:00
amosblomqvist bfa906e2f8 feat(models): BYOK all the way down — factory pins deleted, per-need refusal gates
Owner decision 2026-09-22: 'it doesn't make sense to have any default
model configured anywhere; the application is bring your own keys.'

config.ts: MODELS deleted; ModelRole becomes an explicit 9-role union;
DEFAULT_THINKING_ORDER (medium, low, high, minimal, xhigh, max) bakes
the unset-level default — first entry the model supports; explicitly
set levels keep pi's native nearest-clamp.

core/settings-resolution: the factory layer dissolves — the chain is
role override → slot → NOTHING. resolveSettings(record) takes no
factory. Empty chains resolve to null/undefined; a thinkingLevel-only
slot is inert (stored, never run). resolveConversationSpec: chat.model
override over the strong slot, null when both empty. resolveRoleSpec
spans all nine roles for the job/extraction engines.

Refusal envelope (core/settings.ts): modelsNotConfigured(missing) —
{error, code: 'models_not_configured', missing: [slot tiers]} +
ModelsNotConfiguredError with .envelope. Per-need gates, each honest:
- chat-handler: universal turn gate (new + continuing turns) returns
  503 before beginTurn — no liveness, no write-ahead persist, no graph
  trigger before the gate fires.
- runner.runJob: unresolvable engine lands the job as error with the
  envelope wording; no phantom loop, no result steer.
- extraction: refuseIfStagesUnconfigured throws before any stage
  (turn-service info-skips so coverage retries next turn; the import
  route surfaces per-file code+missing); labeler-less runs skip
  cluster naming with a console line and still merge.

Write side: slot entries must carry a model — level-only slot writes
are 400s (absent slot key = clear). piEngineForSpec resolves unset
thinking levels via DEFAULT_THINKING_ORDER; piEngineFor now throws
honestly; thinkingLevelFor/modelIdFor deleted; teacher-agent, pi-run,
visual-maker, pdf-reader all read engine.level.

model-catalog: defaults/slots/rolePins null when the chain is empty
(contracts nullable) — no phantom ids in payloads. Stores drop
MODEL_PINS entirely.

Tests: pin-leaners converted to explicit injection (per-chat model
pins on scripted chats — exactly what the setup flow writes; faux
engines for agent-construction tests). New coverage: the 503 gate
envelope, the runner refusal, the extraction refusal + labeler skip.

Gates: typecheck, lint, 815/815, build.
2026-09-22 16:59:16 +03:00
amosblomqvist 3e263a18cc Graph pipeline cost metrics: capture naming spend, fold all channels, cost pill + panel
- Cluster namer bills the money channel now: per-step usage + model
  captured via onStep, persisted as NamingRunUsage records to
  data/graph/naming-runs.jsonl (idempotent per startedAt; failed/aborted
  runs persist too — money spent either way). callUsage promoted to
  extraction.ts module scope for reuse.
- core/cost-summary.ts: the pure fold behind the pill — chat runs,
  notes-source runs, and naming runs into stage totals (extraction /
  audit / naming) + newest-first recent runs. Dollars INFERRED read-time
  via prices.ts, never stored; unpriced models excluded-but-counted
  (render '—', never a silent zero); tokens always sum.
- GET /api/graph/costs (src/server graphCosts composition, thin route).
- Graph view: bottom-left cost pill (cumulative dollars headline) +
  expandable breakdown panel (stage rows with tokens secondary, recent
  runs with time/title/merged/cost); fetched on mount, refetched when a
  pipeline run finishes; merged into one bottom-left stack with the
  running-pipeline pills.
- Tests: pure-fold suite, naming capture wiring (full importNotes ->
  naming path), store idempotency + strict reads.
2026-09-19 20:55:35 +03:00
amosblomqvist 2678f04e7a feat: extraction coverage — message-granular ledger + token-batched runs (d132373 vision)
THE MESSAGE IS THE UNIT OF COVERAGE (vision-extraction-coverage.md): a
per-chat append-only ledger (coverage.jsonl) records which messages the
pipeline has extracted, by content fingerprint over exactly what the
extraction view sees (one shared line builder — core/coverage.ts); the
uncovered remainder is DERIVED at read time, never dual-tracked. A quiz
answer merging into the asking message re-opens only that message;
plan-block data-part attach never re-opens anything.

- The trigger (turn-service) replaces every-3-quiz-answers with
  uncovered-tokens >= EXTRACTION.chunkTokens (4096) — quiz-less sessions
  extract too, content size is the honest signal.
- runExtraction is now chunked: token-budgeted batches of the oldest
  uncovered consecutive messages (a tool call + result never split), each
  merged batch checkpointing its messages' coverage atomically — an abort
  or error loses at most the in-flight batch; merged batches stay covered.
- Observation refs are batch-tagged (obs:<batchTag>:<sourceId>): re-runs
  of the same batch dedupe; a re-opened message's fresh understanding
  lands as a new event. Fixes the old obs:p1 position-collision.
- New API: GET /api/graph/coverage (exact per-session derived coverage +
  running flags, most-uncovered first) and POST /api/graph/coverage/extract
  (manual run for one session, 409 while in flight).
- Graph view chrome: the coverage button opens a panel with every
  session's covered/uncovered tokens and a per-session Extract button —
  the manual answer to aborted runs, errors, and dormant tails. Renders
  even when the graph is empty: an empty graph with uncovered sessions is
  exactly when it matters. Polls only while open.
- Notes imports keep the whole-file checkpoint path (coverage undefined).
- Tests: pure-core coverage suite (11), chunked-pipeline suite (3:
  multi-batch + no-op, abort-keeps-earlier-batches + remainder re-run,
  re-open alone + data-part invisibility), panel render suite (5);
  pendingQuizAnswers and its tests deleted as dead code. 486/486.

Live-smoked on real GLM: manual extract of "teach me modal realism" ran
one batch, wrote 4 coverage entries, panel flipped to "1.3k covered ·
complete" with the button disabled, graph refetched (nodes 11), usage
persisted with real stats.
2026-09-15 17:41:56 +03:00
amosblomqvist 05712037b2 Live physics drag + splat cluster regions
The renderer grows a live d3 sim (createGraphSimulation, shared with
the server settle): pointerdown on a node pins fx/fy and pointer capture
(preventDefault kills the compat mousedown, so d3-zoom never pans); the
first CLICK_SLOP-exceeding move reheats to alphaTarget 0.3; release
un-pins and the sim anneals (release-and-relax) — alpha < 0.005 settles
it, a 250ms debounced onCommit PUTs the rest state. Verified live: 123fps
during a 320ms drag (regions rebuilding every 3rd frame), 85-tick settle,
then zero idle frames. A slop-free press stays a click (select, no reheat,
no commit). PUT /api/graph/positions sanitizes (finite coords, known ids)
and if the graph grew mid-drag runs ONE incremental settle with the
client's arrangement as anchors (50-id PUT test: anchored drift 0.15).

Cluster regions swap convex hulls for a low-res world-space SPLAT bitmap:
per-member radial-gradient discs (alpha 0.2) + intra-cluster edges as
round-cap strokes (Minkowski dilation — concave shapes finally render),
upscaled with bilinear smoothing (the upscale IS the blur, no ctx.filter:
Safari). One tiny blit per frame replaces 32 clusters x 4 offset
Path2D polygons re-rasterized at DPR 2 (the old GPU coil-whine path);
during the live sim the bitmap rebuilds every 3rd frame. cluster-geometry.ts
retires convexHull/offsetPolygon/traceRoundedPath (tests follow; clusterHue
stays). Region labels stay (centroid + clusterHue, inverse-gated on zoom).
2026-09-13 09:17:05 +03:00
amosblomqvist ecadededd9 World-space rendering: geometry cached once, zoom pays only rasterization
Fixes the zoom frame-rate collapse and rebuilds draw() around a single
camera transform, per the user's Obsidian-style sizing proposal.

Bug fix first: the eased zoom leaked a queued rAF callback per frame --
advanceZoom applies each step through d3-zoom, whose zoom event called
markDirty() from inside the tick loop, scheduling an extra draw while the
loop scheduled its own. Draws multiplied per displayed frame; sustained
wheel ran at ~20fps against a 120Hz display. markDirty now skips
scheduling while inTick (verified: 62 frames/500ms during a 28x zoom
burst, matching pan).

World-space model: every draw call now runs in graph coordinates under
one setTransform(k). Sizes are defined as screen-px-at-fit and converted
once per geometry build (/ kFit), so nodes, node labels, arrowheads and
the hover halo scale with the graph as you zoom (0.25x painted area at
half zoom, exactly k^2) while edge widths and hull borders stay
viewer-relative hairlines (/ k). Cluster hull geometry (convex hull +
three offset fills + solid boundary -- dashes dropped for now) is built
as cached Path2Ds per data/visibility/settings change; camera moves do
zero geometry math, making zoom per-frame cost identical to pan. Label
wrapping runs once per build, not per frame. Region labels stay
viewer-relative (18px at any zoom) as UI chrome. Picking radius scales
with the world-sized discs.

Glide feel: ZOOM_EASE_MS 120 -> 45, convergence 0.1% -> 0.2%; a notch now
settles in ~260ms instead of ~750ms. Resize still paints synchronously.

Also fixes barrel-law lint violations from the settings wave (import
from @/src/core and @/src/server, not deep module paths).
2026-09-13 07:18:37 +03:00
amosblomqvist 8e2d2118a6 Graph view refinements: server-persisted settings, label size, eased zoom, resize-blank fix
Five refinements to the hand-rolled renderer, all user-directed:

1. Settings are SERVER-persisted (data/render-settings.json via new
   GET/PUT /api/graph/render-settings) - one source of truth for every
   browser, replacing localStorage. The client loads once after mount,
   saves debounced on user changes, and never lets a slow GET clobber a
   faster user (or mounting PUT defaults over the server copy). The
   shape/defaults/sanitizer moved to src/core/render-settings.ts - pure,
   shared by client, server store, and tests; every disk/network value
   passes through sanitizeRenderSettings.

2. New "Label size" control (default 12px - the previous hardcoded size).
   Font, wrap width, and line height all scale; verified live: +76k px
   with the label gate open.

3. "Labels until zoom" slider reworked: log-scaled over 0.0625-4 so the
   useful band (0.25-0.5 camera-ratio units) sits at 33%/50% of the
   track instead of crammed at the far left of the old linear 0.25-4
   slider. Display units are normalized: the default 0.5 reads as "1x",
   0.25 as "0.5x". The underlying labelZoom semantics are unchanged,
   so existing values survive.

4. Smooth wheel zoom: d3-zoom instant per-notch jumps (x1.2, bang) are
   replaced by an eased glide - wheel events accumulate a cursor-anchored
   target scale and each tick eases the camera toward it (exponential,
   120ms constant, frame-rate independent). Pan/pinch stay d3-owned; a
   pan cancels a glide in progress. Verified live: 8-frame glide.

5. Resize blanking fixed (graph vanished for the duration of a window
   drag, reappearing ~300ms after). Cause: every resize event
   reallocated the canvas backing store - which CLEARS it - and the
   redraw was deferred to rAF, which live resizes starve via relayout,
   so the canvas sat wiped for the whole drag. Fix: the ResizeObserver
   callback now redraws SYNCHRONOUSLY after reallocating (verified via
   a probe RO firing in the same delivery batch: canvas fully painted).
   kFit (the label gate 1x reference) is also recomputed on resize from
   the now-cached world bbox - the camera itself still persists.

tests/render-settings.test.ts: 10 tests pinning the sanitizer (garbage/
partial/clamps, legacy no-labelSize records) and the slider mapping
(default dead-center at 500, monotonic, exact round-trips, display
units). 402/402 green; typecheck, lint, build pass.
2026-09-13 05:56:27 +03:00
amosblomqvist 543cf33789 Notes front door: format-agnostic import into the graph pipeline
The same extractor→auditor→merger pipeline now runs over raw personal
notes in arbitrary formats (vision §1, DECIDED 2026-09-13):

- NotesSource records (data/graph/sources.json, deterministic id from
  path hash → idempotent re-imports) play the chatId role in mappings,
  checkpoints, and observation events; usage lands on the source record
  (appendSourceUsage, idempotent per startedAt, kept across upserts).
- mergeFindings is chat-store-free: callers pass MergeSource
  {messages, foldedAt} — sessions pass the chat record, notes pass
  messages:null (no quiz-event rebuild; a deterministic rebuild needs
  structured quiz tool parts).
- Second extractor prompt (NOTES_EXTRACTOR_INSTRUCTIONS) for general-
  purpose extraction; understanding judges the AUTHOR's grasp. Auditor
  wording input-agnostic. extract/audit share one generic runSubmitAgent.
- graphSnapshot.fileSources (srcId→filePath); notes sources render as
  static path entries in the detail card (FileTextIcon, never a link).
- One shared pipeline instance (exported from chat-handler): same
  single-flight registry, live status chips (label = file path), usage
  accounting, and true abort for both front doors.
- Transport: POST /api/graph/import (multipart, 3-wide worker pool) +
  Import notes… button in the graph view; scripts/import-notes.ts for
  batch runs (concurrency 4).
- real-data-extraction/ gitignored (raw import sources never committed).

355/355 tests; typecheck, lint, build green.
2026-09-13 02:08:55 +03:00
amosblomqvist 9174cde55e Extraction observability: usage capture, live status payload, true abort (Wave 4)
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.
2026-09-11 03:25:25 +03:00
amosblomqvist 83a00800e9 Observability contracts: usage accounting, plan blocks, price table (Phase 0)
The 2026-09 observability wave's type-level foundation, landed before the
streams so nothing renders against guessed shapes:

- contracts/chat.ts: CallUsage + TurnUsage (per-model-call token accounting,
  one entry per teacher/wake turn keyed by messageId) and ChatRecord.turns —
  a DELIBERATELY parallel append-only usage 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 messages gets clobbered
  by the next persist (the wave-3 merge-rule lesson).
- contracts/chat.ts: PlanBlockData — the persisted data-plan-block part
  (full DAG on plan_init, one-level neighborhood on plan updates, snapshot
  semantics so history renders what was true then).
- contracts/jobs.ts: JobUsage + JobRecord.usage — a sub-agent run's
  accumulated accounting, written by the runner.
- core/ports.ts + store/chats.ts: ChatStore.appendTurnUsage — idempotent
  per messageId (abort replays / wake retries append once); listChats no
  longer leaks turns into the list payload.
- core/prices.ts: PRICES + CONTEXT_LIMITS (generated from OpenRouter's
  published pricing via scripts/sync-pricing.ts) + pure folds: callCost,
  turnCost, sessionCost, contextWindow, jobCost. Unknown model or absent
  fields render as absent, never zero.
- server/observability.ts: barrel-approved surface for the transport layer
  (fixes the import-law violation in the graph status route that slipped
  through the wave-3 merge verification).

Capture wiring (agent onStepFinish -> persist seam, plan-block emission)
lands next as master seam work; the streams start against these shapes.
2026-09-11 02:43:35 +03:00
amosblomqvist 0c12d53fca Background pipeline status: registry + /api/graph/status + graph-view chips
The background extraction pipeline is invisible on the home surface —
essential observability per the user's directive: the graph view must
show what is currently running (extracting / auditing / merging) and
for which chat.

- extraction.ts: module-level registry of in-flight runs (chatId,
  phase, startedAt), updated at each phase transition in runExtraction
  and cleared in finally (done or failed). extractionStatuses() is the
  read; one process, one registry.
- GET /api/graph/status: thin transport over the registry, chat titles
  joined from the chat store.
- graph-view: polls every 2s and shows one chip per running pipeline
  (spinner + phase + title + elapsed seconds, bottom-left); when a run
  finishes (some -> none) the graph refetches, so merges land live.
  Status is cosmetic — poll errors never surface.
- Unit test drives a mocked pipeline run and observes the registry
  mid-flight: 'extracting' during the extractor's generate, 'auditing'
  during the auditor's, empty after.
2026-09-09 01:26:49 +03:00
Amos e92140f993 Shell/core import law: public API barrels + ESLint enforcement (stage 2)
The shell now imports through exactly two barrels, enforced by
import/no-restricted-paths (npm run lint):

  components/**  ->  "@/src/core"      (pure core API, browser-safe)
  app/**         ->  "@/src/core" + "@/src/server" (server entries)

Zone violations are lint errors with architecture-pointer messages; the
pure-core modules (contracts, question-core, plan-tree, phases, pending,
math-delims, ui-message) may never import agent/server/store.

Supporting moves (direction: IO layers -> core):
- core/index.ts: the core public API barrel
- core/ui-message.ts: moved from src/agent (it was shell-facing all along)
- quiz/ask input zod schemas moved agent/tools -> core/question-core
  (the question contract, alongside the grading logic)
- ChatRecord/ChatSummary moved store/chats -> core/contracts (single schema)
- GraphSnapshot/NodeStateClass moved core/graph -> core/contracts (the
  /api/graph payload IS a contract; the pure renderer needs only these)
- src/server/{chats,graph,jobs}.ts + index.ts: thin server entries for the
  transport layer; GET /api/graph's fold side-effect now lives behind
  graphView() instead of inline in the route

Gates: tsc clean, 190/190 tests, eslint clean (law verified to fire on
store/agent/subpath violations), production build clean.

(Repo repair en route: a git object (app/chat/[id] tree) was missing from
the object store — reconstructed byte-exactly via mktree from its blob;
the broken node_modules that stalled the build was cleanly reinstalled.)
2026-08-31 19:51:17 +03:00
Amos 8728573253 W4: graph store + emission + home graph view 2026-08-22 10:30:00 +03:00