src/agent/subagents/practice.ts: createPracticeAgents(deps) with the
ExtractionModels mirror (per-stage engine overrides + engineFor +
providerKeyMissing + refuseIfStagesUnready, all three roles strong-tier).
Generator doctrine = the owner's problem-engineering framework verbatim
(minus Sources) + Part III wiring section per the green-lit amendments
(FULL SET input with minimal-use doctrine; degradation never in
meta-commentary). Partitioner: true-partition doctrine, validate via
chainCoverage + exactly-once + unknown-ids, error-back retries through
runSubmitAgent's new validate hook (ToolInputError → error tool result).
Generator: echo law (chains set === target). Grader: verdict union +
nonempty feedback (schema-enforced). renderChains/renderPropositions/
renderParams/renderPreviousProblems. BACKGROUND_MODEL_ROLES extended with
the practice trio (settings override symmetry). runSubmitAgent gains the
validate seam (null = accept). 16-test faux-engine suite incl. retry
paths, refusal gates, neverSubmitted.
runSubmitAgent moved verbatim from extraction.ts to src/agent/subagents/
submit-agent.ts (exported; extraction imports it — zero behavior change,
extraction suites green untouched). New optional prepareArguments seam:
extraction wires its healStringifiedArrays heal; default is identity
pass-through. ModelRole += partitioner | problemGenerator | grader;
ROLE_SLOT pins all three to strong (quality-critical trio, plan default).
Note: BACKGROUND_MODEL_ROLES (per-role settings overrides) deliberately
NOT extended — settings-surface decision, flagged for the planning
session.
PRICES must stay a generated static table (core is client-safe; the
renderer folds costs and reading the catalog JSON needs fs), so the
forgetting-the-sync failure mode is guarded instead of eliminated.
- sync-pricing.ts: buildPrices() extracted (the pure catalog walk),
exported for the test; main() now guarded by an entry-point check so
importing the module runs nothing
- tests/prices-sync.test.ts: asserts PRICES equals EXACTLY what the
script would generate now — unpriced new models, drifted prices, and
keys gone from the catalog all fail with 'run npx tsx
scripts/sync-pricing.ts'. Verified it fires by deleting the
deepseek-v4.1-flash entry (1 unpriced, actionable message)
- sync script's embedded header template corrected to match prices.ts
(no bare-slug legacy claim) + drift-guard note, so the next
regeneration keeps the honest docs
Costs were rendering '—' for tonight's extraction runs. Root cause: the
pi family bump (df1328e, 0.85.1 -> 0.87.1) added deepseek-v4.1-flash to
the shipped catalogs, but scripts/sync-pricing.ts was never re-run after
it — src/core/prices.ts was still the 2026-09-25 generation. The design
itself is sound and matches the owner's expectation: nothing stores
dollars; records keep tokens + model string; callCost infers at read
time from PRICES[model]. So the re-sync retroactively re-prices every
run the moment the entry lands.
- npx tsx scripts/sync-pricing.ts: 1495 keys (+314/-173 lines);
openrouter:deepseek/deepseek-v4.1-flash = 0.15/0.6/0.003 (matches the
catalog the picker serves)
- glm-5.3-flash price drift (0.075/0.25 -> 0.15/0.5, cacheRead 0.015 ->
0.05): four pinned-cost tests re-derived (usage, cost-summary via
callCost, activity-dock, stats-bar)
- prices.ts header: dropped the false bare-slug-legacy claim (the table
has zero bare keys; sync emits provider-qualified only) and noted the
forgetting-the-sync failure mode
Observed live: the auditor looped find for a very long time, never touching
list_sessions/list_propositions. Root cause was the prompt: its only
procedure text was 'For each proposition, search the graph (its key words,
its synonyms, its phrasings)' — a per-proposition find loop with no
starting point, no sense of the space's extent, and no termination rule.
And find returned id+name+statement with no session pointer, so results
never composed with the listing tools (owner diagnosis, 2026-09-29).
- AUDITOR_INSTRUCTIONS now prescribes the process: (1) list_sessions ONCE —
the graph is small and finite, this shows its entire extent; (2) read
plausibly-related sessions with list_propositions (full text) — every
realistic candidate seen in a few calls; (3) find as the targeted sweep;
(4) SUBMIT once related sessions are read — never re-search seen ground
- find hits now carry 'born in: <session id>' — results point at sessions
the auditor can read whole (legacy no-provenance nodes name none)
- identity doctrine untouched: equivalence bias, linkTo mechanics,
verdicts-only deliverable
Diagnosis on tonight's data (4 sources, 173 nodes, 216 edges): supports is
62-83% of edges, topology is hub-and-spoke (34/94 libertarianism nodes are
degree-1 leaves), hub edges read as essay-flow not logic, 33-41% of nodes
are degree<=1 across sources. The old doctrines asked for exactly this:
'every distinct proposition' + 'lonely nodes = failed extraction' +
'COMPLETENESS... omitting is the main way extraction fails'.
Owner rulings, prompt-only (data model untouched, both prompts):
- strict reasoning bar: a proposition earns its node by being a premise,
step, or conclusion of an argument the source actually makes; isolated
facts, name-drops, asides, trivia, example enumerations are dropped
- supports tightened: ONLY explicit grounding acts in the text, rare
exception, genuinely argumentative relations are almost always follows
- completeness inverted: precision over recall, WHEN IN DOUBT OMIT,
never connect merely-related propositions
- MUST-connect clause dropped; corrections of misconceptions stay in
(argumentative by nature)
Identity doctrines untouched (established-terminology names, MERGE YOUR
OWN OUTPUT, aliases) — zero near-duplicate statements proves they work.
A LINEAR 0–1 multiplier of Edge width (default 0.5, dead-center at step
0.1 — 11 stops like the ladder rows but ratio-valued), grayed out by the
supports-edges toggle (the Dilation dependency pattern). Renderer splits
supports shafts into their own Path2D and strokes a second pass at
edgeWidth × supportsWidth; arrowheads stay full-geometry in the one shared
path (owner pick: shaft only). A true 0-width stroke is undefined on
canvas — the 0 end floors to a 0.5px on-screen hairline; hiding remains
the toggle's job. Sanitizer: real numbers clamp to [0,1], everything else
resets to 0.5; clamps pinned in render-settings.test.
Off hides supports edges from the canvas: the renderer skips them when
building the shaft/head paths (draw-only — the physics sim still feels
them and detail cards still list them; settings never shift the graph).
Server-persisted with the rest (render-settings.json); sanitizer takes
real booleans only, anything else (missing key, legacy file) loads ON —
today's look. Toggle row styled as the Background-colors row; sanitiser
pinned in render-settings.test.
The persisted (chatId, sourceId)→node table was a shared namespace across
independent chunk/batch jobs: each chunk's extractor mints local ids fresh
(p1..pN), so later chunks collided with earlier ones — propositions silently
skipped as 'already resolved' (coverage checkpointed anyway: permanent silent
loss) and edges resolved onto the wrong nodes (the 'everything links to
everything' mush). Owner ruling 2026-09-29: mergeFindings resolves THIS job's
ids in memory, nothing persists between jobs; cross-job identity is the
auditor's linkTo verdicts alone.
- contracts: NodeMapping dies; NotesSource.contentFp — sha256 of the text at
the last run, the re-import guard (unchanged re-import = TRUE no-op, no
model calls, no usage; changed text re-runs; legacy unstamped sources
re-run once)
- merger: job-local resolution map, in-submit duplicate ids skip once,
edges resolve extractor-local ids only
- ports/store: appendMapping/readMappings, LEDGER_FILES.mappings,
rewriteLedgers.mappings gone (stale data/graph/mappings.jsonl just stops
being read)
- session-clusters: birthSessionOf is provenance-only — the legacy
mapping-line fallback for provenance-empty notes nodes dies
- deletion: no mapping rows to strip; stats lose mappingsRemoved
- replay-layout batches nodes by first provenance point
- graph.test pins the new independence semantics (re-merge re-creates;
duplicate ids within one submit resolve once); extraction tests pin
per-chunk merges + the fingerprint guard
The reveal rule (c902bc6: a run leaving the feed means its merge is on
disk, so refetch immediately) was tracking chatId sets — fine when one
row meant one source, but per-chunk jobs made it re-batch the reveal:
chunks finish one by one, each row leaves, but the source's chatId stays
present until its LAST chunk drains, so a big import showed nothing
until it fully completed. The exact batching bug the rule exists to
prevent.
Track by row id (the registry job key) in both the snapshot-reveal poll
and the per-run costs refresh — a chunk row disappearing IS that
chunk's merge landing.
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.
Hypothesis under test by the owner: coherent extractions turn to mush
after merging because the auditor minted too many bridging edges of its
own — inference, not identity, and out of the auditor's scope. The graph
now connects across sources solely through identified equivalences;
relations between propositions come from the extractor alone.
- auditSchema drops 'edges' (submit_review = links only); the submit-seam
required-field doctrine reworded per tool
- AUDITOR_INSTRUCTIONS: the BRIDGING EDGES section dies; deliverable is
the identity verdicts only
- AuditVerdicts drops edges; ExtractedEdge endpoints are extractor-local
ids only; mergeFindings stops consuming verdict edges and its resolve()
drops the raw-node-id branch (dead — the extractor never sees graph ids)
- extractor untouched: submit_findings keeps propositions + follows/
supports edges, required as before
- tests: 17 submit fixtures + 9 typed verdict literals slimmed to links
- docs: architecture.md division-of-trust + pipeline diagram updated
The live import surfaced it: a multi-chunk notes source renders N feed
rows, and the dock keyed every one by run.chatId (the SOURCE id) —
React's duplicate-key error. The activity feed also dropped run.part
entirely, so all chunk rows showed identical titles.
- ActivityExtraction/FeedExtraction gain a unique row id (source id, or
'src#i' for chunk jobs) and part; the feed title carries the '· i/N'
suffix like the graph status route
- toBlobs keys extraction blobs by the row id; chatId stays the SOURCE
id (identity for links)
- abort sends the SOURCE id for extractions — aborting any chunk row
kills the source's whole fan-out (the new source-wide abort), and the
abort route's id charset rejects '#' anyway
- new pin: one source, two chunk rows → two blobs, distinct ids, same
chatId; dock fixtures updated
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.
Owner ruling 2026-09-28: the auditor has no control over the
extractor's output and never alters it, so re-emitting the full
findings per submit was pure payload cost — the retry collapse the
schema slim attacked, halved again. submit_review is now links
(local proposition id -> existing graph node; EQUIVALENCE BIAS
carried in the linkTo description) + bridging edges ONLY; the
merger joins extractor findings + verdicts.
mergeFindings(chatId, findings, verdicts, ...) — propositions come
from findings verbatim, links join by id, edges emit extractor's
then auditor's through one dedup-preserving emitter. The old echo
form hid an accidental drop channel: an auditor that omitted a
proposition from its echo silently dropped it from the merge.
Verdicts-only removes it structurally — extractor output always
fully merges (tests updated to full-merge expectations).
AuditResult/AuditedProposition -> IdentityLink/AuditVerdicts;
audit() logs links/bridges; batch view renders anchorless like
transcriptView; auditor prompt rewritten to the verdicts-only
deliverable; architecture.md division-of-trust updated.
OpenRouter log diagnosis (owner, 2026-09-28): extraction time was
dominated by tool-validation retries. The failing call had the whole
propositions array emitted as ONE JSON-STRING ('propositions.0: must
be object'), a truncated placeholder artifact inside it, and
word-salad statements late in the array — payload-size collapse.
Field audit: 'evidence' was never persisted anywhere and its
faithfulness-checking consumer died with the fully-trusted ruling;
'messages' fed messageId granularity that NOTHING downstream reads,
and the model sent [] anyway (chat-level provenance in practice).
'aliases' stays (optional now): the auditor's find tool globs them.
Schema: propositions = {id, name, statement, aliases?}; edges =
{from, to, type}. Prompts lose the evidence/messages doctrine and
[id]-anchor guidance, gain a compact faithfulness line;
transcriptView renders anchorless. The auditor echo instruction
drops the evidence/messages mention, so its payload halves too.
Heal: healStringifiedArrays on the submit tool's prepareArguments
(pi-agent-core's sanctioned pre-TypeBox shim) parses a stringified
propositions/edges back into arrays — the LaTeX-restore doctrine:
heal weak-model wire behavior at the seam. Test drives the real
agent-loop seam with a stringified submit.
The old refresh trigger was the some→none transition of the
/api/graph/status poll — with parallel runs under the serialized
audit, run A's merge landed while B was still queued, but the feed
still held B, so nothing refreshed until B finished too: both runs'
nodes appeared in one batched reveal (owner's live-test finding,
2026-09-28). The ledgers were correct the whole time (each merge is
a synchronous append inside the gate; the serialization test pins
B's auditor seeing A's node) — only the reveal was batched.
Now: refetch the snapshot whenever a run DISAPPEARS from the feed
(finished / aborted / failed). A run leaves the registry only after
its merge + settle + usage persist complete, so disappearance
implies the graph change is already on disk; each run surfaces
within poll latency (~2s). Redundant fetches on aborted runs are
free — the deletion-refresh doctrine. The costs pill follows the
same disappearance rule (usage persists per run). Confirmed the
status pills already render the raw phase, so the 'waiting' stage
displays as Waiting with no change.
Extraction may run in parallel across sources (extractors are
source-local and graph-blind), but the auditor explores the LIVE
graph through its tools and stamps identity verdicts against what
it finds — two concurrent audits would each link into a graph the
other is about to change, the structural mint of duplicate nodes.
withAuditLock: a FIFO promise-chain mutex on globalThis (the same
module-instance idiom as the run registries — a module-level lock
would be per-route-instance and useless in dev). The check runs
before any LLM is invoked or the graph read; the lock spans audit
through merge inclusive (the merge is the graph mutation) and is
reacquired per batch, so interleaved multi-batch runs each audit
against the latest graph instead of starving each other. Applied at
both front doors — chat batches and notes imports.
New 'waiting' phase (extracting → waiting → auditing → merging):
visible in the status feed so a queued run never looks stalled in
extracting. Abort-aware on both sides of the queue: a run aborted
while waiting leaves without auditing (its chain link still
releases — successors never hang); a run aborted mid-audit releases
in finally.
Tests pin: a second run queues at the gate and its auditor is
invoked only after the first run's merge is in the ledgers (the
post-merge-graph invariant itself); a run aborted while waiting
never audits, checkpoints nothing, and leaves a healthy chain for a
third run. Gates: typecheck, 1061/1061, lint, build.
Owner's first live test of the relinked pipeline surfaced three issues,
diagnosed against the ledgers:
1. Detail-card edge labels were backwards (UI only — data and canvas
arrows were correct). Semantics pinned 2026-09-28: an edge A→B
means A implies B (follows) or A supports B (supports). Outgoing
edges now list under 'What follows from this' / 'What this
supports', incoming under 'Follows from' / 'Supported by'.
Verified against the ledger: Invariance --supports--> Twin
paradox / Relativity of simultaneity; Argumentation ethics
--follows--> Homesteading — arrows correct, only the words were
swapped. docs/architecture.md wording fixed to match.
2. Session-local terminology leaked into node names ('Criterion C',
'Action axiom (F1)'). Both extractor prompts gain a naming
doctrine: names use established, discipline-canonical terminology
(glossary-headword test); never source-local bookkeeping (letters,
numbering, positional labels); local shorthand goes to aliases.
3. Near-duplicate propositions and lonely nodes (~40% of fresh-run
nodes edge-less; 'Argumentation ethics' vs 'A priori of
argumentation' duplicated despite the auditor having linked that
phrase once). Root cause is upstream of the auditor: the extractor
had no self-merge or edge-completeness duty. Both prompts gain:
MERGE YOUR OWN OUTPUT (same claim at different precision = one
proposition, phrasings to aliases; nothing downstream dedupes) and
edge COMPLETENESS (a proposition that arose as evidence for,
consequence of, or prerequisite of another must carry the edge —
a graph of lonely nodes is a failed extraction).
Owner rulings on the open question: keep two edge types (no
'relates to' junk drawer); re-measure loneliness after these fixes.
Gates: typecheck, 1059/1059, lint, build.
The graph becomes raw reasoning chains only. Quiz outcomes and
understanding observations are no longer formalized; assessment
moves to the future practice module.
Events ledger removed end-to-end: LedgerEvent/LedgerEventKind/
UnderstandingLevel/ExtractionAttribution/AuditResult.dropped and
snapshot nodeObservations deleted from contracts; quizEvent builder
and the events step of mergeFindings gone; appendEvent/readEvents
dropped from the port and store; LEDGER_FILES loses events.jsonl
with a one-time purge of a stale file next to the nodes ledger;
graph-deletion and the delete dialog lose event purge + quiz-answer
impact counts; GraphSessionProposition becomes a pure birth list.
Whole-session markdown source view: new core/session-markdown.ts is
the shared formatting engine (id-anchored speaker blocks, quiz/
recall/question blocks, tool one-liners) — the extractor's prompt
view and the future user-facing export render from one engine.
Coverage fingerprints deliberately stay on the old line format
(content-hash semantics; re-formatting never re-opens covered
history). The whole session goes to the extractor — no phase cut.
Auditor refocused as the identity linker: never sees the source;
explores the graph via new auditor-specific tools in
agent/tools/graph.ts (find — glob over name/statement/aliases;
list_sessions; list_propositions with full statement text; no
timestamps) instead of an injected full-graph dump; equivalence
bias — paraphrases merge, only genuinely distinct propositions stay
apart; no drop channel, the extractor is fully trusted; may draw
bridging edges (typed follows/supports) between existing nodes and
the batch, resolved by the merger with chat-scoped message-less
provenance. Agentic loop, maxTurns 16.
UI: the node detail card gains an edges section above the sessions
list (follows-from / supported-by / what-follows / what-it-supports,
rendered only when present, click-to-select neighbors); the
understanding-note chips are gone.
Prober list_propositions reworded to what a session covered —
names only, no levels. docs/architecture.md + README updated to
the as-built pipeline. Gates: typecheck, 1059/1059, lint, build.
Four living documents capturing the post-explain ideation:
- vision-practice.md (priority 1): exams over chosen node sets; assessment
record model (extractor observation = data point zero, practice appends,
labels-for-extractor/numbers-for-everyone-else, aggregation TBD-Bayesian);
problem-engineer agent flow with self-verifiable coverage metadata and
commit-before-pose grading via a dedicated write-seam tool; formats
configurable, theory pending from owner.
- vision-side-chat.md (priority 2): per-block hover-revealed mini threads,
invisible to the teaching record; v1 = block-only context, escalation
ladder documented; fork idea set aside (role confusion).
- vision-branching.md (unscheduled): tree-shaped records, plan-overlay UI;
pi SDK has no native branching (verified) — our own record-model work.
- vision-persistent-agent.md (PARKED): markdown session records as the
pull-based substrate, graph-as-truth over user.md, graph composer as
interface, session kinds/three-surfaces note.
Two follow-up rulings on the explain tool:
1. No 'I don't know' row — not needed in free recall. If he doesn't
know, he says so in his own words; his text is the only answer
channel. Dock: text mode is rowless again (the composer IS the
surface); doctrine and the shared prompt chunk updated; the format
branch and card revert to quiz-only dontKnow.
2. explanation + correctAnswer collapse into ONE field — the separate
explanation was quiz remnant (MC needs the why outside its options;
a free-text commitment carries its own reasoning). Schema is now
{question, correctAnswer, details?}; the reveal shows the single
committed answer; no explanation snapshots anywhere on the explain
path.
1047/1047 tests, typecheck/lint/build green.
Sycophancy mitigation as a structural commitment: the model writes its
full correctAnswer (plus explanation, in that order) BEFORE the learner
sees the question; the commitment rides the persisted call args, is
revealed on submit, and grades his free text in the model's next reply —
the client never grades free recall.
- tools.explain setting (segmented 'Quizzing — Multiple choice | Free
recall' in Agent settings' Tools group): free arms explain and drops
quiz for BOTH agents (prober variant carries no nodeId, same pattern
as the probe quiz); mechanics keys never pass filterEnabledTools —
quiz stays unwritable (the 2026-09-19 wedge ruling stands)
- doctrine lives pi-natively in the tool descriptions (owner ruling —
no conditional system-prompt chunks): explain carries the commitment
rule; the MC option-building craft moved out of the prompt into the
quiz description; shared/20-question-tools.md slims to the
quiz-agnostic core
- display seams: parseExplainInput (LaTeX-debris restoration included),
dock text-mode with an 'I don't know' row (kbd row 0), AnsweredCard
reveal (The answer / Why, no verdict chips), [Explain answer] format
- phases/plan seams recognize both quizzing tools' nodeId tags; explain
answers deliberately feed no ledger evidence (no client grading to
key on — a future ruling can parse the model's grade)
1049/1049 tests, typecheck/lint/build green.
The owner's capture-vs-composition check confirmed the wire sends exactly
what composes (byte-identical save the nodeId paragraph ebfe058 had since
changed). This wave removes the implementation meta that leaked into the
prompts and reorders so the method carries weight:
- base: owner's new opener verbatim (install understanding; the
understanding definition — which absorbs the old 'Understanding is
structure' paragraph); Voice moved in from shared/10 (deleted); the
philosophy keeps edge-dependence + logistics ('standard learning'
dropped per owner); intro's two-agents/fragments sentence gone.
- teacher/05-method opens with the owner's methodology bridge ('No other
teaching methods come close') — paragraph 3 of the opener, placed here
so the shared base never points the prober at methodology it doesn't
carry.
- deleted: teacher/60-probing-in-brief (self-evident from shared history;
its mid-lesson mini-probe move salvaged into 20-teaching as one
sentence), the 'Session shape — the durable artifact' section
(extraction-pipeline meta), the fragments-meta lines in shared/20 and
05-method, 'the transcript's seam' phrasing, prober's 'the teacher
that takes over' / 'the handoff is automatic'.
- composition reorder (listSourceSegments): base -> the agent's own
fragments -> shared/ appendix. Teacher method now starts at 12% in
(was 62%); prober doctrine at 34%. shared/20 opens with the owner's
decision rule (quiz only for known-correct-answer, ask for everything
else).
- HANDOFF_TEXT: the owner's user-message version — first-person, paces
the planning (unconditional truths, motivated derivations), ends at
plan_init + accept-or-changes.
Teacher 31.4k -> 28.7k chars; prober 10.0k -> 9.6k. Gates: typecheck 0,
1018/1018, lint clean, build green. (Suite runs 16s; the earlier 10-min
timeout was stale node_modules after master's df1328e 0.87.1 sync —
npm install fixed it.)
The probe intro was redundant with 60-probing-in-brief.md (and its
no-probe fallback described a state the code can't produce — the teacher
only exists post-probe). 'How the map tracks itself' folded into one
tagging paragraph; grain and rewriting compressed; the live-map context
kept to one sentence. The discovery-chain thinking block stays verbatim,
and the stress-test stays. Presenting: ask him to confirm the plan
before teaching begins — medium unprescribed (owner ruling 2026-09-27;
dock-question confirmation leaves the teach divider floated next to the
planning divider when no text reply splits the group — accepted as
cosmetic).
The inferred teach seam sat as a ternary branch in the messages.map
chain (alongside the no-bubble marker branches), so the ENTIRE first
teaching group — thinking, prose, the tagged quiz call — rendered AS
the divider and vanished below it (the Lacan session, 2026-09-27).
The marker branches replace contentless scaffolding; the teach group is
a real message. The divider now prepends inside the group's Fragment.
Also tightens 10-planning.md: the presenting turn asks NOTHING — no
quiz, no ask_user_question, no 'shall we begin?' (the teacher was
substituting a confirmation question for the dead verdict buttons).
The Accept/Decline verdict gate is gone (owner ruling 2026-09-26):
presenting the plan ends the teacher's turn; the learner's next message
is a natural reply. Teaching begins at the first quiz call tagged with a
nodeId (probe quizzes carry none) — both folds (view phaseAt3, native
phaseAt3Lh) advance on the call itself, and the teach divider renders
before that whole assistant block. No backwards compat: verdict markers,
the verdict input event, optimistic appends, composer buttons, and
projection all deleted; prompts and docs rewritten to the natural flow.
Owner's three rendering refinements (plan-graph round, 2026-09-26):
1. Reading font: the diagram root sets font-family var(--font-prose) —
node titles render in the reading font instead of the inherited mono
UI font. Deliberately NOT the font-prose utility: @theme inline bakes
the literal into utilities (the --font-title trap), which would freeze
the graph at Source Sans 3 and ignore the Appearance reading-font
override. The runtime var rides it (live-verified: Georgia override
propagates, null resets to Source Sans 3).
2. Thinner lines: edges + box outlines 1.5 → 1 (the app's standard border
weight). The arrowheads scale by the same 2/3 (HEAD_H 16 → 10.67; the
width, the notch overhang, and the DOCK_GAP all derive from the head
scale), so the head:shaft LaTeX proportion is preserved exactly.
3. Arrow color: white only when BOTH ends are white — an edge with an
upcoming origin OR upcoming terminal stays gray (line + head).
Supersedes round 6's target-only rule: arrows leaving a gray box no
longer paint white into covered territory.
Live-verified on :3001 (native-format fixture: plan_init + answered
quizzes; popup): font default + override, stroke-width 1 on all rects and
edges, 5-edge graph with exactly one both-white edge white and both
upcoming→visited cross edges gray.
Two owner-reported symptoms, one mechanism (verified frame-by-frame on a
zero-model-call fixture): the position machine read its OWN growth-driven
scroll lag as the learner leaving.
1. Probe arrival at the bottom: the form opened, the reserve grew, the
smooth catch-up lagged (d peaked ~405px > COLLAPSE_DIST 96) → phantom
fold → bar shrink morph → re-expand at bottom. FORM→BAR→FORM, ~600ms.
Fix: the at-bottom arrival now runs under the SAME fold suppression
the teach reveal uses (suppressFoldDuringReveal, extracted) — the
arrival's open and its scroll-to-bottom are the same motion class.
2. Probe arrival scrolled up: one stale-open frame painted before the
after-paint effect collapsed it. Fix: probeArrivalExpanded — the
open/closed state decided DURING RENDER (open iff at bottom), like
the teach seam; the effect keeps only the reveal scroll.
3. Dictation stop flush into the pending question: the field grows on
its own render (capped 192px), the reserve lands on the NEXT
DockOverlay render (~1s later live), and the spacer's growth spiked
d≈152 → fold → bar → re-expand. Fix: the offer stamps the geometry
mute when the reserve GROWS while pending — the spacer is the
phantom-scroll source, so the mute covers exactly its scroll events.
Shrinks never stamp (the resize round's stuck-collapsed regression).
Live-verified on :3001 (job-settle fixture, abort-channel settle, no
model calls): A at-bottom arrival FORM-only; B scrolled-up arrival
straight to BAR with zero FORM frames and no scroll-down; C dictation
flush FORM-only through the +152px reserve growth.
Three owner rulings from the gradient-descent session
(chat_muhskq6p_e8c3600b — 7 failed quiz calls, answers leaked in
option descriptions, planning divider floating at the transcript top):
1. RETRYING LINES (general): any tool call that errors back to the
agent (validation failure, malformed args — the retry loop) renders
one quiet "Retrying…" SystemLine instead of silence. Question tools
previously rendered null, so a flailing quiz loop read as a stall.
Aborts are terminal ("Operation aborted") and stay silent. Verified
live: exactly 7 lines render at the 7 failure sites of the session.
2. QUIZ OPTION DESCRIPTIONS: NUKED. The model used them to telegraph
correctness in every quiz of the session ("that moves uphill — it
climbs f instead of descending it") — answerable by elimination.
The field is gone from BOTH quiz schemas (probe + teach); ask keeps
it (ungraded — nothing to leak). The dock additionally suppresses
descriptions for quiz kind, so pre-fix records stop leaking too.
Verified live: the session's pending quiz renders bare labels.
Plus the diagnosed failure-mode fixes: GLM sometimes emits `options`
as a JSON-encoded string (6 of 7 failures). prepareArguments hooks
on all three question tools coerce it BEFORE pi's validation (the
call lands instead of bouncing); the client coerces again in
normalizeOptions (pi persists raw call args). Schema descriptions
hardened ("REQUIRED ... never a JSON-encoded string").
3. DIVIDERS AT THE MARKERS: the probe-handoff SystemLine
("Preparing your plan…") IS NOW the Planning divider; the accepted
verdict IS NOW the Teaching divider ("✓ Plan accepted" line gone) —
one signal per seam, not two. The fold-based divider block is
DELETED: Wave D grouping merged the whole probe into one giant
assistant group, so the inclusive-fold comparison floated the
divider to the transcript top (reproduced against the real record:
6 view messages, phase flipped at the giant group). The markers
render in the right spot by record construction. A DECLINED verdict
is not a seam — it keeps the "↻ Plan revision requested" line.
Verified live: plan divider after the probe concluded, teach
divider after the plan presentation, nothing at the top.
Tests: 1004/1004 — string-coercion pins (normalizeOptions + the
prepareArguments hooks on all three tools, identity fast path),
quiz-schema-has-no-description pin (ask keeps it).
TWO owner-reported bugs from the live red-circle session
(chat_muhpyoll_27305e23), both investigated to root cause first:
1. THINKING BLOCKS UNDER THE MESSAGE. GLM emits trailing reasoning
tokens, so the persisted content lands [text, thinking] (4 of the 6
thinking-bearing messages from [21] on in that session) - and the
view canon presented arrival order verbatim, rendering the reasoning
UNDER its own message. Fix: projectView stable-partitions each
assistant message - reasoning parts first (their own order kept),
then text/tool parts. Owner ruling 2026-09-26: the reasoning belongs
above, always. Covers live partials and settled records alike (one
projection canon). NOT addressed (owner ruling): the plan_tools
"missing" claim in the same session - wire-verified 11 tools incl.
plan_init; GLM confabulation, no mitigation wanted.
2. DOCK CARDS LINGERING AFTER DELIVERY. /api/activity lists jobs with
status "running", and the status flipped only in settleSteers at the
DELIVERING TURN'S onFinish - so a subagent whose card was already
visible in the transcript (marker injected mid-run) stayed in the
background dock until the teacher finished teaching. Fix: flip the
job at the moment the result reaches the learner - noteSteerVisible
(jobs.ts) called from BOTH visibility seams: the marker's message_end
wire event (live steer injection, ui-stream) and the write-ahead
persist (the wake's input appends, chat-handler). finalizeJob is
idempotent; settleSteers/runner-200 re-run it as a no-op, and the
registry's delivery promise still settles exactly as before.
Tests: 1001/1001 - view-canon hoist pin (trailing [text, think, call]
projects reasoning-first; think-first unchanged), noteSteerVisible unit
pin (flip at the seam, idempotent, handoff no-op), and the mid-run
steering e2e now holds the turn open past the injection and pins the
job flipping done at the marker's message_end WHILE the turn still
streams. Live-verified read-only on the owner's server: the original
session reloads with every reasoning trigger above its message.
Owner correction (2026-09-26, later): a real lesson is ONE continuous
teach->quiz->teach run - there are no run-would-stop moments mid-lesson,
and the run ends that DO exist are quiz stopper exits where delivery
must not fire. Subagent results must land BETWEEN messages INSIDE the
run, never splitting a toolCall from its toolResult.
Vehicle 1 now hands markers to agent.steer() - pi's STEERING queue:
the loop drains it at TURN BOUNDARIES (after a completed assistant
message AND its tool results - the bonded pair survives injection) and
again at RUN START (a continuation drains before its first LLM call),
then CONTINUES the run with the marker in context.
steeringMode "all" replaces followUpMode.
registerLiveAgent moved BEFORE runPiTurn and now hands pending markers
itself: the start poll drains the queue SYNCHRONOUSLY inside the
runPiTurn call, so a later registration would miss it. A marker
stranded by a quiz-terminated run therefore rides the ANSWER run's
first LLM call - the native replacement for the old continuation
coalescing.
Settlement reconciliation, the idle wake, probe-handoff exclusion,
and the registry-as-truth design are UNCHANGED; stranding is now
exactly the stopper-exit case.
Tests: jobs 45/45 (fakeAgent.steer; new pin - registration steers
pending markers into the new run, parked wake never fires); chat-flow
24/24 (new e2e through the real composition - a gated mid-run turn, a
marker delivered while the turn streams, record shape
[call, result, MARKER, response] with the marker adjacent to the
completed unit, the model SEES the marker in its next prompt, zero
wakes); full suite 999/999, tsc/lint/build green.
Live smoke (PORT=3004): research dispatched in the probe turn parked
behind the dangling quiz; on the quiz answer the marker injected at
the answer run's START poll - record [answer result, marker, teacher
response], job flipped done/delivered at settlement, brief card
rendered with its sources toggle.
Plan doc: docs/PI-NATIVE-MIGRATION-PLAN.md gains the pivot section.
Deferred (owner list): thinking blocks rendering after messages;
vanishing tool-call output tokens.
Delivery now rides pi's OWN follow-up queue when a run is live, and a
carry-everything idle wake otherwise. One registry, two vehicles;
claimSteers and continuation coalescing are deleted (owner rulings,
2026-09-26: follow-ups only; one queue; the wake is the only appender).
THE LIVE RUN (vehicle 1): the chat-handler registers the streaming
turn's agent (registerLiveAgent, next to registerTurn); the pump hands
every eligible queued marker to agent.followUp() - pi polls that queue
when the run would otherwise stop, injects the markers
(message_start/message_end on the live wire), and CONTINUES the same
run. followUpMode "all" joins every handed marker in one continuation
turn. The question stopper runs before the follow-up poll, so an open
quiz is never overridden - a handed marker strands at settlement and
waits (today's pacing). probe-handoffs never take this path (the
handoff must START the teacher's run - a record-level branch).
SETTLEMENT RECONCILIATION: settleSteers(chatId, persisted) at onFinish
(after unregisterLiveAgent) resolves+flips entries the run delivered
and returns stranded ones to pending - the registry stays the truth;
the dead agent's queued copy is abandoned. This is what makes the
handoff safe: pi owns no retry for a stranded follow-up.
THE IDLE WAKE (vehicle 2): waitForIdle (unchanged gates) then ONE turn
carrying EVERY pending marker as its input appends - dequeue-at-fire
(the Wave B regression lesson: the wake run's own follow-up poll must
never see its own cargo; a 409 pushes the cargo back and retries).
CLIENT: applyWireEvent now upserts non-assistant messages by id, so
in-run injected markers render live instead of dropping until settle.
Tests: jobs 44/44 (live handoff, settlement stranding, handoff-waits,
one-wake-carries-all); chat-flow 23/23 (the double-append pin drives
the real pump through the new input); session-core 28/28 (custom-role
wire appends). Full suite 997/997, tsc/lint/build green.
Live smoke (PORT=3004): research job delivered mid-probe - parked
behind the dangling questions exactly per semantics, landed once on
the open gates, teacher folded the brief in and re-dispatched; the
second (good) result rendered the full 10-source brief card and the
lesson continued from it. No duplicate ids anywhere in the record.
Known gap flagged to owner (NOT from this change): the server accepts
malformed answer events via raw API (answers without the answers[]
shape) and AnsweredCard then crashes the page render - surfaced by the
smoke's scripted answers, repaired by hand in the scratch record.
A pump-fired wake turn carries its steer in the input appends, but the
pump only shifts its queue entry AFTER the turn registers - so the
handler's claimSteers popped that very steer too. The record-side
steerDelivered filter checks the PRE-write-ahead snapshot and cannot
see the carried copy: the marker persisted twice with the same
steer_<jobId> id, two view messages shared one React key, and the
client crashed on delivery (live: steer_job_muhlokbf_84e9373c).
This exact bug was known and fixed 2026-09-18 (the old claimSteers
received the incoming body messages and finalized-without-appending
anything the request already carried). Wave B's turn-root rewrite kept
only the record-side filter and lost the carried-side half.
The fix restores the carried filter: a steer whose id the input already
carries is claimed-and-finalized but never appended a second time.
Regression pin in chat-flow.test.ts drives the REAL pump end to end
(deliverJobResult with a real job record -> waitForIdle -> scripted
wake turn) and asserts exactly one copy + the wake response. Two test
lessons baked into comments: chatIdOf resolves a job steer's chat
through the JOB STORE (a synthetic jobId with no record silently
no-ops the pump), and deliverJobResult resolves at CLAIM time, not at
turn completion (the record assertion waits for the wake response).
The live-corrupted record (chat_muhlcltz) was deduped by hand.
pi streams AssistantMessages with NO id (verified against a captured
live stream and pi-ai's AssistantMessage type): the id exists only once
the server's persistTurn stamps the record. applyWireEvent minted a
FRESH c_ id per event, so message_start appended, every message_update
found no match and APPENDED another full copy, and message_end appended
again - the tail grew one message per chunk. projectView's step
separators + thinking blocks then filled the transcript with dividers
and thinking indicators mid-stream (with fresh React keys remounting
the whole subtree per chunk), cleaned up only by the settle sync's
setMessages(record).
The fix: the in-flight assistant message is tracked by SLOT -
message_start mints and remembers one client id; message_update and
message_end REPLACE that same tail entry (pi guarantees exactly one
streaming message at a time via its strict start-updates-end ordering;
the reattach replay replays the same sequence). An explicit
message.id on the wire still wins; the slot clears at message_end,
stop(), and setMessages().
Tests: all streaming fixtures rewritten to pi's real shape (no
message.id) - the old ones encoded the wrong assumption - plus a
regression pin asserting N id-less updates leave exactly ONE assistant
message in the tail with a stable slot id.
Verified live mid-stream (DOM sampled every 500ms during a teaching
turn): 0 separators throughout, thinking blocks 1-2 (one per step, not
per chunk), text growing smoothly.
Live-driven via the owner's dev server (browser tools), reproduced on
/chat/new: send fires, optimistic appends, POST never happens, and the
page shows the frozen empty state. Three compounding defects:
1. session-core: default fetch stored UNBOUND — the browser's native
fetch requires this === Window, so every call threw 'Illegal
invocation' and the POST /api/chat never fired. Node's undici has no
receiver discipline, which is why 994/994 tests passed while the app
was dead. Now defaulted through a receiver-preserving wrapper; pinned
by a strict-this regression test (the browser's semantics simulated.
2. session.ts: state memo keyed on [model, forceRender] — the reducer
dispatch is STABLE by contract, so the memo computed once per mount:
no optimistic bubble, no stream events, no error banner. The view was
frozen at first render. Rekeyed on a version counter bumped by the
model's onChange.
3. view.ts: turn_end carries the clean 'Question posed' placeholder as
a toolResult message (details: null) — applyResult flipped the quiz
part to output-available with a null output and AnsweredCard crashed
on answer.dismissed (killed the whole page render). Fork 2 now holds
on the message-attachment path too: clean question results with null
details are ignored; the AnsweredCard renders only the learner's real
answer. Regression pin extended in session-core.test.ts.
Verified live end to end: new session → text send → probe questions in
the dock → ask answer → graded ✓ quiz card with explanation → reload
renders the full transcript from the native record with the pending
question intact.
EOF
)
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).
Owner-approved home: docs/ instead of the repo root. Wave C (stateful
agents, seam 5) stays optional and deferred; Wave D (SSE dialect +
client rewrite) is next.
The onSubmitAnswer path was the one Wave B send site missing its stash:
addToolOutput triggers the SDK's automatic continuation send, the
transport found nothing stashed, and threw — the answer never POSTed, so
the question stayed open. One line (plus the contract comment). Caught
live by the owner; the suite drives answer events directly at the
handler, so this client seam stays untested until Wave D's client work.
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.
- src/agent/lh-tool.ts deleted: the zod-wrapped LhTool + toPiTool bridge
(z.toJSONSchema per call, clientQuestion flag, hand-rolled result mapping)
is gone. Tools are pi-agent-core native AgentTools now.
- New src/agent/tool-execute.ts owns what the wrapper added: the abort guard
(runWithAbortGuard: run signal + 30s per-call timeout + 250ms grace,
'Operation aborted' / timeout wording preserved), ToolInputError (the
retryable '- problem at path' format), result mapping (string verbatim /
object JSON / image parts, details rides the record's tool part output),
and questionResult() — placeholder + terminate:true (owner Option A:
belt of terminate + slim makeQuestionStopper with the clean-question
rule; maxTurns deleted from the teaching turn entirely per owner).
- All 11 tool files + tools.ts registries converted to TypeBox parameters
(typebox@1.3.7, pinned to pi-agent-core's copy). zod refinements
(quizInputProblems, askOptionProblems, live-nodeId) moved from schemas
into execute — the model sees the same retryable error shape.
- question-core + contracts/probe TypeBox-authored (single source shared
with the UI); tool-parts parse seam runs the boundary's full chain
(Convert → Check → Clean → refinement), fixing the live-smoke lockout:
pi's boundary Value.Converts correctAnswer: 17 → '17' but the record
keeps raw args, so a parse without Convert rejected a posed quiz
(invisible pending question, turn ended). Pinned with the real smoke
emission shape.
- Subagents converted: demo-builder/researcher AgentTool-native,
visual-maker/pdf-reader declaration-only pi-ai Tools with local execute
fns, extraction submit schemas re-authored in TypeBox (owner option 2:
zod defaults → Type.Optional + fill at the submit seam).
- Accepted delta, flagged: TypeBox-native schemas skip pi's JSON-Schema
primitive coercion fast path... (boundary still Value.Converts), and
prober-quiz unknown keys ride through (Value.Clean strips at display).
- Tests: 988 passing (was 967 at the recon baseline); tool-abort and
read-tool rewritten against the native seams; question-core/question-
tools/tool-parts/demos/web-search/graph-tools/plan-tree/probe-done/
tool-toggles converted to Value.Check/execute pins.
Live smoke (PORT=3004, real model): native ls turn streams end-to-end; a
real quiz pose (first attempt missing correctAnswer → tool-input-error →
model retry → posed) terminates the turn cleanly and persists pending.
baseAgentOptions (teacher-agent.ts) and runSubagent (subagents/pi-run.ts)
passed getApiKey: () => openRouterApiKey(). pi-agent-core resolves
(config.getApiKey ? await config.getApiKey(provider) : undefined) || config.apiKey
(agent-loop.js:191) — a provided hook OVERRIDES pi-ai's native per-provider
env resolution, and this one ignored its provider arg: every non-OpenRouter
provider was sent the OpenRouter key and 401'd (owner repro: openai and
anthropic direct). Deletion restores the architecture's stated intent —
the keys seam reflects file-saved keys into env and pi-ai reads them per
request. openRouterApiKey() export kept (now consumer-less; visual-maker/
pdf-reader read process.env.OPENROUTER_API_KEY directly for their own
OpenRouter-specific fetch paths — correct, untouched).
Pinned by tests/api-key-hook.test.ts: built teacher + prober expose NO
getApiKey hook. Gates: typecheck 0, 987/987, lint clean, build green.
The prober was planning perfect 7-question batches instead of probing. Three
surfaces now say the same thing — one question, adapt, next question:
- prober/10 rewritten to ~2.6k (from 5.6k): serial discipline leads (ask,
wait, choose the next question from the answer); 'How your questions
arrive' deleted (owner: does nothing); 'You cannot get there by thinking'
deleted (owner: didn't reduce thinking); 'never plan a batch' and the
first-message-length line dropped per owner — no overcorrection, no
strong-instruction inflation. Binary search compressed to four moves.
- tool descriptions: the async-era fossil 'Ask exactly one question per
tool call; after calling, end your turn and wait' — which permitted
multi-call batches — swapped for 'ONE question per turn: ... his answer
determines the next' in all four places (prober quiz + ask descriptions,
teacher quiz description, both zod question-field describes).
- shared/40-formatting: markdown/LaTeX coverage now explicitly names quiz
questions, options, and explanations ('in prose and in quiz questions
alike') — the prober wasn't using LaTeX in questions.
Prober composed prompt ~13.2k -> ~10.0k chars. Pins preserved verbatim
('measurement should be efficient', 'the read tool can inspect it directly').
Gates: typecheck 0, 986/986, lint clean, build green.
- FontMenuList: the separate custom-family input is gone; the search
field absorbed its job — a no-match query renders a 'Use <query>'
action row (customFamilyFor, pure, pinned), Enter picks it
- openFontMenu: an empty-but-successful queryLocalFonts() now falls back
to the curated list (Electron 44 grants local-fonts and still
enumerates zero fonts — reproduced with a hidden-Window probe); the
footer split states WHY: 'could not be read' vs 'needs the desktop app'
- The Hint tooltip wraps the trigger button only and unmounts while the
menu is open — it no longer pops over the dropdown
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.
Owner-confirmed semantics (verified in composeInstructions first: switch
ON = the user's override text is what the agent reads; OFF = the default
composes, the saved file stays on disk unused). Four refinements:
1. The small text under a file name states WHICH VERSION IS IN USE:
'Your version is in use' (PROMPT_IN_USE_HINT, pinned) when the
switch is on; NOTHING when the default composes — even with a saved
override sitting on disk. The old presence-based 'Your version is
saved' (shown whenever a file existed, regardless of the switch)
dies; the switch's meaning becomes legible through the line it
visibly controls.
2. Save is clickable only when there's something to save: grayed and
inert while the draft equals what's on disk (including the
untouched editor just opened), full-contrast the moment the text
differs. The always-active same-color button was the 'doesn't feel
like it does anything' half of the report.
3. Saving auto-turns the switch ON — writing an override and putting
it to work collapse into one action (a save with the switch off was
the did-something-changed-nothing trap). Fires whenever the record
genuinely has the path off, so a re-save after toggling off re-on's
too. Order is the safe one (file first, then the switch the
validation requires it).
4. A quiet inline 'Saved' / 'Deleted' confirmation rides next to the
buttons (the old status line echoed the file path at the section's
bottom — production-copy violation and easy to miss).
Plus the mirror-symmetry the auto-on needs: deleting an override while
its switch is on now composes the switch OFF in the same action — not
just redundant with Platform's 643a693 server-side clear: the dialog's
record mirror is what composes the next section write, so a stale ON
would silently defeat the auto-on after a save-following-delete (the
mirror would think the switch is already on and skip the write the
server record needs).
Live-verified on :3001: untouched editor grays Save; typing activates
it; reverting re-grays it; save-with-switch-off lands toggle ON + hint
+ 'Saved' + Save re-grayed in one click; toggle-off drops the hint
with the file retained on disk; re-save while off re-on's; delete
while on clears switch + file + hint with record left clean and a
subsequent agent-section write landing 200.
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.
The prompts area's five owner-reported symptoms, root causes found
in code and verified live before fixing:
1. STATE ECHO — the toggle's truth (SegmentInfo.enabled) derives
from the settings record's agent switches, but the segment view
was read ONCE per section mount: the switch write landed (PUT
/api/settings/agent) yet the toggle painted its old state until
a remount re-fetched. Root-cause fix in the settings-saved
family: the section now re-reads /api/settings/prompts when the
'lh:settings-saved' signal lands for the agent section — the
reader the signal law was missing. Toggle flips verify at ~40ms,
no navigation.
2. DUPLICATION — the server contract returns the shared segments
in BOTH per-agent lists; the UI rendered both lists back to
back, so every shared chunk appeared twice (18 rows, 5 dupes).
sectionedSegments (pure, exported, pinned) dedupes: the agents'
own chunks plus the shared chunks ONCE.
3. ORDERING — Teacher first, then Prober, then Shared last
(owner-ruled), each section headed, chunk order within a section
preserved from assembly order (the server lists ARE assembly
order: base, shared sorted, own numeric-prefix sorted).
4. GRAYING — the whole-row dim died; only the TOGGLE dims now when
there is no override file (a wrapper span on the control; the
file name is always full-contrast).
5. DESCRIPTION LOGIC — the 'No changes yet' line died. No
description when there's no override (the empty state is clean:
toggle + name); 'Your version is saved' (PROMPT_SAVED_HINT,
pinned) appears ONLY when an override exists. The redundant
'Shared base — appears in both agents' editor note died too —
the Shared section header carries 'Used by both agents'.
Also: saveSegment/deleteSegment reuse the readPrompts callback
(keeps last-good on a failed re-read; the mount effect owns the
loading state).
Live-verified on :3001: sections in the ruled order with 13 unique
rows, clean-row and overridden-row states per the rulings, toggle
flip instant (view + record agree), save/delete round-trips, and a
shared-segment save + toggle through the single Shared control.
Open finding (NOT fixed here — needs a ruling): an ON switch whose
override file is later deleted orphans the record, and the
agent-section write validation 400s every subsequent write (an
ON switch must have an existing file) while the UI's disabled
toggle cannot reach the OFF escape hatch the validation
anticipates. Repro: Save + switch on, Delete the override, flip
any tool. Server validation + row semantics are Platform's /
owner's call.
The probe→teach handoff wake read as a dispatch order ("Author the session's
plan map now") and the teacher skipped the deliberation the planning fragment
asks for. The wake now paces it: take your time, read the assessment and the
probing transcript, reason out the optimal way to teach this person this
thing — floors, ceilings, misconceptions, the minimal tree so every step
lands as obviously true — and only then call plan_init. Machinery honest:
plan_init is still the first tool, the plan map still the first artifact, and
the tail now says to wait for his verdict (which is what presenting actually
does — the old "then begin teaching" tail skipped the verdict seam).
Reattach to a turn that outlived its view through the SDK's native
resumeStream() so replay burst + live deltas flow through the exact
same processUIMessageStream chunk pipeline as first-subscriber
streaming — no second rendering dialect.
chat-transport.ts: ReattachingChatTransport subclasses
DefaultChatTransport and repoints reconnectToStream at GET
/api/chats/[id]/stream (the server's reattach endpoint, not the SDK
default /api/chat/{id}/stream). It peeks the first SSE event:
- turn-settled terminal: cancel body, fire onSettled (refetch), return
null — the SDK treats null as no active stream, status stays ready.
- start chunk whose messageId equals the seeded last assistant message
id (continuation case): rewritten to a fresh id so the resumed run
mounts beside the seeded message instead of replacing its parts —
settle-time record sync merges to the server's assembled message.
chat.tsx: mount effect resumes when initiallyInFlight (no once-ref:
React StrictMode's dev double-mount aborts the first attempt at
dispose — the retry must run). Mid-session discovery sites (jobs
poll, handoff watch) funnel into attachToRunningTurn().
turnInFlight is demoted to fallback liveness: the 1500ms record poll
self-heals a failed attach and clears a stale error banner on settle.
A status watcher clears the flag once the local stream represents
the turn. Stop/Esc unified in abortInFlightTurn: local stop() when a
stream runs here, always POST /api/activity/abort {kind:'turn'} with
409 not_running treated as a benign raced settle. Composer busy now
covers reattached turns (Stop square shows, send gated).
Live-verified on :3001 with real model turns: navigate away mid-turn
and back (replay + live deltas in-view, fallback poll disengaged),
Esc-abort of a reattached turn (partial persisted server-side,
composer restored), two concurrent sessions (raced-settle handled
via turn-settled + record refetch; live settle adopts the final
record), and no leaked stream readers after unmount.
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 report (2026-09-25): in the teaching phase a new question arrives
collapsed behind the reveal button, but the transcript scrolled up as if
it had OPENED — the room it made was the expanded form's height, never
shown. Root cause: the expanded state is stale-true from the previous
question (nothing resets it when the dock returns to the composer), so
the new question's FIRST paint rendered open; the dock measured itself
tall and offered that height to the spacer, and the collapse one tick
later could never give it back (fold immunity refuses drops while a
question pends). Collapsed bar, tall reserve — scroll as-if-opened.
Fix (owner-shaped, deliberately minimal): apply the arrival state DURING
RENDER, before the first paint (the render-time state adjustment
pattern) — revealArrival(wasQuestion) decides how a teach question
lands: after the composer → collapsed (the learner reads the teaching
first); after ANOTHER question (a queued parallel check) → OPEN — the
reading already happened, and the height it already has is the honest
height. The paint measures the truth, so the reserve never learns a
height that isn't on screen. Probe arrivals are untouched — instant-open
at the bottom stays. The old [pendingId] effect keeps only its probe
branch.
Live-verified on :3001 with a fabricated teach-phase session driven
through the real settle/adoption path (zero model calls): composer→teach
arrival lands as the 59px bar with the spacer growing 206→211 (+5) and
the transcript scrolling only 77px (the new message itself) — the
previous behavior jumped the reserve +339px and scrolled 660px;
teach-check reveal click still expands (425px) and scrolls to bottom;
a queued parallel question arrives open with the reserve unchanged
(577→577) and +40px scroll.
Pins: tests/reveal-arrival.test.ts — revealArrival(false) = collapsed,
revealArrival(true) = open (the owner ruling). 946/946.
Root cause (interlude, owner 2026-09-25): keyboard-shortcuts dispatches
registry actions on the lh:kbd-<action> convention, but the sidebar's
listener sat on the pre-convention name "lh:toggle-sidebar" — a name
nothing ever dispatches, so ⌘B silently did nothing while its tooltip
and cheat-sheet row kept claiming the binding. A sweep of every lh:
listener confirmed the sidebar was the lone stray (chat-renamed and
graph-revision are deliberate non-kbd events).
Fix, structural: kbdEventForAction(action) exported from
src/core/keyboard.ts — the ONE spelling — and both sides compose
through it (dispatcher's default branch, sidebar's toggle listener);
keyboard-shortcuts re-exports the helper so the contract pin can assert
no second spelling exists. The mismatch is now impossible by
construction.
Thinking pill: label trimmed to "Thinking" with its real shortcut via
the registry-composition path — "Thinking (⇧⇥)" (cycle-thinking,
rebind-live like every Hint suffix).
Pins (tests/keyboard-toggle-sidebar.test.ts): the full chain — mod+b →
actionForBinding → toggle-sidebar → kbdEventForAction →
lh:kbd-toggle-sidebar; formatBinding("shift+tab") → ⇧⇥; the
dispatcher-imports-the-same-helper contract.
Live-verified on :3001: ⌘B visually toggles on home, /chat/[id], and
/chat/new (sidebar 0↔288px, hamburger slot 0↔36); with the rebind
lh:keymap {"toggle-sidebar":"mod+j"}: ⌘J toggles and ⌘B is inert
(rebind-live); thinking pill renders "Thinking (⇧⇥)"; keymap restored,
sidebar left open.
Owner round 2026-09-25, over 09c0f6e. The wave's tooltips read as
distracting; this round makes them quiet:
- Delay: TOOLTIP_DELAY_MS = 500 in the primitive — tooltips appear when
you pause on a control, never in passing (Radix focus-opens stay
instant by design; keyboard users don't wait).
- Vanish on leave: disableHoverableContent — the chip is un-hoverable,
so the instant the cursor leaves the trigger it's gone, even mid-move
onto the chip's own pixels.
- Look: bg-accent (#2a2a2a, the hover-chip tone) instead of the white
bg-foreground slab; 11px; px-2 py-1; arrow culled (nothing purely
decorative); text-foreground/85.
- Suffix: parentheses, not em-dash — "Dictate (⌘D)"; the registry
source unchanged (still override-aware).
- Drops (owner-selected): send square, start square, stats summary —
the icons/numbers describe themselves.
- Home logo kept but to the RIGHT with its real shortcut "Home (⌘G)"
(jump-graph routes to /, the same destination).
- Trims: "Model selector (⌘K)", "Thinking level", "Graph costs —
click for details", "How much of this session isn't mapped yet".
- Added: Import Notes gets "Import notes into the graph" — the one
button whose destination wasn't obvious.
Pins: TOOLTIP_DELAY_MS=500; hintText parens expectations updated.
Live-verified on :3001 — 508ms hover delay measured, close within 10ms
of pointerout, chip styles probed (rgb(42,42,42), 11px, 4px/8px), arrow
gone, all new labels + sides confirmed, drops carry no tooltip and no
aria-describedby.
Owner-approved full pass (tooltip wave, 2026-09-25). Mechanism: icon/pill
controls ride the styled Radix tooltip via a new Hint composition
(components/ui/hint.tsx) — the shared primitive adopted as-is, no
restyling; the shortcut appends as an em-dash suffix off the LIVE registry
(a rebind in Settings → Keyboard changes the suffix): "Dictate — ⌘D".
Surface-owned keys (Esc closes, Enter submits) ride a raw binding prop.
Native title= stays only for truncation-reveals (dock spans, the rename
field) and menu-interior abbreviations (MenuShell untouched). One
em-dash pattern; per-state truth on the send square (busy = "Stop the
reply — Esc", idle = "Send — ↵"), Enter on the plan verdict's accept,
"Stop dictation (Esc)" kept verbatim per the owner's flag.
Copy: the dev-speak cull — no pipeline/prober/tier/wave vocabulary
anywhere a user reads. New pins in tests/tooltip-mechanism.test.tsx
(hintText composition, asChild no-wrapper-DOM, the auto-update hint
constant); dock truncation titles pinned native; graph-chrome + settings
pins updated to the new copy.
Live-verified on :3001 — 12.6ms hover appearance (no native 1s lag);
glyphs render on every suffixed surface (⌘D, ⌘K, ⌘⇧F, ⌘,, ⌘B, F2, ↵,
Esc); per-page probes confirm Settings entry, Home logo, session search,
Start session, Close sidebar, model + thinking pills, stats summary,
rename title, plan chip, coverage ring, send square, cost button, node
search (⌘F), graph gear, settings close (Close — Esc), keyboard section
"Fixed — part of this screen", auto-update hint, light-mode "Coming
soon", the three slot rosters, graph-tools hint. Esc still closes the
settings dialog.
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.
The owner's asymmetry find (interlude over the zero-keys wave): the
models-unconfigured case guards BEFORE send — no request, no chat
created, setup opens — but the key cases only reacted to the 503 after
POST /api/chats had already created the record, leaving orphan error-shell
chats in the sidebar. The guards now twin the models mechanism on BOTH
creation paths (home start(), /chat/new send()):
- Zero keys (useZeroKeys reuse): no provider key present -> skip creation
and send entirely, open SetupDialog (stage 1 first-key screen).
- Strong-provider key missing (owner-approved extension): keys exist
generally but the strong slot's pinned provider has none — the original
deleted-key scenario. New pure strongKeyless(catalog) in setup-cta.tsx:
slots.strong resolved pin + providers[] (present-key only, the catalog's
own federation surface), false when unconfigured (the models guard owns
that case) or catalog not loaded (the server 503 gate remains the belt).
useStrongKeyless rides useModelCatalog, re-reading on the 'keys' AND
'models' signals (a deleted key or a re-pinned slot flips it live).
Both guards run after the configured check, same
skip-everything-and-open-setup treatment; 'force' (setup's onDone replay)
bypasses both — completing setup implies a keyed provider. /chat/[id]
sessions keep the 503 revert-draft interception unchanged: those chats
legitimately exist.
Pins: strongKeyless fires on a keyless strong pin (keys elsewhere or
none), never fires with the pin's key present (incl. the keyPresent=false
belt), false on unset pin / unloaded catalog. Normal-path pins ride the
existing startDraftSession contract suite. Live-verified on :3001 under
LH_IGNORE_ENV_KEYS=1 with a file-key anthropic + openrouter-pinned strong:
both guards on both pages (setup opens stage-1 / stage-2 respectively,
draft preserved, 154 chats unchanged, zero sidebar entries), normal path
with a keyed strong provider still creates and lands the prompt redirect.
929/929, tsc/lint/build green.
Over Platform's 66dbea9 key-presence seam, the five owner-locked tasks:
1. Zero-keys pill — components/setup-cta.tsx: pure hasProviderKey
(classifyKeys reuse — tool keys like Deepgram never count), useZeroKeys
(GET /api/keys, re-read on the new lh:settings-saved 'keys' signal),
ZeroKeysPill (testid setup-cta-keys) taking PRECEDENCE over the models
CTA on chat/home/new-session. SettingsSection gains 'keys'; keys-section
dispatches after save AND delete.
2. Setup stage 1 is self-contained: provider picker (MenuShell +
ProviderMenuList) + key paste + Save via PUT /api/keys/:id — the dialog
STAYS MOUNTED; its own save dispatches the keys signal, the fetch effect
re-runs on it, and the stage flips to the pickers on its own.
onOpenKeys + setup-open-keys die. SetupKeysBody exported for SSR pins.
3. parseProviderKeyRefusal (chat.tsx, envelope-verbatim twin of
parseModelsRefusal) joins the revert-draft-and-open-setup interception;
activity-dock reads 'needs a provider key'; dispatch-card shows
'Add a key' — both deep-link setup, which lands on stage 1 by itself.
4. Dictation trio: the token route's only 503 (missing Deepgram key)
becomes the client-authored DICTATION_KEYLESS short line; the errored
idle click clears the error and deep-links Settings -> Keys ->
DEEPGRAM_API_KEY via the new open(section, row) seam (dialog landOnRow
-> SettingsDialogBody initialRow -> KeysSection openRow follow-effect).
Live-verified on :3001 under LH_IGNORE_ENV_KEYS=1 (the fresh-user switch):
pill on all three surfaces + precedence over the CTA, stage-1 save ->
auto-advance with dialog mounted, real 503 send refusal reverting the
draft and opening setup, dictation 503 -> short line -> Settings lands on
the unfolded Deepgram row with the error cleared, keys-section save and
delete both flipping the pill live, and a fabricated refused research job
rendering the 'Add a key' dispatch card. 926/926, 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.
The measured problem (330MB DMG / 1066MB app): electron-builder's
dependency collector packed the whole production dep tree into
app.asar (502MB) regardless of the files allow-list, because
normalizeFiles turns the array into one {filter} fileSet whose
entries ALL feed the per-dep copier (its implicit '**/*' passing
everything), and unpacked the native rungs into a 150MB
app.asar.unpacked. The launcher needs nothing but electron + node
builtins — the server ships via extraResources.
The fix (electron-builder.yml files): gitignore-style negation
ordering — '!**/*' first excludes everything, then only
electron/** and package.json re-include. The same list reaches the
per-dep filter, where app-builder-lib's destination-path quirk
(stat.moduleFullFilePath in util/filter.js) means no dep file can
ever re-include: all 566 modules collect zero files (DEBUG-mode
verified). Locale trim rides along (mac.electronLanguages: en).
Result on a fresh desktop:dist: app.asar 502MB -> 16KB (shell only:
electron/main.cjs, preload.cjs, package.json), app.asar.unpacked
gone, Frameworks 288MB -> 239MB, app 1066MB -> 367MB, DMG 330MB ->
144MB. Resources/server curation untouched (127MB, sharp native +
pdfjs traced and verified live).
Live verification against the packaged app: server spawn via
ELECTRON_RUN_AS_NODE on its kernel-assigned port, model slots via
PUT /api/settings/models, chat round-trip streaming an exact echo,
read tool on a PDF through the pdfjs path (exact text extracted),
image attach through the vision slot, GET /api/graph snapshot and
render-settings.
Docs full-accuracy pass (owner-approved scope): README +
docs/architecture.md swept of the dead embeddings / graph-context
injection / cluster-namer / semantic-clusters story (wave-7
casualties), MODELS/config roster rewritten to the BYOK slot
chain, EXTRACTION.chunkTokens -> graph.extractionChunkTokens,
route count 16 -> 25, steer_ctx_ dropped, seam table re-pointed at
the prober graph tools; docs/research/stt.md:50's "we already ship
transformers.js" premise corrected; config.ts orphaned EXTRACTION
doc block (left over from eed4b4e) deleted.
Note for master: npm prune still owed in the master tree (~690MB
stale @huggingface/onnxruntime trees absent from the lockfile);
worktree tree is clean and verified.
Owner polish round (2026-09-24): the two destructive-confirmation
surfaces must read as ONE design. The node dialog was the good one —
the session dialog now wears its exact look and flow.
components/delete-session-dialog.tsx (full rework):
- Red serif "Delete session?" (was neutral "Delete "title"?") — the
node dialog's destructive title, text-destructive on font-title 2xl.
- The session name below the title in JetBrains Mono at text-lg,
NO code-block chrome — the font already says code. (The node
dialog's name got the same treatment: text-xs bg-muted code block
dies, mono text-lg line lives. Owner chose below-the-title over
parens-in-the-title: long titles wrap safely below, never inside
the 25px headline.)
- TWO OPTION BUTTONS -> CHECKBOX FLOW: the locked two-button design
(preserve-graph / delete-nodes) collapses to ONE unchecked-by-default
checkbox "Also remove its graph traces" (accent-primary, the settings
panel's native-checkbox idiom) carrying the graph-touching option's
consequence copy verbatim. Unchecked = preserve-graph; checked =
delete-nodes. The unchecked promise ("everything it added to the
graph stays unless you check below") lives in the intro line now.
- Bottom row = the node dialog's exact pair: quiet Cancel + destructive
red Delete (border + text, fill on hover), "Deleting…" on the Delete
button while busy. Try-again re-derives the mode from the checkbox
(retry IS Delete pressed again; the checkbox is disabled while busy).
- DeleteSessionBody exported pure (the settings/node dialog body
pattern) for SSR pins; TRACES_COPY exported for the copy pin.
DELETE_OPTIONS dies with the two-button design.
Tests: session-context.test.tsx's two-option pins rewritten for the
node-dialog shape (red title, mono name no-chrome, unchecked checkbox
default, red Delete pair, old testids absent); delete-node-dialog
pins the chrome-less text-lg name. 905/905. Live-verified on :3001:
both dialogs render 25px red serif titles, 16px mono names, matching
red Delete buttons; Esc closes both, nothing deleted in verification.
Off UI's d651f93 flag (displayCostTotals already isolates the display to
extraction + audit; the naming stage row and naming runs were filtered
out client-side). This culls the server side of the same ruling.
THE STANDING RULING, REVERSED BY ITS OWN PREMISE: NamingRunUsage /
readNamingRuns / appendNamingUsage were kept at the wave-7 namer kill
ONLY because cost-summary's HISTORICAL naming stage read them. With the
fold culled, that premise dies — so the whole ledger read path goes too,
following the wave-7 embeddings precedent chain: consumer-less ledger
seams are deleted, and the on-disk naming-runs.jsonl stays INERT (real
trees keep their historical spend bytes; they are simply not displayed
anywhere — the same treatment as historical stages.embedding spend).
- core/cost-summary.ts: two channels in (chat + notes front doors), two
stages out; RecentRun.kind collapses to "extraction" (labels field
gone); CostSummaryInput.naming gone; totalDollars = extraction + audit.
- ports/store/contracts/config: appendNamingUsage + readNamingRuns ports,
store impls + guards, the NamingRunUsage interface, and
LEDGER_FILES.namingRuns die. graphRevision no longer counts the file
(a smaller revision value is harmless — reads never compare).
- components/graph-view.tsx (mechanical, flagged): the defensive
.filter(run.kind !== "naming") guards a kind that no longer exists.
- Tests: cost-summary's both-channels fold pins; the naming fixtures and
the appendNamingUsage store pin die with the seams.
The cluster namer is dead (owner refinement round, 2026-09-24): cluster
labels are just session titles / file names now, coined without an LLM,
so there is no reason to keep tracking naming spend in the cost UI.
components/graph-view.tsx (UI-only; the core fold is untouched):
- NEW pure export displayCostTotals(costs) = extraction + audit dollars
and unpriced counts. Both the cost pill and the panel headline read
THIS — never GraphCostSummary.totalDollars, which still includes the
historical naming channel — so the two can never disagree.
- GraphCostPanel's stage rows lose the "Cluster naming" line; the
headline and unpriced counts come from displayCostTotals.
- The recent-runs list filters out kind === "naming" runs entirely
(the "N labels" tail died with them).
- Mount comment updated: the fold covers chat + notes extraction runs.
Note for master/Platform: src/core/cost-summary.ts still folds
naming-runs.jsonl into stages.naming / totalDollars (Platform's lane).
The UI no longer reads those fields; Platform can cull the seam and the
dead ledger whenever convenient — displayCostTotals already isolates
the display from whatever the core returns.
Pinned in tests/graph-view-filters.test.ts: displayCostTotals with a
naming stage of $9.99 + 2 unpriced proves the display sees extraction
+ audit only ($2.00, 1 unpriced). Live-verified on :3001 — the panel
renders Extraction and Audit rows, no naming row, no naming runs in
the recent list. 905/905.
Three owner fixes for /chat/new (refinement round 4, 2026-09-24) —
the page must stop looking like a session that's already running:
1. NO PHASE CHIP: the header's <PhaseChip phase="probe" /> dies —
no session has started, so no phase exists yet. Active sessions
keep their chip (chat.tsx's derives from the live timeline).
2. TOP BAR STRUCTURE MATCHS SESSIONS: the header gains the same
border-b border-border as the chat header (h-15, lh-drag,
60px). The floating-bar look is gone; /chat/new now reads as
the same shell with empty content.
3. COMPOSER PLACEHOLDER = THE OPENING QUESTION: active sessions
keep "Ask, answer, or steer the lesson…" (now the DEFAULT of a
new Composer `placeholder` prop), but the fresh page asks what
the graph view's new-session input asks — "What do you want to
learn?" No lesson is in flight, so the composer must not read
like one is.
The ConversationEmptyState intro card ("The teacher will open by
probing…") stays, per owner confirmation.
Live-verified on :3001 /chat/new: no phase-chip node, computed
1px header border rgb(67,67,67) at h-15/60px, placeholder reads
"What do you want to learn?"; the empty-state card intact. Unit
test pins the default session placeholder AND the override.
903/903.
The scroll-to-bottom disc (ConversationScrollButton) was the scroller's
last child — DOM order painted it ABOVE the dock overlay, so it floated
at the scroller's bottom edge, partly covering the composer (owner
refinement round 3, 2026-09-24: it must ALWAYS sit above the composer,
never over any element, and stay positioned with respect to the
composer — which grows a lot taller when a question owns the band).
The fix is a DOM move, not a coordinate: the button now renders INSIDE
the DockOverlay, as a sibling of the DOCK container within the dock
block — `absolute bottom-full mb-2`, anchored to the BLOCK's top edge.
Because the anchor is the block itself (not the scroller's bottom):
- it sits above the composer shell AND the stats row, in the
gradient's transparent band;
- it rides every height change LIVE: typing growth, a pending
question expanding the band (verified: the disc moves with the
393px question form, 9px clear), the 180ms fold morph to the slim
bar (8px clear of the folded bar — no overlap mid-morph either,
height animations reflow per frame);
- pointer-events-auto (the overlay is none by default); the
isAtBottom/scrollToBottom context still reaches it — DockOverlay
is a Conversation child.
The use-stick-to-bottom pattern comment updated; the shared
ConversationScrollButton component itself is untouched (positioning
arrives via className, twMerge swaps bottom-4 → bottom-full).
Live-verified on :3001 with a tall transcript, all three states:
chat mode (15px clear of the composer textarea), expanded question
(9px clear of the form top), folded slim bar (8px clear). 902/902.
Two owner fixes (refinement round 2, 2026-09-24) in the question
dock's ask questions (quiz untouched):
1. ESC ESCAPES THE OTHER TYPING: the ask's "Type your own answer…"
row auto-focuses its input on selection, and once in it there was
no way out — arrows moved the caret (the typing-surface guard), so
keyboard navigation was stranded. Esc in that input now blurs it
(focus falls to the page) and pins kbdIdx to the Other row, so:
the row STAYS selected (deselection is a deliberate act, never a
side effect), the typed text survives, the arrow walk resumes
immediately, and the row stays the walked row. stopPropagation
keeps the window chain from reading this Esc as abort-turn.
2. SPACE DESELECTS A SELECTED OTHER: the walk's Space on the Other
row selected it; now Space on an already-selected Other CLEARS
the pick — focus never drops back into the typing field (the
core's toggleOther has no single-select off-path; the off-path
lives in kbdAct, keeping `selected` intact in multi). Re-selecting
— walking back and Space, or a click — is the way back into the
field. Clicking the selected row keeps its old meaning (choose
Other); only the walk's Space gained the off-path, per spec.
3. MULTISELECT CHECK GLYPH BIGGER: the ✓ inside the multiselect
checkboxes drew at the 11px text-xs rung — small next to the 16px
box. Now text-base (14px on this app's ladder) + leading-none.
The single-select dot and the quiz's rows are unchanged. (Pinned
live: 14px computed in the 16px box; the span only mounts on a
checked row, so no static pin — noted in the render test.)
Live-verified on :3001 (fabricated ask chats, keyboard-only flows):
select via Space focuses the input; type; Esc → focus BODY, row
still selected, text kept; Space → deselected, focus stays out;
re-select → input focused again; listed option displaces in
single-select. Multi: Other coexists with checked options through
the whole sequence. 901/901.
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.
The owner-locked extraction-UX redesign (dispatch 2026-09-24), UI
chunk over Platform's data layer (eed4b4e — GET /api/chats/[id]
carries `coverage`, the threshold is Graph ▸ extractionChunkTokens):
1. SIDEBAR BLOCK (components/extraction-coverage.tsx +
sidebar's session menu): the coverage info block at the TOP of
the context menu — uncovered tokens, uncovered messages, running
state — then the Extract affordance (optimistic "extracting… /
Running" until the server says running; 409 = benign, the trigger
raced us), fetched ON MENU OPEN (the block mounts with the menu —
on-demand, never eagerly), polled at 2s while a run is in flight.
Visually separated from Rename/Delete by a hairline; those stay
the two session-management actions. Failed read → nothing (never
fake zeros).
2. RING METER (CoverageRing, chat top bar, before the PhaseChip):
a 16px donut filling uncoveredTokens / extractionChunkTokens —
"how close this session is to its next extraction run". Click →
the exact numbers (covered/uncovered tokens, messages, the
threshold, lastFoldedAt via timeAgo). Refresh chains: mount, the
turn-settle bump (chat.tsx coverageTick), the settings signal
(threshold/kill-switch re-read live), a 2s drain-poll while a run
is in flight, and the ACTIVITY FEED — a run for this session
appearing or settling in the dock's feed re-reads coverage (runs
fire from other surfaces — the sidebar menu, the turn pipeline —
and no turn-settle bump announces them; the runs-empty-transition
the graph view rides). Vanishes entirely when the graph is off or
before the first honest read. Running = the whole ring PULSES
(implementer's visual call: motion is the working-state signal in
this app's quiet register).
3. PHASECHIP UN-PILL: the phase label is a flat tag — no border, no
rounding, no padding. The Plan chip is the only button-shaped
thing in the top bar. Drag-surface (pointer-events-none) and aria
semantics unchanged; the transcript PhaseDivider keeps its look.
4. PANEL DEATH: components/graph-coverage.tsx, its graph-view
mount, and tests/render-graph-coverage.test.tsx die whole.
Confirmed: after this, NOTHING client-side consumes GET
/api/graph/coverage — the only remaining client use of the
coverage endpoints is the Extract POST (kept, by design); the
routes stay (Platform's call).
Pure seams exported for tests: ringFill (clamp math),
CoverageMenuBody, CoverageRingGlyph, CoverageRingDetail.
901/901.
Live-verified on :3001: ring fill arcs match the API byte-for-byte
(483/4096 = 4.45/37.70; 1991/4096 = 18.32/37.70); the sidebar block
reads "2k uncovered / 4 messages unextracted"; a real Extract run
went optimistic → dock run → menu settled "fully extracted" AND the
ring drained to the empty arc (the feed chain); graph off → the ring
vanishes; the flat phase label renders chrome-free beside the Plan
pill.
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.
Platform's context-meter pair (04e336d, 18cc493) left two client seams
in my band; this closes the arc:
1. STATS-BAR CATALOG JOIN: contextWindow's denominator resolves ONLY
from pi's live catalog — stats-bar now builds the provider:id ->
contextWindow index client-side (catalogLimits in model-picker.tsx,
the client twin of the server's catalogContextLimits join) over the
picker's existing /api/models fetch/cache (useModelCatalog exported;
module-level dedupe, one fetch feeds both ModelControl and the
meter). Catalog pending or failed -> undefined index -> limit null ->
USAGE-ONLY (Platform's interim render IS the null path; the
honest-render precedent, never a fake window).
2. DOCK NUMERATOR CONSISTENCY: toBlobs' context fold was still
last-step input+output only — now the same cache-inclusive footprint
the session meter uses (all four directions; provider "input"
EXCLUDES cacheRead/cacheWrite, so the old fold read wildly low on
cache-heavy jobs). Same metric everywhere.
Review of Platform's interim edits in this band: verified as-shipped —
stats-bar's usage-only render (summary + popover), FeedJob.contextLimit
with the line-hides null treatment, and the render-test rewrites all
fit. Only fix needed: none beyond the above.
Tests: catalogLimits pinned pure (index shape, provider:id keying,
missing-model absence); toBlobs pinned on the cache-inclusive
numerator (10k+0.5k+120k+30k = 160.5k against a 200k feed limit).
889/889.
Live-verified on :3001: seedplan's bar reads "18.7k / 1M" (footprint
18725, catalog 1048576); a nope/unknown-model chat renders "12.8k"
usage-only with cost "-"; the popover detail matches; the dock blob's
context line renders "946 / 1M" from a server-joined feed (an
unintentionally real runner-adopted job — the full chain, live).
Owner ruling (2026-09-23, amendment to 04e336d): no hardcoded fallback
exists anywhere. contextWindow(record, limits) resolves the denominator
ONLY from the live index (provider:id -> contextWindow straight from
pi's catalog); a model missing from it (historical session, package
update, failed fetch) reads limit null and the meter renders
usage-only — the honest-render precedent, never a fake window.
- src/core/prices.ts: the 1354-entry CONTEXT_LIMITS table (65KB) and its
generation apparatus deleted; the fold returns limit: number | null.
PRICES untouched (regen verified byte-identical via the reworked
sync-pricing script).
- scripts/sync-pricing.ts: generates PRICES only; contextWindow leaves
the priced map, the guard, and the template.
- The dock meter's owner-selected treatment: the activity feed carries
the limit server-side. catalogContextLimits() (server/model-catalog.ts)
builds the index over the same keyed providers the picker federates;
activityFeed() joins it onto every running job (ActivityJob.contextLimit,
null when the model is missing — the dock reads it off the feed, no
client-side table, no regression while the UI's catalog-join chunk lands).
- components/activity-dock.tsx: toBlobs reads job.contextLimit (import
of CONTEXT_LIMITS gone); stats-bar renders usage-only on null limit
(mechanical interim — UI stream's join chunk follows).
- tests: usage.test.ts pins the no-fallback contract (index hit / absent
/ empty / missing-model -> null); activity-feed.test.ts pins the
server join (catalog model -> 1048576, unknown -> null, no-usage ->
null); render pins updated to the interim usage-only render.
The owner's context-meter audit (2026-09-23), three fixes in the pure
contextWindow() fold (core/prices.ts):
- NUMERATOR: cache tokens count. Provider "input" excludes
cacheRead/cacheWrite, so input+output read wildly low on cache-heavy
sessions (40k shown vs ~190k real). The fold is now pi's own
totalTokens semantics: input + output + cacheRead + cacheWrite. The
fields were already persisted in every CallUsage (chat-handler's
capture seam); non-caching providers are unchanged.
- DENOMINATOR: the live catalog wins. contextWindow() accepts an
optional live-limits index (provider:id -> contextWindow — the shape
the client already holds from GET /api/models, where toModelDto
projects pi's Model.contextWindow); CONTEXT_LIMITS, the generated
duplicate, is demoted to fallback for models the live catalog doesn't
carry. The UI's stats-bar join passes its catalog index; the call is
unchanged when omitted.
- RIDE-ALONG (honesty guard): a zeroed/aborted last step falls back to
the previous step's footprint — an abort artifact is not a reading.
All-zero runs return null (no meter, never a bogus tiny number).
Tests pin all three: the cache-inclusive fold (and the plain no-cache
fold unchanged), live-index-beats-table + table fallback + unknown-to-
both null, and the zeroed-step fallback (previous step wins; all-zero
is null).
885/885 tests, typecheck/lint/build green.
Owner default change (2026-09-23): the factory strong-color token
moves to the softer coral. The factory lives in the stylesheet
token (both theme blocks in globals.css) - settings' boldColor:null
means "factory" by design, so no record/schema change. The settings
row hint + placeholder follow, plus the placeholder test and two
comments that cited the old literal.
Live-verified: unset boldColor renders rgb(238,146,140) (#ee928c) on
the real <strong>; dialog hint reads "Empty = #ee928c".
The owner's repro: the appearance Bold color never applied anywhere.
Root cause: streamdown's default `strong` component renders bold as
<span class="font-semibold" data-streamdown="strong"> - NOT a
<strong> element - while the app's ink plumbing (.lh-prose
:where(strong, b) in globals.css, zero specificity by design) matches
strong/b ELEMENTS. So bold text never took --lh-ink-strong: not the
factory #c96b4a, not any settings boldColor (it rendered plain
foreground at weight 600). Italic always worked because streamdown
leaves `em` native - hence the asymmetry the owner noticed.
Fix: a `strong` override in mdComponents (the stable module-level set,
so Streamdown's memoized tree keeps identity) renders the semantic
element: <strong data-streamdown="strong">. Screen readers announce
emphasis again, the designed token rule applies unmodified, and the
weight reads 700 (the .lh-prose rule) instead of the span's 600.
Both Streamdown surfaces (MessageResponse, reasoning) pass
mdComponents, so every markdown surface is covered.
Verified live: bold in seedplan now renders #c96b4a at weight 700 in a
real <strong>; after PUTting boldColor #7dd3fc the live strong flips
to rgb(125,211,252) with no reload; italic unchanged (#6fa9d6 native
em). Scratch record restored to unset.
Flag routed from the UI stream's rename-shuffle fix (a9339b3): the
positions ledger was the one remaining plain writeFileSync among the
rewritable ledgers — a torn write (crash mid-write) could replace it
with a half-written file, and readPositionMap parses a failure to {}
(honest empty, but the resting layout would be silently lost). The
tmp+rename pattern from rewriteLedgers, ~4 lines up the file: write a
uniquely-named temp, rename over the ledger — a crash leaves either the
old ledger intact or the new one complete, plus an orphan .tmp-*.
879/879 tests, typecheck/lint/build green; live-verified via the node
deletion's positions prune (no temp residue, valid JSON).
The live-rename interlude routed label changes through a fresh
snapshot object, which flowed setData -> geometry rebuild - and the
rebuild treats the last-fetched position ledger as authoritative and
cancels any pending drag commit, so a rename visibly rewound the
whole graph whenever display had drifted (recent drag, mid-anneal,
physics re-anneal).
- Renderer gains setClusterLabels (a setPhysicsTuning sibling): pure
applyClusterLabels patches the data-side cluster records and the
built region meta (re-wrapping the changed entries with the same
measure the build uses), markDirty only. Never touches nodes, the
sim, positions, or the pending commit. The hull-silence guard
travels inside it: a null-label cluster is a gone record - no
rename can reach it.
- The lh:chat-renamed listener stops building snapshot objects:
renameSessionsPatch writes the sessions entry in place (pure
display state - the detail card's provenance line), the cluster
label goes through the new channel, and a tick bump re-renders the
view. Identity preserved everywhere -> the [snapshot]-keyed
setData feed cannot fire.
- Hygiene (a): a fresh sim starts at alpha 1 and never decays while
stopped - the first post-rebuild drag re-annealed the whole graph
at full strength. buildSim now initializes alpha at the settle
floor, so first-drag matches steady-state drag. setPhysicsTuning
still rehearses alpha(1) when a full re-anneal is the point.
- Hygiene (b): nodes missing from the position ledger used to dump
at (0,0) via the ?? 0 fallback. seedMissingNodePositions seeds
them on a deterministic ring around the known positions' centroid
(FNV-1a hashed angle, golden-angle spiral offset).
- Hygiene (c) is store-band (writePositionMap's writeFileSync ->
tmp+rename): flagged for Platform, not crossed.
Tests: applyClusterLabels suite (patch + re-wrap, hull silence,
no-op, explicit null, unknown ids), renameSessionsPatch identity
pins (sessions map and clusters array identity preserved - the
contract that keeps setData from firing), seedMissingNodePositions
determinism + ring math. Live-verified: mid-anneal rename shows a
4-world-unit max disc jump (no teleport), the commit PUT survives
the rename, rest renames leave the disc build byte-identical, and a
held first drag displaces discs ~14 units instead of re-forming the
graph.
Owner polish dispatch (2026-09-23):
- Right-click no longer pins a node or opens the detail card: the
canvas pointerdown/up handlers now gate on the primary button, so
the context menu arrives alone.
- Node context menu: label is just "Delete" and the shell hugs its
content (w-44 -> w-fit); the menu body keeps focus for arrow
cycling but never shows the browser's default focus ring
(outline-none on the MenuShell body - applies to every menu).
- Delete confirm: "Delete node?" title in CMU Serif, red
(text-destructive); the node name sits below in JetBrains Mono
inline-code; the Delete button carries the destructive treatment
(border + tint, fill deepens on hover). Rest of the dialog stays
neutral.
- Sidebar rename row: text and metadata stay at the idle row's x -
the status spacer and the input's border/padding are gone; the
underline is the only editing cue.