electron-updater in the shell (packaged builds only; dev never updates).
Checks at startup + every 24h, downloads silently, and the renderer shows
a Restart banner once the update is staged (it installs on quit either
way). Manual "Check for Updates..." under the app menu answers via dialog.
Every updater event routes through the shell log and is caught - the
updater can never take the app down (verified live: a feed failure logs
three lines and the app runs on).
LH_UPDATE_FEED overrides the baked GitHub feed with a generic local
server - the pre-release drill: serve latest-mac.yml + zip + blockmap
from localhost and a real installed copy updates itself, no release, no
public repo.
The asar allow-list re-includes electron-updater + its 16-package runtime
tree explicitly (gitignore last-match-wins; asar verified at 2.1 MB).
The banner rides the preload bridge (onUpdateStatus/installUpdate) and
renders nothing in the browser. window.lh typing centralized in
sidebar.tsx's declaration.
Root cause (postmortem, verified live): Chromium caps 6 concurrent
HTTP/1.1 connections per origin. Every running turn pinned slots for
its whole life — the POST /api/chat response stream plus a reattach
GET stream per visit that never closed on navigate-away — so two
concurrent thinking sessions starved navigation RSC fetches in the
browser queue (147s measured) until the turns settled. Server was
exonerated (3ms curls during the freeze); main thread idle throughout.
- COMPACTION LAW + WIRE DIET (ui-stream): store the states, not the
chunks — replace-in-place snapshot retention keyed by updateSlots,
~100ms coalesced latest-wins fan-out, slim wire drops pi's duplicate
assistantMessageEvent payload.
- DIET B: POST /api/chat acks and closes ({ok:true}) — the turn wire
is GET /stream for everyone, submitter included; send() reattaches
via refetch-first resumeStream(true).
- DIET A: unmount cleanup calls model.stop() — navigating away aborts
the view connection (turns are server-side background objects).
- Answer-flicker fix: resumeStream's eager tail clear moved off the
refetch path (the refetch swaps base+tail atomically; clean-slate
guard restored on refetch failure) — the optimistic answer card
survives refetch latency (1.7s stalls measured on dev).
- Tests converted to the ack contract (drainTurn/drainRunning helpers,
dietFetch mock) + a pin that the POST body reads while the turn runs.
Gates: 1489/1489, typecheck, lint. Live: dual streaming sessions,
navigations 335-648ms, in-page settings 19ms (was 147s).
The one emphasis device for load-bearing ideas: GitHub-style alert
callouts. The prompt teaches only > [!IMPORTANT] (one device, one
meaning — an unconditional truth laid down, a payoff landed, a hinge
definition) with the restraint law: everything else stays in prose, if
everything is boxed nothing is. The renderer accepts all five GitHub
markers and boxes them identically — a stray [!NOTE] still meant 'this
deserves emphasis', and a marker line must never leak as text.
Rendering (owner rulings): SHARP corners, white stroke, no background,
no label, no icon — just the outline — and the box hugs its text
(width: fit-content capped at the column) instead of stretching
full-width into empty space. Same grammar as the plan blocks: this is
structure, not chrome.
Seam: the Streamdown blockquote override in md-blocks. parseAlertMarker
is pure and exported — Streamdown wraps blocks in its own styled
components, so detection reads the hast node's tagName, and a standalone
marker drops its <br> hard-break artifact along with the empty
paragraph. Plain blockquotes pass through untouched.
Rider (owner, same round): tables hug their contents — width:
max-content on the data-streamdown table (width:auto would still
stretch, the classic table trap); wide ones keep the md-blocks
overflow-x scroller. Graceful in exports: a callout is still a
blockquote there.
6 render pins: the box mounts with white stroke + fit-content + no
rounding, all five markers box, standalone-marker drops the empty
paragraph, multi-paragraph structure kept, plain quotes untouched.
- bridge measures the BODY (scrollHeight + rect + margins) instead of the
viewport-clamped documentElement — documentElement.scrollHeight latches
at the parent's own height, so demos could grow but never shrink on
window resize (owner-reported); smoke: 1036 → 2386 → 1036 px
- invert now PERSISTS as details.ui.inverted on the steer record, written
by the silent artifact-ui route (dismissal pattern: no wake, no SSE) and
seeded through the projection (SubagentArtifactData.ui); survives
navigation + reload, never enters teacher context or exports
- invert restyled as the house Toggle switch (settings controls) on both
cards; demo header is the caption grammar: 'INTERACTIVE DEMO — title'
left (title normal-case, same size/tracking), switch + download far
right; figure header: label left, switch/download/open far right
1439 tests green (route + pure-seam pins added); live smoke passed
Owner round (2026-10-06): markdown export of sessions, actioned from
the sidebar context menu — "Export as Markdown…" sits with the
management actions, above Delete (the destructive action stays last).
- GET /api/chats/:id/markdown (404 unknown): the whole session as one
attachment — text/markdown; charset=utf-8, filename from the slugified
title ("relational-databases-joins-normal-forms.md", non-ascii titles
fall back to "session.md"). Both session kinds export.
- The client is a same-origin anchor download; the route's
Content-Disposition names the file.
- sessionMarkdown's user-facing debut — minted ANCHORLESS (message-id
anchors are extraction provenance, not learner-facing). The engine
joins the core barrel (pure, browser-safe) for its first consumer.
The practice join (owner-approved mid-round): practice tool results are
bare acks — statement, outcome, verdict, and the model solution all
live in the assistant's call arguments. The engine now folds them the
same way the view seam folds cards: Problem blocks render the pose's
statement (solution never leaks at pose time); Concluded blocks render
the outcome as prose ("Solved with hints.") + verdict, then the
pose-committed model solution as a blockquoted reveal via
problemInputForConclude (clean-poses-only, its refusal law). A
dismissed problem concludes as "The learner skipped this problem." —
no reveal. Errored poses render nothing (the failed-tool law).
1427 tests (route pins: headers, slug, anchorless body, 404; engine
pins: pose-time statement, the reveal, the dismissal refusal, the
errored-pose silence); tsc, lint, build green.
The settings Graph section splits into two switches (owner, 2026-10-06):
- EXTRACTION (was the lone "Graph" row): the DATA channel — pipeline,
auditor, prober graph tools. Key unchanged (graph.enabled), label now
names its job; off = the graph stops growing, nothing is deleted.
- GRAPH RENDERING (new, graph.rendering, default on): display-only.
Off = the home's main view is the CHAINS VIEW instead of the canvas;
data, reasoning-chain model, and the pipeline keep working untouched.
The chains home (components/chains-home.tsx): ChainTableBody in flow —
same body as the overlay, same powers (query + sort, selection, the
delete fence with its confirm dialog), but onClose is absent so the
top-bar X does not render (the table IS the page; ChainTableBody now
takes onClose optionally). The chrome survivors float top-right below
the top bar: Import notes (ALWAYS — the entry-point law) + the cost
pill (GraphCostControl, now exported). The composer blob persists over
everything; the table scrolls CLEAR of it via a fixed pb-40 band (text
under a floating box is noise — unlike the canvas, which runs under).
Pick flow mirrors the overlay contract exactly (home lifts the pick
set; selectMode swaps the bar's confirm to Done). The chip relabels to
"Pick propositions" (no "from the map" — there is no map); while
picking, the chip row hides so the floating bar above the composer is
the one voice. Settings resolve rendering through the one core
resolver + renderingEnabled(); read server-side per navigation, so the
switch applies on the next visit home.
Smoke-verified live (headless browser): chains home renders without the
X, import + search + composer clearance confirmed, pick flow checked 2
propositions -> Done -> chip count -> practice session started with the
graph context carried (2 props, 2 chains), settings restored.
Known follow-ups (flagged, not built): after Done the bar morphs to the
table's Clear/Delete management bar while the chip also shows the count
(two voices, same set — the table's own editor law); /chat/new's pick
chip still says "from the map".
Tests: rendering in factory/sanitize/parse/resolve pins, the GraphSection
render pins (Extraction label + rendering row), the home-variant
chain-table pin (no close X). 1414 pass; tsc, lint, build green.
The relational-databases incident, diagnosed and fixed:
TRIGGER — pi's estimator law, adopted exactly: anchor on the last
assistant message's provider-reported usage (totalTokens || the field
sum) and chars/4-estimate ONLY what was appended after it. The anchor
scan skips aborted/zero-usage assistants and STOPS at the newest
compaction marker (pre-cut usage described context the next request
will not carry — pi's distrust rule). The old estimator
(JSON.stringify of every message /4) tripled the true context
(measured: 84,412 'tokens' estimated vs 38,799 provider-reported on
the incident record) — scaffolding, escaped LaTeX, thinking blocks
and app-side details all counted. estimateMessageTokens now counts
wire text only (text + thinking + toolCall name/args; content text
for user/toolResult/custom). The unanchored fallback runs the plain
heuristic over the CUT-HONORED projection plus the additive prompt
term; the anchored path adds nothing (usage already billed prompt
and tools).
OBSERVABILITY — compaction is a visible session-wide state (owner
spec): the transcript-bottom placeholder register gains COMPACTING
CONTEXT… (BrailleSpinner + Shimmer, the THINKING placeholder's twin,
mutually exclusive by construction — the gate precedes beginTurn),
and the sidebar row shows 'Compacting context · <ago>' with the
spinner (sessionMeta outranks busy; compacting-changed joins
turn-started/turn-settled as refresh triggers). Liveness rides the
API like turn-busy: ChatListItem.compacting + compactionInFlight on
both GET /api/chats and GET /api/chats/[id] (initiallyCompacting
covers the navigate-back-mid-compaction mount — the event already
fired), exported through the server barrel.
Also: the relational-databases record rolled back to pre-compaction
(marker, post-marker turns, tree reset; backup in /tmp) for a clean
retest. The summarizer-usage 'gap' was a misread — steps[] was
populated all along.
- settings: compaction.thresholdTokens row on the Agent tab (owner pick)
in a Context group — CommitInput mirroring the graph budget row, hint
resolved through the SAME core resolver the trigger reads; PUT rejects
below the 32k floor with the anti-churn rationale; own section
(compaction) in SETTINGS_SECTIONS + the settings-signal union
- view fold: one marker → two dividers — the minimal MOMENT divider at
the marker (retrying-message register, no rule lines) and the longer
CUT divider emitted immediately before the first kept message
(phase-divider geometry, chip carries '· Nk tok'); both carry the full
summary payload; full history stays visible to the user
- components/compaction-divider.tsx: the dividers + summary dialog
(pure exported body for SSR pins, the delete-dialog pattern; Esc +
Close dismiss, backdrop does not)
- chat.tsx: compaction-changed SSE flips the running state — composer
canSend, onSend/doSend/doSteerOrSend, and both docks' submit + hint
('compacting session…') gate on it (the server's registry mutex is the
structural gate; this is the affordance); dialog mounts beside the
abort dialog
- pins: settings-body (4), view fold (2), divider SSR (4)
- ZOOM: the ⌘±/⌘0 ladder is owned, not stock roles — applyZoom sets the
factor and broadcasts 'lh:zoom'; the renderer publishes --lh-zoom; the
gutter lengths (.lh-lights-gutter / .lh-closed-toggle) divide by it so
their RENDERED size is a physical constant at every zoom — the graph
button stays glued next to the lights, nothing slides under them.
before-input-event catches ⌘=/layout variants the menu's Plus may not
claim; did-finish-load re-pushes on reload; getZoom covers first paint.
The lights themselves stay at construction position — Electron 44 has
NO setTrafficLightPosition (options-only; the first run crashed on
it). HIG-acceptable: native lights hug the corner; the web chrome
compensates around them.
- JOBS SUITE FLAKE, actually fixed this time: the seams are
module-global and the sweep scans every record — a timed-out async
test left a running orphan + late wake that poisoned its NEIGHBORS
(respawned 3 !== 0). New afterEach fence: settle every tracked
running job, unregister aborts, seams reset to inert no-ops — every
test starts from identical state regardless of upstream flake.
Verified: two consecutive full-suite runs 1326/1326.
Owner round 2 (2026-10-05), Slice F:
- USER TEXT: the right-aligned bubble is gone. User messages are
full-width document rows — muted 'You' label, muted quote rail,
full-opacity text. Learner words are content, not a chat-app tell.
- USER THINKING (everywhere): arrives COLLAPSED as a trigger row in the
exact register of the agent's reasoning rows (muted label + chevron +
hover affordance); expanding opens the quote block — rail, 50%
opacity, italics. The inner-voice signature. The ThinkingBlock loses
its shell/border: pure document material.
- QUIZ CARDS: the answer's thinking is the SAME collapsible row, same
placement, just inside the container; the explanation drops to 50%
opacity (upright — italics stays the thinking signature).
- AGENT'S THINKING: the duration bookkeeping is gone (it was live-turn
state that died to 'a few seconds' on remount anyway) — the settled
row reads static 'Agent's thinking', pairing with 'Your thinking'.
Spinner + shimmer stay the working signal while streaming.
- Glance law: user thinking = rail + 50% italic; agent thinking = no
rail; user text = upright, full opacity, 'You' label.
Owner round (2026-10-05): the same act (a human thinking in text) had two
visual languages — the quiz cards' quote block vs the practice container
with a collapsing chevron and raw pre-wrap text that read oversized.
- components/thinking-block.tsx: the quiz quote-block recipe verbatim
(rail + uppercase label + lh-prose at foreground/90) carried by a
faint user-side shell — ml-auto, max-w-[95%] mirroring the user
bubble's geometry, px-4/py-3 rhythm. Never collapsible. Rails reuse
the existing vocabulary: primary = committed register, muted =
working register.
- ThinkingCard becomes the practice adapter: 'Your solution' (primary)
/ 'Your thinking' (muted) — one component, two registers.
- The reading channel LIFTS out of the answered card: chronologically
prior to the question, it renders above it as its own user-side
block under the plain 'Your thinking' label; the in-card inset and
the 'while reading' phrasing are gone.
- lh-card-enter (globals.css): the one-shot 150ms fade+rise mount,
the app's motion register; honors reduced-motion.
The GLM incident (2026-10-04): glm-5.3 sent mode as a JSON-string-encoded
enum ('"recall"') on 19 consecutive problem calls; TypeBox's error never
named the expected values, so the model re-emitted the identical payload
until the dev server was killed. And mid-turn the learner was trapped:
Esc's question/problem rungs only dismissed — the dismiss route 409'd
against the turn-owned record and nothing could stop the running turn.
- healEncodedArguments (practice.ts, the prepareArguments seam): a STRING
where the schema expects a literal union / boolean / null / number /
array is JSON.parse-d and kept when it satisfies the schema. Valid args
pass through untouched (same reference); unhealable values stay for
validation to reject honestly. Wired on BOTH problem and conclude.
- dismissal-park.ts: the dismiss route parks a mid-turn dismissal (202);
the turn's settle path (onFinish, after persistTurn, before endTurn —
the same ordering law as the record write itself) applies it against
the persisted record, only if the dismissed thing really is pending.
- confirmAbort: question/problem discard while busy now ALSO aborts the
in-flight turn; the dialog line says so (busy prop).
No error-result cap (owner ruling). 16 new pins (heal incl. pi-ai
validateToolArguments end-to-end; park registry, apply paths, the 202
route contract).
The map picks no longer glue into the opening prompt as seed text
(owner, 2026-10-04 late). POST /api/chats accepts graphContext
{propositions}; the route resolves the picks against the live graph,
derives the reasoning chains among them (chainsForSelection →
formatChainLine), and writes a graph-context custom marker as
message[0] — before the learner's first user message, in either mode.
- Core: newGraphContextMessage (model-facing content = the
contextualization framing + proposition lines + chain lines;
details = the structured pick); toLlmMessages flattens it to a user
message like every marker, so the model reads it for free.
- View: projects to a user-side data-graph-context part.
- UI: GraphContextCard — minimal, muted, dashed, collapsed by default
(counts only); expand reveals name — statement rows and the chain
lines. Same compact-expandable pattern as the practice cards.
- Home: sends graphContext with the creation whenever picks exist;
the prompt param carries the learner's own words only.
- needsContinuation: a persisted problem-pose ack (terminate:true ended
the turn deliberately) owes no continuation — reloads after a pose no
longer wake a phantom turn; error results stay owed like any
interrupted tool.
- Chat header: practice sessions swap the phase chip for a quiet
Practice tag (no probe→teach arc applies) and hide the coverage ring
(practice never extracts — an ever-filling ring would lie).
- ChatSummary gains pendingProblem (activeProblemOf at projection); the
sidebar meta says 'Problem waiting' for a practice row with a posed
problem. Kind threads through Chat/[id] page.
- Home picker chip gains Clear (picks were otherwise irremovable).
- Sidebar: the three-way kind filter (Teaching/All/Practice, persisted
in localStorage) above the search; two new-session doors (Teaching →
/chat/new, Practice → /chat/new?mode=practice); practice rows carry
the dumbbell badge and 'Practicing…' busy meta.
- Draft: ?mode=practice swaps the copy (title, empty state, 'What do
you want to practice?') and startDraftSession posts kind:'practice'.
- Graph entry (home composer): teach|practice switch on the stats row's
left edge (StatsBar leadingSlot); practice mode shows the propositions
chip that opens the chain table as a picker (its floating bar becomes
'Practice these' — no delete fence); the picks seed the opening prompt
('Propositions from the map:…') while the title stays the typed text.
Practice sessions (owner, 2026-10-04): the marker rides the record and
the normalized summary (absent field reads as teach — pre-practice
records never migrate); POST /api/chats accepts {kind: 'practice'}.
The agent branch, tools, and UI land in the following slices.
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.
STEERING, NOT FOLLOW-UPS — pi-agent-core's two rails, used deliberately:
agent.steer() injects at the next response boundary INSIDE the live run
(one logical turn, the model changes course); followUp() would only extend
an ended run — not built. The same rail job markers already ride
(steeringMode 'all', the 2026-09-26 doctrine).
- POST /api/chats/[id]/steer: 404/400 honest guards; no live turn → 409
and the client silently falls back to a plain text send (the settle
race); live → steerLiveChat hands the message to the live agent's
steering queue AND parks it in the userSteers registry — the stranding
safety net. A steer stranded behind a question termination or abort
stays parked and rides the chat's NEXT run (handOffEligible), the same
deliver-or-requeue lifecycle as job markers. Reconciliation at settle:
delivered ids drop (reconcileUserSteers).
- Client: steer(text) queues + POSTs without flipping any turn state;
the drain's verbatim message events upsert the real user card by the
client-minted id (steer_<localid>) and the indicator fold drops the
line. canSend no longer gates on turnInFlight — Enter mid-turn steers;
Enter with an empty composer still stops; the square button stays Stop.
- The queued-steer indicator (owner spec): quiet one-liners above the
composer shell — right-aligned to where its corner rounding starts,
truncated with an ellipsis, fixed max width, ~half opacity, no
background, no container. Tunable later.
- Verified from pi-agent-core source: steering drains per completed turn
inside the loop; injected messages emit message_start/end verbatim;
finishTurn {action:'end'} (our question stopper) exits BEFORE the
steering poll — hence the registry.
- {type:"dismiss"} input event: parse + pure appends with the answer-event
orphan discipline (dangling-call 400, kind match, answer must be dismissed)
- POST /api/chats/[id]/dismiss: the no-turn composition — write-ahead append,
409 while a turn runs, emits record-changed (new bus word; idle views sync)
- needsContinuation: an all-dismissed trailing batch demands nothing;
endsInDismissal = the derived pause predicate (tail-based, no latch)
- wake pipeline: while the record ends in a dismissal the pump holds every
delivery in the steer queue — the user's next send is the wake (until the
text-turn steer claim lands, settlement re-pump delivers right after it)
- session-core.dismissQuestion: optimistic skipped card + silent POST;
chat.tsx confirmAbort question-mode swaps submitAnswer for it; ESC-abort
popup everywhere (AbortTurnDialog) with popup-Esc claimed via state gate
- parallel question calls dismiss call-by-call (results bind by id)
Root-cause find: the sidebar 'highlighting' on ←/→ was :focus-visible —
pointer-clicked elements match it only after a keypress, so any key in
the quiz lit the still-focused session row. The arrows never moved
focus. Fix: the no-ring law (owner) — *:focus/:focus-visible outlines
removed app-wide in globals.css; surfaces keep their own affordances
and the dock's keyboard walk keeps its ring utilities.
Slider repairs:
- mousedown-preventDefault had killed click + drag in Chromium —
removed (with tabIndex -1); the slider is natively interactive again,
and the ring law makes its focus invisible anyway.
- Focused arrows step the STOP index on the input itself (native would
step 1% and snap straight back — dead keys); the global handler
skips INPUT targets, so no double-step. Enter still submits.
- Empty side: var(--muted) matched the card and vanished — now #141414
(the owner's main-background pick), and the pill carries a border
again (border-border).
- Dashed tick per snap stop below the bar, inset by the thumb's
half-width so each tick sits under the thumb's center at that stop.
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.
The chains-overlay TopBar had dead drag zones (counts, around search,
close gap) — cause: app-region subtraction resolves by DOM ORDER, not
z-index; the graph chrome's lh-no-drag column came later in the DOM and
punched holes through the overlay's drag band despite sitting visually
underneath (z-40 vs z-10).
Two laws, per the owner's standardization ruling:
- AUTO CARVE-OUT (globals.css): inside any .lh-drag band, every native
interactive element (button/input/textarea/select/a/summary/
[contenteditable]) is automatically a no-drag island — draggable
anywhere that is not a clickable element becomes structural. Manual
lh-no-drag is only for clickable non-natives.
- THE OVERLAY DOM LAW: a surface containing a TopBar that covers other
content must be the LAST child of its container — ChainTableOverlay
moved after the chrome column (verified live: last child, after the
column; auto-carve rule compiled; search/close/sidebar-toggle all
still click).
- dialog: optional 'Anything else?' textarea (typing-only, 2000-char mirror)
- API: body gains note? — trimmed, whitespace-only omitted, hard 400 over 2000
- pipeline: note persisted on the set and threaded to both agent stages
- agents: THE LEARNER'S NOTE prompt block (present only when set) + the
weighed-never-owed stance pinned in both doctrines
- fix en route: doubled THE PARAMETERS heading in both prompts since slice 3
(renderParams owns the heading; the literal blocks removed)
Rulings: each problem generation is its own visible dock job (the
extraction per-chunk pattern), and practice shares extraction's
semaphore — one process-wide 3-permit pool for ALL background LLM
jobs; total background LLM concurrency bounded at 3.
A. SHARED POOL: new src/agent/subagents/bg-llm-semaphore.ts —
globalThis __lhLlmSlots, PERMIT_COUNT 3 (the ruling), the
fresh-take/handed-off-permit deadlock discipline preserved for
BOTH callers. extraction.ts's withExtractSlot is now a thin
wrapper (job-row signal → withBgLlmSlot); __lhExtractSlots and
__lhPracticeSlots both die. The partitioner now acquires a permit
too (it's a background LLM job — it didn't before).
B. REGISTRY ROWS: the aggregate setId:generation / setId:grading rows
are replaced by setId:partition (phase partitioning, label
'Partitioning — title'), setId:gen:problemId (queued→generating,
'Problem i/N — title'), setId:grade:problemId (queued→grading,
'Grading i/N — title'). Each row carries its OWN abort controller
and its OWN usage (one PracticeStepLog per row — the aggregate
partitioner/generator/grader folding dies). Row key = feed id =
abort key (the dock dup-key law).
C. ABORT SEMANTICS: abortPracticeRow(key) routes by shape — gen row:
THAT problem only (error 'Aborted', siblings continue, set settles
ready with one errored problem); partition row: the whole run;
grade row: that grade drops (answer kept, re-submittable).
abortPractice(setId) composes from row aborts + the synchronous
persisted marks (a set-abort's verdict stands before the settles
re-read it — the settle step keeps an existing error status).
/api/activity/abort kind practice now takes the ROW key (charset
admits ':').
D. FEED + DOCK: ActivityPractice/FeedPractice carry per-row identity
(id = row key, problemIndex/problemTotal); dock blobs render per
row, labels carry the i/N; the aggregate row is gone.
E. Boot sweep unchanged at set grain; the live-row check is now a
key-prefix scan (any live row of the set protects it). The
updatedAt heartbeat covers the new row scheme as-is.
Tests (20 in the pipeline suite): per-problem row shape/uniqueness/
i-N labels (partition row frozen behind a gated partitioner; per-row
usage ISOLATION pin — b's log never shows on c's row); abort-one-gen
(sibling completes, set ready with one errored problem); partition-
row abort; queued grade-row abort (dead-ticket path: answer kept,
re-submittable, sibling lands); shared-pool identity + contention
(practice queues behind extraction-style holders); registry/dock
feed mapping. Test isolation hardened: unique per-store set ids,
afterEach registry/lock/pool reset (a timed-out test can no longer
poison the rest).
architecture.md: jobs-pattern paragraph rewritten (per-problem rows,
shared pool, abort routing).
1149/1149, tsc 0, lint 0, build 0.
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.
components/practice-dialog.tsx: portal dialog (delete-session shape),
three dial rows (SettingSlider markup, anchor text as display:
flashcard-competition / near the lesson-far transfer / theory-
application, defaults 0.5), session picker (generic mode, /api/chats)
or preselected lock (right-click path); 400 empty_selection renders
'This session has no propositions yet', 503 renders the server line +
Open settings deep link (useSettingsDialog, zero-keys precedent).
Pure body exported for SSR pins.
components/practice-list.tsx: rows (title, date, ready/total problems,
graded count, status word; destructive tint on error), click-through,
delete affordance → portaled confirm (DeleteSetBody: no mode checkbox
— a set has no preserve-graph question), 2s reveal-poll while any set
is partitioning/generating. components/practice-exam.tsx: numbered
problems; generating stubs (BrailleSpinner + 'Generating problem…',
propositions never surface); ready statements/solutions/rubrics/
feedback as MessageResponse markdown with restoreMathEscapes at the
display seam; per-problem textarea + useDictation instance with the
DictationBar rendered only on box focus; one Submit answers POST per
set; graded reveal (verdict chip, feedback, model solution, rubric)
with read-only answer; polling while not ready OR grading-live
(activity feed practice rows — dropped grades terminate the poll).
app/practice/page.tsx (force-dynamic, listPracticeSets) + app/practice/
[id]/page.tsx (getPracticeSet + notFound). Sidebar: quiet Practice nav
row under New session (GraduationCapIcon, active on /practice) +
'Practice this session's propositions' in the session right-click
below the hairline, above CoverageMenuBlock — opens the dialog
preselected.
tests/render-practice.test.tsx — 15 pins: dials + anchors, picker
selection gating, preselected lock, empty-selection line, 503 deep
link, list rows/counts/empty, delete confirm + inline failure, exam
generating (no proposition text, no answer boxes, no submit), ready
(markdown statement, gated submit), graded reveal + read-only + sibling
answerable, failed problem/set lines, math healing. 1129/1129, tsc 0,
lint 0, build 0.
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.
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
The reported bug (2026-09-28): every chat ran glm-5.3-flash regardless of
any pick, while composer and settings UIs agreed with each other and lied
about the run. Root cause: a fossil default-settings record (settings.json
chat section, frozen pre-slots-wave) was stamped verbatim into every new
chat at birth by the new-session flow, and resolveConversationSpec prefers
the per-chat record over the strong slot forever.
Owner redesign — models are per session, always; the strong slot is the
new-session default:
- POST /api/chats stamps the newborn verbatim from the strong slot
(model + thinking level); no client settings PUT on create anymore
- the composer control is dual-bound: in-session it reads/writes the
session's own record (GET /api/chats/:id, PUT .../settings); on the
draft surfaces it binds the strong slot (picking there = changing the
default)
- thinking levels follow the identical mechanics (confirmed by owner)
- the zombie default-settings system dies whole: useDefaultSettings,
/api/settings/defaults, src/store/default-settings.ts, the chat
section of UserSettings (sanitizer strips fossil copies), the legacy
default-settings.json fold, and DEFAULT_SETTINGS_FILE
- catalogDefaultsFor collapses to the strong slot alone
- thinking display for a legacy unstamped session resolves exactly like
the engine (session ?? strong ?? model ladder)
Tests: new chat-birth-stamp suite pins the stamp, the unconfigured boot,
the unset level, and the copy-not-live-view regression; defaults pins
rewritten strong-only; legacy-migration suite deleted.
Server: ui-stream.ts is verbatim pi AgentEvent passthrough (SSE data
lines + [DONE]); only app-level events are the loop-level error and
turn-settled terminal; onError carries raw provider text (owner ruling).
Client: useChat + the AI-SDK transport replaced by components/session-core.ts
(SessionModel: 409 identity-retry, optimistic c_ appends, absorbed()
adoption, needsContinuation, shared consume for POST + reattach) and the
session.ts hook; the golden-master grouping canon moved client-side into
core/view.ts (projectView); scanners read the native record (phaseAt3Lh,
danglingQuestionOf, graph toolParts, plan-blocks, runner, extraction).
Coverage: the native message is the unit (fork 3) with the call+result
inseparability bond rule; view-line-less messages (markers, error
results) are invisible to coverage and never checkpointed.
Deletes: chat-transport, projection, ui-message, their tests (ported to
tests/session-core.test.ts), and the ai + @ai-sdk/react dependencies —
the Vercel SDK is fully out of the tree. Wire pins in chat-flow rewritten
onto the AgentEvent dialect (eventsOf parser).
Gates: tsc clean, 994/994 tests, eslint clean, build green; live smoke
on PORT=3004 (verbatim wire → native record).
The chat record is now pi-native agent history (LhMessage[]), the AI SDK's
UIMessage transcript contract is gone from the server, and every client turn
arrives as ONE explicit input event.
Core (new):
- src/core/native-messages.ts — the LhMessage taxonomy (user/assistant/
toolResult + lhMarker customs: verdict, probe-handoff, subagent-steer,
subagent-abort, plan-block), constructors, and toLlmMessages — the single
model-facing seam (convertToLlm): strips ids, flattens markers to user
text, drops UI-only plan-blocks, synthesizes INTERRUPTED results for
dangling aborted calls, never throws.
- src/core/projection.ts — projectNative: the one-way native → UIMessage
projection (consecutive assistant+toolResult runs group into one
assistant view message; toolResults attach backward by toolCallId;
markers project to data parts; plan-blocks splice after the last
successful plan_init). Every existing consumer reads through it.
- src/core/input-events.ts — the ChatInput union (text/answer/verdict/
continue), parseChatInput validation, and inputAppends (answer targets
the dangling question call or 400s; verdict = optional note user
message + native verdict marker).
Server:
- chat-handler.ts — the turn root takes {chatId, input}; write-ahead
persists input appends + claimed steers before the turn; claimSteers
coalesces the whole queue with the handler's steerDelivered filter as
the dedupe seam; teacher-vs-prober branch on hasProbeDone.
- turn-service.ts — persistTurn stamps ids, drops clean question
placeholder toolResults (the record ends at the unanswered call),
emits plan-block markers idempotently (plan-blocks.ts rewrite), and
persists abort partials.
- ui-stream.ts — PiTurnSpec takes the native base; the wire dialect is
unchanged. jobs.ts steers are native markers (steer_<jobId> dedup ids);
waitForIdle reads danglingQuestionOf. teacher-agent.ts wires
convertToLlm: toLlmMessages. record-adapter.ts DELETED wholesale.
- GET /api/chats/[id] serves messages: projectNative(record.messages).
Client:
- chat-transport.ts — the 409 retry is IDENTITY (re-POST the same event
body after the turn settles — the event was never persisted
pre-acceptance; the merge logic is gone). The transport consumes the
stashed input event via prepareSendMessagesRequest and throws loudly on
a missing stash.
- chat.tsx — every send call site stashes exactly one ChatInput event
(text, answer, verdict, continue) before invoking the SDK send.
Tests: full native migration (chat-flow rewritten to input events and
native record pins; jobs/extraction/demos/graph/usage/plan-blocks/
probe-done/reattach-transport converted; record-adapter.test.ts deleted;
chat-transport tests pin the identity retry). 982/982, typecheck, lint,
build all green.
Two owner rulings (2026-09-26):
READING FONT — the free-text CommitInput is retired; a searchable
dropdown of the user's INSTALLED fonts over model-menu's exact
fuzzy + arrow-walk + Enter machine (FontMenuList in model-menu.tsx,
fed by window.queryLocalFonts in the desktop shell):
- The DEFAULT row (Source Sans 3) is a fuzzy CANDIDATE, not a ghost:
fontMenuResults (pure, pinned) matches it on empty/source/sans/
default queries only, so an unfiltered list opens with the default
first and a searched list drops it honestly — Enter can never pick
a row that isn't on screen. Picking it writes fontFamily null
(empty = Source Sans 3 semantics preserved).
- The enumeration is CLICK-bound (queryLocalFonts requires user
activation — it cannot be a mount effect), deduped to unique
families, the factory family excluded (the DEFAULT row is it),
cached for the section's life; a failed enumeration falls to the
curated list rather than an empty picker.
- Dev-browser fallback is honest, never fabricated: queryLocalFonts
absent → a small curated list (the app's own knowns + ubiquitous
macOS families) with the footer 'Common families — your installed
list needs the desktop app'. The manual escape lives inside the
menu: a custom-family input + Set commits any family name the
enumeration didn't surface (the old free-text input's heir).
- composeFontFamily (pure, pinned): null/blank → null, else verbatim.
- The trigger gets a Hint (tooltip wave convention); the row's
empty-state hint keeps reading 'Empty = Source Sans 3'.
⚠ electron/main.cjs — one line, FLAGGED FOR PLATFORM: 'local-fonts'
joined the allowedPermissions set (the Local Font Access grant the
picker needs in the desktop shell; the loopback-origin discipline is
untouched).
EMPHASIS INKS — the factory #ee928c/#6fa9d6 are gone: both globals.css
theme blocks now default --lh-ink-strong/--lh-ink-italic to
var(--foreground) — unset emphasis renders the regular text color.
Explicit hex overrides ride unchanged (the appearance seam was already
null-honest); the rows' empty-state labels now say 'same as text'.
Tests (+16, 983 total): fontMenuResults default-row candidacy +
filtering, composeFontFamily null/blank/verbatim, the curated roster
(no factory-family duplicate), FontMenuList SSR shape (default row,
families, effective highlight, custom escape, loading state), the
trigger-replaces-input pin, and the globals.css factory pin (no old
hexes in the declarations; var(--foreground) ×2 in both blocks).
Live-verified on :3001 (dev browser — the curated-fallback path):
dropdown opens with the default row + curated list + honest footer;
search filters ('serif' → CMU Serif only); picking Georgia lands
record + --font-prose live; the default row unsets (var cleared);
the custom input commits Baskerville verbatim; Esc closes the menu
WITHOUT closing the dialog. Ink: strong/em computed = rgb(230,230,230)
(the text color) at factory; #ff0000 through the dialog renders red
and clears back cleanly. The desktop queryLocalFonts path cannot run
in this environment — the seam is click-bound + cached with the
failure fallback, and needs one desktop-shell smoke.
Contract-fix interlude (2026-09-26), two bugs from UI's prompts-overhaul
(6edc220):
(a) ORPHAN-400 TRAP: an ON switch whose override file dies orphaned the
record; every agent-section write then 400'd (ON-must-have-file) while
the disabled toggle couldn't reach the OFF escape hatch — a user stuck
unable to write settings. Verified live on 3004 before the fix.
Two-layer fix:
- DELETE /api/settings/prompts/[...segment] now clears the record's
switch in the same operation (switch-first order: a crash between the
two leaves the valid off+file state, never an orphan). Response gains
switchCleared (additive). Overrules the 2026-09-19 switch-survives-
delete note — the delete route and the record agree by construction.
- PUT /api/settings/[section] agent writes HEAL an orphaned ON to OFF
(key dropped) inside the write — never a 400 (defense in depth: disk
wipes outside the app can still orphan). A valid ON with its file
stays valid; the invariant: a user can never get stuck unable to
write settings, and the record never contradicts file reality for
long. Heal logs at info.
(b) TEST WIPES REAL DATA: tests/prompt-overrides.test.ts wiped
data/prompts/ (REAL user override files) in after() and its empty-list
case — every npm test killed actual overrides and orphaned their ON
switches (feeding trap a). Isolated via the established LH_DATA_DIR
seam (mkdtemp + env set before the first config.ts-loading import; all
app-module imports dynamic). after() now snapshots the REAL repo
data/prompts before/after and fails the suite if it changed; the seam is
pinned (writes land under the temp dir).
Pins: delete-clears-switch (incl. idempotent no-op), the exact repro
(disk-level wipe → mirror-the-orphan + tool toggle → 200, healed key,
rest of the write landed), vanished-fragment ON heals, valid ON stays.
Live: the 400-forever write now lands 200, heal info-logged, record
clean.
Gates: 967/967 (+5), typecheck, lint, build green.
Owner ruling 2026-09-25. runPiTurn executes a turn decoupled from any HTTP
response: an unbounded memory-only event log (finite by construction; the
30-min liveness TTL bounds the pathological case) plus a subscriber set.
The POST /api/chat stream and the new GET /api/chats/[id]/stream are both
subscribers — identical SSE dialect; late subscribers get FULL replay
(starting at the start chunk) then live until finish + [DONE]. A view
cancelling (reload, navigate) now detaches ONLY — never aborts the turn.
The remote kill is the abort channel: POST /api/activity/abort gains
kind: "turn" (id = chatId; not_running 409 like jobs); handle.abort sets
sawAbort + agent.abort(), every subscriber closes cleanly, the partial
persists through the usual settle (onFinish runs once, then endTurn,
then unregisterTurn LAST so a view that finds nothing finds the record
current). No running turn on reattach: the cheap terminal — one
{"type":"turn-settled"} event + [DONE] (settledTurnResponse).
The live smoke caught what the unit suite had masked: the agent_end
close() had been left behind in the restructure — ReferenceError every
turn, steered into the error path; fixed and pinned (clean turns must
never emit an error chunk; finishReason stop present).
Owner ruling on the empty graph (honesty principle — dead controls are
lies): the top-chrome actions row now gates per control.
- Import Notes renders ALWAYS (graph enabled) — the one action that
works on an empty graph, the entry point itself. It used to hide with
everything else, leaving the empty state a dead end with no entry.
- Search + settings render only when the snapshot has nodes, and their
appear-once mount carries a deliberate 200ms fade (graph-chrome-fade-in,
globals.css — the settings-pop register, reduced-motion appears
instantly). Computed animationName verified live.
- The row's body is now the exported pure GraphChromeRow (the
DeleteNodeBody pattern) — the no-match note's truth (visibleIds +
snapshot) stays upstream, computed by the wrapper.
- The empty-state placeholder copy is LEFT AS-IS (SCOPE rule: it already
has copy — icon, title, and the session-mapping paragraph).
Pins (tests/render-graph-chrome.test.tsx): empty → Import Notes present,
search/settings absent, no fade carriers; populated → all three, exactly
two fade carriers; the no-match note follows its upstream truth. SSR of
GraphChromeRow both ways.
Live-verified on :3001 with scratch data: empty graph → Import Notes
only + placeholder intact; a real import through the button (file input
via DataTransfer) → nodes land, search + settings fade in (computed
chrome-fade-in on both carriers); wipe → reload → back to Import Notes
only (the reverse path). Scratch graph restored byte-identical.
932/932, tsc/lint/build green.
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.
Five small fixes (owner refinement round, 2026-09-24):
1. RING POPOVER OPENS DOWN: the ring lives in the chat top bar —
the window's top edge is right above it, so the upward popover was
clipped. top-full mt-1.5 now: it drops below the ring.
2. SESSION MENU ORDER FLIPPED: Rename/Delete first, hairline, then
the extraction coverage block + Extract at the bottom — the info
reads after the actions (was the reverse, the block on top).
3. UNIT TYPED OUT: the sidebar block reads "2k tok uncovered" now —
the tokens unit is explicit (owner: "tok").
4. MARKDOWN LIST INDENT: streamdown ships list-inside with no
container padding — top-level markers sat ON the text edge (only
nested lists got their pl-6 via [li_&]). .lh-prose :where(ul, ol)
{ padding-left: 1.5rem } indents both list kinds as blocks, 24px —
the same measure as the nested-case pl-6. Unlayered CSS beats the
utility layer; :where keeps specificity zero.
5. THINKING LEVELS LOWERCASE EVERYWHERE: the composer pill and setup
dialog rendered THINKING_LABEL (Off/Minimal/…/Max) while every
menu body renders the raw lowercase level — the map is now
lowercase, the level string IS the display form, everywhere.
Live-verified on :3001: list content inset 24px vs paragraphs
(padding-left computed 24px, nested fine); the composer pill reads
"low" with options low/high/max; menu order Rename → Delete… →
hairline → block. 901/901.
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.
Owner-locked extraction-UX redesign (dispatch 2026-09-24), part 1 (data
layer; the UI chunk follows).
THRESHOLD -> SETTING
- config.ts's EXTRACTION.chunkTokens dies; the default moves into
FACTORY_SETTINGS.graph.extractionChunkTokens (4096, the old constant's
value). GraphSettings gains the optional field, Required at resolution
via the standard ?? FACTORY pattern — pre-redesign records resolve the
default cleanly.
- FLOOR 500 (the implementer's call, documented on the field): every run
pays the graph-carrying prompt overhead out of the write channel's
money — a smaller budget fires penny-batches that cost more per
extracted token than the content is worth. The sanitizer CLAMPS up
(bd68486 clamp-never-reject discipline: fractions floor, junk drops);
the settings PUT REJECTS below-floor with the rationale in the message.
No ceiling: a large budget just delays extraction — the user's stated
cost/thoroughness tradeoff, not a misconfiguration to police.
- The trigger (turn-service.triggerExtraction) takes the budget as a
parameter — chat-handler passes effective.graph.extractionChunkTokens,
the fresh per-turn read, same as every other gate.
- The pipeline's BATCH side threads through the same number:
ExtractionPipelineDeps gains an OPTIONAL getChunkTokens getter with the
factory default (Teacher's 24 test fixtures stay untouched; the two
call-site lines inside extraction.ts are the only cross-band edits,
flagged). chat-handler + backfill/import-notes wire the live
effectiveSettings — trigger and batches share one budget, always.
PER-SESSION COVERAGE READ
- sessionCoverage(chatId) (server/chats.ts): the chat view's ring-meter
block — totalMessages/coveredCount/uncoveredCount/coveredTokens/
uncoveredTokens (the same coverageOf derivation /api/graph/coverage
serves, scoped to one chat), running (chat-kind extraction registered
for this id), and lastFoldedAt (the chat's newest coverage row's fold
— a cheap single pass; null before any fold). GET /api/chats/[id]
carries it as coverage: {...}; unknown chat -> null (the route 404s
first).
- /api/graph/coverage (the all-sessions list) compiles untouched — the
per-session read is a scoping of it, not a supersession; the UI's
sidebar/ring chunk decides which surface it consumes.
Tests: factory/sanitizer pins (clamp-up, fraction-floor, junk-drop),
resolution pins (absent -> 4096, stored 2000 wins), settings-body pins
(valid/absent/fraction/below-floor 400/junk 400), sessionCoverage pins
(derived split, newest-fold scoping, cross-session isolation, running via
the registry, null on unknown chat). 893/893.