Commit Graph
204 Commits
Author SHA1 Message Date
amosblomqvist cf0ec4a2f0 practice UI round — top bars + sidebar reopen, one-attempt per-problem submit with instant reveal, score-only grading (owner 2026-09-29) 2026-09-29 21:54:32 +03:00
amosblomqvist 8d39f3717c practice bug fix — the boot sweep stamped a LIVE run (owner live test 2026-09-29)
Owner report: created a set, 'Interrupted (server restarted)' showed
on list + set views, generation proceeded and overwrote it. The
sweep's 'generating + no registry row' test had a false-positive
mode: it ran at EVERY pipeline instantiation, and an instance could
evaluate against a registry whose fresh row it couldn't observe.

Three guards, per ruling:

1. HEARTBEAT: ProblemSet.updatedAt? (contract + doc) — bumped on
   every store write; createSet stamps it = createdAt; saveSet
   re-stamps (validator tolerates absence for legacy rows).

2. ONCE PER PROCESS: the sweep guards on globalThis
   .__lhPracticeSweepDone — no instance re-evaluates after the
   process's first sweep.

3. GRACE WINDOW: only sets whose heartbeat (updatedAt ?? createdAt)
   is older than 120s are stamped. A live run saves at every phase/
   problem transition, so it is structurally unreachable; a true
   orphan is minutes stale at next boot. One [practice <id>] log
   line per stamped set (the extraction/jobs console.error idiom).

Tests: stale orphan stamped; FRESH generating set untouched (grace);
once-guard (second factory instantiation never re-sweeps); live-row
protection re-armed via resetSweep() test seam; store pins for
updatedAt birth-stamp + re-stamp + legacy tolerance. Architecture.md
sweep sentence updated (once-per-process + grace semantics).

1142/1142, tsc 0, lint 0, build 0.
2026-09-29 20:19:46 +03:00
amosblomqvist e92679254d practice — discretized parameter axes: named levels, never a number (owner-approved 2026-09-29)
Contracts: LadderRung 0-4, CueDistance 0-2, Presentation theory|
application; PracticeParams carries the three named levels. Doc
comments hold the three definition tables verbatim (rung names +
definitions from the ladder doctrine; cue levels; presentation
levels).

Core: LADDER_RUNGS/CUE_LEVELS/PRESENTATIONS tables in src/core/
practice.ts (definitions verbatim) — ONE source spoken by the agents'
prompts, the dialog's stepped controls, and the exam header; core
barrel re-exports practice.ts (pure domain, browser-safe).

Agents: Part I's ladder intro now 'five rungs, numbered; the request
arrives as a rung' with bullets numbered 0-4 (definitions untouched);
Part III's parameters block replaced with the named-level doctrine
(rung = exact class, no continuum; cue counts lesson examples AND
previous problems' situations; presentation dresses, never changes);
partitioner's RESPECT THE PARAMETERS replaced (low rungs = many small
assignments, high = larger; far cue makes confusables-together MORE
important; presentation does not shape the partition). renderParams
outputs the exact named-level block. Anchor/0..1 language swept dead.

Service + store: strict union validation — floats between levels,
out-of-range, wrong strings, legacy dial shapes all 400 at the API
and fail isProblemSet (legacy sets skipped from lists; no migration —
data/practice is disposable scratch, owner wipes before testing).

UI: dialog = 5-detent ladder slider + 3-detent cue slider (each shows
the level NAME + one-line definition) + Theory|Application segmented
toggle; defaults rung 2 / cue 1 / theory; Create posts the new shape.
Exam header gains the levels line ('Direct exercise · Adjacent
context · Theory').

Tests: renderParams exact-block pin; strict-validation pins (new
tests/practice-service.test.ts — bad unions 400, legacy shape 400);
store pins (legacy floats + 2.5 rung fail); render pins (detents +
names + definitions, defaults, detent-swap, toggle active segment,
levels line); every params fixture moved off floats. 1138/1138, tsc
0, lint 0, build 0.

Docs: architecture.md — discretization paragraph (compact tables,
named-level invocation, defaults, one-table source), UI mentions;
README axes sentence.
2026-09-29 20:06:35 +03:00
amosblomqvist a53127a764 practice fix follow-up — the picker lists EVERY birth-session group (owner ruling)
Owner escalation on deviation 1: practice availability must not
inherit a visual-rendering threshold. graphClusters() now runs
birthSessionPartition(snap.nodes) directly (same size-desc/id-asc
ordering) instead of reading the snapshot's MIN_CLUSTER_SIZE-filtered
clusters array — MIN_CLUSTER_SIZE stays a hull-drawing rule only.
Label join mirrors the snapshot's (chat title / notes path, null when
the naming record is gone). Contract doc + architecture.md sentence
corrected to match; empty-picker copy reworded ('once a session births
its first proposition' — no 3+ mechanics). Render pins: new empty line
+ a 1- and 2-proposition group listing pin. 1130/1130, tsc 0, lint 0,
build 0.
2026-09-29 19:28:51 +03:00
amosblomqvist f39e1b4294 practice fix — creation is graph-domain: cluster picker, nodeIds-only API (owner ruling 2026-09-29)
Owner ruling: 'problem sets always take in nodes — graph objects' —
practice is graph-domain, not chat-domain. No session-created sets.

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

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

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

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

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

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

1130/1130, tsc 0, lint 0, build 0.
2026-09-29 19:26:37 +03:00
amosblomqvist da0cd558d0 practice slice 3B — the practice agents: partitioner, generator, grader
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.
2026-09-29 18:14:30 +03:00
amosblomqvist 00b3c23e06 practice slice 3A — runSubmitAgent extracted to shared module; practice model roles
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.
2026-09-29 17:57:53 +03:00
amosblomqvist b7a19609cb practice slice 2 — store, paths, self-contained sets
Contract amendment (planning ruling): ProblemSet gains propositions
(nodeId → {name, statement}) and chains (the frozen derivation) — sets
are documents of what WAS selected; grading/dedup/coverage never touch
the live graph. config.ts PATHS.practice → data/practice/. PracticeStore
port (createSet assigns ps_<ts36>_<rand>, getSet, listSets createdAt-desc,
saveSet replace-whole, deleteSet). src/store/practice.ts: one JSON per
set, hand-written isProblemSet read guard (types-only; corrupt/missing →
null/skipped, never thrown; saveSet refuses invalid records). Wired in
src/server/stores.ts. 10-test suite: round-trip, corrupt-tolerance,
missing-dir, validator garbage-reject + optional-tolerate + verdict
union, ps_ id shape.
2026-09-29 17:53:36 +03:00
amosblomqvist 05dcaafc68 practice slice 1 — contracts, the chain bijection, coverage
src/core/contracts/practice.ts (new domain): ReasoningChain (group by
conclusion, id = conclusion node id), PracticeParams (three 0..1 dials),
ProblemSet/Problem lifecycle, feedback-only ProblemGrade. src/core/
practice.ts: chainsForSelection (follows-only, both-ends-in-selection,
group-by-conclusion, singletons for edge-less nodes), chainsToGraph
(the exact inverse — bijection pinned), chainCoverage (the completeness
law: union of problems' chains = the set's chains; duplicated allowed).
13-test suite: diamond, premise-only, singleton, supports-invisible,
outside-endpoint, dedupe, determinism, round-trips, coverage cases.
2026-09-29 17:48:20 +03:00
amosblomqvist 82503446c8 price-table re-sync — deepseek-v4.1-flash and 173 price corrections from pi-ai 0.87.1
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
2026-09-29 04:30:15 +03:00
amosblomqvist f5a9ed59c7 supports-thickness slider — relative shaft width for supports edges
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.
2026-09-29 03:00:57 +03:00
amosblomqvist 6543d4f886 supports-edges draw toggle — last row of the graph settings' first section
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.
2026-09-29 02:40:07 +03:00
amosblomqvist 8355fbabdb delete the mappings ledger — per-job in-memory resolution + notes fingerprint guard
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
2026-09-29 02:25:32 +03:00
amosblomqvist 3d9d20794c Auditor submits links ONLY — the edges argument is gone (owner, 2026-09-28 evening)
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
2026-09-29 01:16:32 +03:00
amosblomqvist 3e9a85d60f per-session models: every session owns its model + thinking level from birth
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.
2026-09-28 23:30:11 +03:00
amosblomqvist 1f5e21a674 teacher-prose-only extraction view: coverage, fingerprints, tokens, and batches derive from the teacher's text blocks alone (owner 2026-09-28) 2026-09-28 22:37:18 +03:00
amosblomqvist f11b1664dc verdicts-only auditor: submit_review is {links, bridging edges} — the findings echo dies
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.
2026-09-28 19:22:58 +03:00
amosblomqvist bd9694b0e4 slim the submit schemas: evidence and messages die; stringified-payload heal at the pre-validation seam
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.
2026-09-28 19:04:39 +03:00
amosblomqvist 1b50013551 extraction cleanup: events ledger dies, markdown source view, auditor-as-linker (2026-09-27 rulings)
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.
2026-09-28 16:54:44 +03:00
amosblomqvist 3160829eb6 explain refinements: no opt-out row, one committed field (owner 2026-09-27)
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.
2026-09-27 09:13:34 +03:00
amosblomqvist 2f39766528 free-recall explain tool: the quizzing-mechanic switch (owner 2026-09-27)
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.
2026-09-27 08:55:13 +03:00
amosblomqvist 07b25c4ffd de-meta the system prompts; method leads the composition (owner, 2026-09-27)
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.)
2026-09-27 04:14:42 +03:00
amosblomqvist 921cacbb8f display-math newline preservation (Option A ruling 2026-09-27): inline spans restore all six debris chars, display spans keep real newlines — multi-line equations never corrupted; \right/\rho CR-debris still restores; sloppy \neq-in-display accepted as the rarest slice 2026-09-27 02:24:10 +03:00
amosblomqvist 8faf97edc7 LaTeX-escape restoration at the display seam (owner ruling 2026-09-26): JSON-escape debris (FF ate \forall's f, VT \varphi's v, TAB \to's t) restored inside balanced math spans — one seam (tool-parts parse) heals new turns and poisoned records; outside spans untouched 2026-09-27 02:19:27 +03:00
amosblomqvist d6d7c09e2e quiz nodeId required (owner ruling 2026-09-26): no untagged teach quizzes — boundary rejects, model retries tagged; off-map checks route to ask_user_question; anti-rush doctrine in schema+prompt; docs+comments updated 2026-09-27 02:05:51 +03:00
amosblomqvist 145b470752 fix: verdict death — plan→teach seam inferred from first nodeId-tagged quiz
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.
2026-09-27 00:48:46 +03:00
amosblomqvist 876185eb6e chore: pi family 0.85.1 -> 0.87.1 (finishTurn, transcript contexts, typebox 1.3.27)
Mechanical fallout only; semantic deltas flagged in the wave report:
- shouldStopAfterTurn -> finishTurn ({action:'end'} wrap; loop order verified
  equivalent for Wave C stranding)
- transcript rework: seeded leading SystemMessage filtered at both
  state.messages read seams (ui-stream settleWith, pi-run slice); prompt
  and tool declarations now ride the transcript
- streamOnce sites normalizeContext() (Context -> TranscriptContext)
- AgentToolResult details / ToolCall.arguments JsonValue narrowing (casts)
- AUTH_PROVIDER_KEYS regenerated (meta added); glm-5.3-flash price pin
  follows the 0.87.1 catalog (0.15/0.5/0.05)
- vanish probe: 0/12 vs 2/6 baseline; live smoke green on PORT=3004
2026-09-26 21:45:51 +03:00
amosblomqvist 9853075331 fix: quiet Retrying lines, quiz option descriptions nuked, dividers at the markers
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).
2026-09-26 19:08:39 +03:00
amosblomqvist b84ab1cdc0 fix: reasoning always renders above its message; dock drops delivered subagents at delivery
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.
2026-09-26 05:46:33 +03:00
amosblomqvist 7f51259fc6 feat: Wave C - pi-native steer/follow-up delivery
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.
2026-09-26 03:46:09 +03:00
amosblomqvist 0803fbd88d fix: the empty-new-session bug — three live client defects
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
)
2026-09-26 02:13:18 +03:00
amosblomqvist f3003d357a Wave D: native AgentEvent wire + client session rewrite (SDK purged)
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).
2026-09-26 01:52:47 +03:00
amosblomqvist 7ab47d4fcb Wave B: the native message record — input events, projection, adapter death
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.
2026-09-26 00:37:46 +03:00
amosblomqvist b87a6925af Wave A: pi-native tool layer — delete LhTool/toPiTool, TypeBox everywhere, terminate on questions
- 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.
2026-09-25 23:32:13 +03:00
amosblomqvist e89d4c2e23 serial questioning + maximal formatting (owner ruling, 2026-09-26)
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.
2026-09-25 20:51:45 +03:00
amosblomqvist 95bc8d2261 launch message: reasoning before the plan (owner ruling, 2026-09-26)
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).
2026-09-25 18:55:31 +03:00
amosblomqvist d1542cdcbf quiz argument order: explanation before correctAnswer — reasoning precedes the pick (owner ruling, 2026-09-25) 2026-09-25 03:31:53 +03:00
amosblomqvist b3c07f89f4 Fix ⌘B: dispatch/listen event-name mismatch; thinking pill → "Thinking (⇧⇥)"
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.
2026-09-25 02:39:11 +03:00
amosblomqvist e4049e4bf8 Key-presence refusal at every gate: a pinned model is not enough
Owner-diagnosed: the turn gate checked only model coordinates, so a
pinned slot whose provider key was deleted passed the gate and died
mid-stream masked to 'An error occurred.' The key is now gated where
the model is — by what would RUN (env-first keyValue truth, so an
env-shadowed key passes).

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

Known residual (pre-existing, flagged for master): the manual extract
route logs BOTH config refusals as console.error (fire-and-forget path);
the coverage ledger keeps everything uncovered either way.
2026-09-24 22:51:12 +03:00
amosblomqvist 73027f49bf Cost channel cull: the cluster-naming fold dies — two stages, two channels
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.
2026-09-24 19:33:35 +03:00
amosblomqvist 2e9fd01c0c Extraction-UX data layer: the chunk budget becomes a graph setting; the chat view gets a per-session coverage read
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.
2026-09-24 15:12:24 +03:00
amosblomqvist 4ded547cb5 Context-meter amendment: CONTEXT_LIMITS dies — pi's live catalog is the only limit source
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.
2026-09-24 02:10:06 +03:00
amosblomqvist b68a16fdb4 Context-meter fold: cache-inclusive numerator, live-limits denominator, zeroed-step fallback
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.
2026-09-24 02:01:50 +03:00
amosblomqvist 2d133f1133 Single-node deletion: full death by node id (node interlude, owner-locked)
The remedy for undeletable nodes (preserve-graph orphans from session
deletion; notes-import nodes with no chat behind them): DELETE
/api/graph/nodes/[id], full-death semantics — the user pointing at the
node IS the intent; references from other sessions do NOT preserve it.

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

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

859/859 tests, typecheck/lint/build green.
EOF
)
2026-09-23 16:28:24 +03:00
amosblomqvist d1b46132b9 Wave 7 close-out: the embedding ledger seam dies; notes birth via mapping lines
Part 1 — the ledger + remnants interlude (master-dispatched, owner ruling
2026-09-23: nothing configures the graph beyond chat models):

- The embedding ledger surface is gone: readEmbeddings/writeEmbeddings
  ports, the EmbeddingsLedger contract, EMBEDDINGS_FILE (config, store
  impls, graphRevision source list), and graph-deletion's vector-pruning
  step. On-disk data files stay untouched (inert history).
- The extraction embedder seam dies wholesale: ExtractionPipelineDeps
  drops the embedder dep (chat-handler + backfill/import-notes stubs go
  with it), the 'embedding' phase leaves ExtractionPhase, the usage
  capture/persist helpers die, and stages.embedding leaves
  ExtractionRunUsage (historical records keep their shape — reads are
  lenient). Extraction is two stages again, like the local-embedder era.
- Consumer-less remnants: latestQuizOutcomes + QUIZ_EVENT_KINDS,
  clusterContentId, matchClusterLabelings + Community + ClusterLabeling,
  the readClusterLabels/appendClusterLabel port seams + store impls +
  LEDGER_FILES.clusterLabels. NamingRunUsage/readNamingRuns STAY
  (standing ruling — the cost pill renders historical naming spend);
  clusterHue + MIN_CLUSTER_SIZE stay (live).
- The 'labeler' role dies: BACKGROUND_MODEL_ROLES, ROLE_SLOT, ModelRole,
  the catalog pin projections, and the UI roster strings. RolePins keys
  shrink with the union.

The notes birth fallback (owner-approved 2026-09-23, surfaced by the
interlude's live smoke: production notes imports create provenance-EMPTY
nodes (importNotes passes messages: null — the file's anchor is the
mapping line), so the provenance-only birth rule left every imported
file unclustered and invisible to the prober's session list, against the
locked file-birth ruling. A provenance-empty node now births under its
FIRST mapping line's chatId (ledger order); only notes-born nodes ever
hit the fallback (chat nodes always carry provenance; a re-encountered
node's link-append re-opens the provenance rule). Deletion-safe: mapping
lines of deleted chats are stripped alongside their provenance.

Live-verified on port 3004: a fresh notes import clusters under its
src_ id with the file path as the label, end to end with no embedder.

845/845 tests, typecheck/lint/build green.
EOF
)
2026-09-23 15:37:34 +03:00
amosblomqvist 6b13583e96 wave 7: the prober reads its map — graph-context injection dies whole
The owner's zero-configuration ruling (2026-09-23): nothing configures
the graph beyond chat models. The prior-knowledge injection — its budget
dial, its runner, its marker part, its dock/dispatch entries — is
removed across all bands; the prober now reads the learner's persistent
knowledge map on demand through two gated tools instead:

- list_sessions: every birth session, names + kinds + counts + dates
  (graphSessionIndex, Platform's core seam)
- list_propositions(session): what was born there + the session's own
  latest understanding level + that evaluation's date
  (graphSessionPropositions) — names/levels/dating ONLY, no statement
  text (owner ruling: the map's organization is the prober's business,
  its prose is not)

Registered via createProberTools when chat-handler passes graphStore
(the graphToolsEnabled gate decided server-side, per-turn fresh); the
prober doctrine gains the map-review nudge at session start.

Also: runner's JobKind special-casing collapses (KIND_ROLE/KIND_RUNNERS
are plain records again), semantic-clusters.ts dies with its test, the
embedder dep's doc tells the wave-7 stub story, and ports.ts's
readClusterLabels/readNamingRuns surface stays untouched for Platform's
ledger-port interlude.

848/848 tests, typecheck/lint/build green.
2026-09-23 15:24:48 +03:00
amosblomqvist c86430e8bf Wave 7 chunk B: remove the embeddings machinery — the ladder collapses
The owner's wave-7 ruling: embeddings die entirely (git history is the
stash); clustering is birth-session derived (chunk A, 950ddfd), labels
are session names, and the prober's graph tools replace injection.

- settings: GraphSettings collapses to { enabled } — embedding,
  embeddingModel, clustering, labeling all die. Migration story: stale
  record keys are UNREAD and dropped by the sanitizer's reconstruction;
  the next graph-section PUT (replace-whole) persists the collapsed
  shape. The settings-panel GraphSection renders one master row.
- deleted: src/server/embeddings.ts (adapters, resolveEmbeddingPortFor,
  embedNewNodes, embeddingPort, embeddingModelCatalog), the
  GET /api/models/embeddings route, scripts/embed-graph.ts, tests for
  all three, and the /api/models/embeddings picker surfaces in the UI.
- prices: EMBEDDING_PRICES overlay + callCost fallback die. cost-summary:
  the embedding stage fold dies — back to three stages (extraction/
  audit/naming) per the owner ruling; historical stages.embedding spend
  in old run records is simply not displayed.
- ports: EmbeddingPort/EmbeddingResult/EmbeddingTask die. The embeddings
  LEDGER surface (readEmbeddings/writeEmbeddings + EmbeddingsLedger +
  EMBEDDINGS_FILE + graph-deletion's vector pruning) STAYS — inert data,
  kept only so graph-context.ts compiles until Teacher's pass removes
  it; the seam dies in the follow-up interlude.
- chat-handler (approved cross-lane unwiring, wave-6 precedent): the
  extraction pipeline's embedder becomes a documented no-op (nothing
  embedded, nothing spent, ledger untouched) until Teacher's pass
  removes the phase; the graph-context injection dispatch gate, the
  graphContext deps wiring, the setContextRespawn seam, and
  turn-service's triggerGraphContext are gone — injection is dead
  code pending Teacher's file deletions.
- scripts: backfill-graph/import-notes pass the same no-op embedder;
  replay-layout derives clusters from birthSessionPartition (the same
  partition the real settle uses) instead of the embeddings ledger.
- settings-body: parseGraphSettingsBody reads the master switch only —
  old-shape keys in a body are ignored, not rejected; bad shapes 400.
- next.config: the @huggingface tracing-exclusion guards die with the
  last reference.

Cross-lane flags: extraction.ts/graph-context.ts keep stale embed
comments + the embed phase until Teacher's full pass (their parallel
interlude owns those files — zero overlap kept by design);
semantic-clusters.ts + its test stay one more round (extraction.test.ts,
Teacher's file, still imports it — they delete it with the namer cases);
ChatSettings.graphContextTokens + the graph-context-control UI stay
until Teacher+UI.

Gates: tsc clean, eslint clean, 885/885 tests, build compiles.
Live-verified on the port-3004 dev tree: PUT /api/settings/graph with
dead keys collapses on write, bad shapes 400, GET reads {enabled: true},
/api/models/embeddings 404s.
2026-09-23 15:02:55 +03:00
amosblomqvist 0fcffdffaf Birth-session clustering + the prober graph-tools data seam (wave 7 chunk A)
Embeddings are dying (chunk B removes the machinery); the clusters they
used to carry become the session that created them — derived on read.

- New src/core/session-clusters.ts: birthSessionPartition groups merged
  nodes by their FIRST SURVIVING provenance point's chatId (owner-locked
  2026-09-23). Deletion is self-healing by construction: stripped-birth
  survivors re-attribute to their earliest remaining contributor, and
  empty-provenance nodes stay unclustered. Notes imports are birth-sessions
  too (owner ruling) — their label is the file path.
- graphSnapshot: clusters = the birth partition filtered >= MIN_CLUSTER_SIZE
  (the hull presentation rule), id = session id, label = session name (chat
  title / file path; a deleted record renders unlabeled). The persisted
  cluster-labelings join dies; no LLM naming pass needed anymore.
- settleGraphLayout derives the same partition from readNodes — drops its
  readEmbeddings dep. One cluster concept everywhere.
- Prober data seam (core/graph.ts, pure ports-driven like the snapshot):
  graphSessionIndex (every birth session: id, name, chat|file kind,
  createdAt/updatedAt, birth-proposition count, recency-ordered) and
  graphSessionPropositions (a session's birth propositions with the
  extractor's latest understanding level and THAT event's own timestamp —
  owner ruling: quiz outcomes are ledger evidence, they never move the
  evaluation date). Teacher's tool handlers consume these in-process.
- New agent.graphTools subsection { enabled?: boolean } (owner ruling:
  the toggle gates prober tool REGISTRATION — agent capability, agent
  section, its own subsection, NOT the tool-toggles map; absent = ON).
  Lenient sanitizer, strict settings-body parser, passthrough resolution,
  and graphToolsEnabled(effective) = graph.enabled && toggle for Teacher's
  registry wiring.

Gates: tsc clean, eslint clean, 923/923 tests, next build compiles.
Live-verified on the worktree dev data: 10 nodes → 2 clusters labeled
with their chat titles; session index + propositions carry levels and
their own timestamps.
2026-09-23 14:51:37 +03:00
amosblomqvist fb66606555 Wave 6 chunk B: the embeddings surfaces — picker, re-embed gate, gray-outs, cost pill
- Settings ▸ Graph grows the embedding-model picker (graph-row-embedding-model):
  GET /api/models/embeddings' live-federated BYOK catalog (re-fetched on mount
  and every settings save), rows priced from EMBEDDING_PRICES ("$x.xx/1M in",
  honestly "—" when unlisted), ids shown AND stored as full colon-safe slugs
  (openrouter:liquid/…:free is never split), plus an Unset row writing null.
  Empty catalog = nothing keyed: the trigger BECOMES the deep link to Keys
  ("Add a provider key first…") — and deep-links fired while the dialog is
  open now actually switch sections (initialSection was read once at mount;
  the body follows it now — pre-existing gap, first exercised by this link).
- Re-embed gate (owner design): picking a model that differs from the stored
  one holds the write behind a confirm strip carrying the honest node count
  from GET /api/graph; zero propositions skip the strip (nothing to re-embed)
  and land directly. Unset never confirms — the port just stops resolving.
  The copy says the re-embed runs with the next background pass (the staleness
  machinery — a settings PUT alone never triggers it; no server endpoints
  were added).
- Gray-out, the searchStatus precedent: with no model configured, the ladder
  rungs Embedding/Clustering/Labeling dim + disable with a "Needs an
  embedding model" hint (master switch stays lit), and the stats-bar
  GraphContextControl swaps its slider for a dimmed deep-link button to the
  Graph section. Loading never grays; every settings save re-reads (signal).
- Cost chrome: the pipeline popover grows the Embeddings stage row
  (costs.stages.embedding, wave 6's folded stage — "— · 0 in / 0 out" until
  runs exist, same as its siblings).
- Master's ride-along: the core-barrel ChatDeleteMode comment now says two
  type-only things (it always exported both).

Gates: tsc clean, 912/912 (8 new: price lines, model-dependency predicate,
unset/set SSR dimming; 2 ladder fixtures updated to the new no-model
semantics), lint 0, build ok. Live-verified on :3001: unset deep-link →
graph section, menu pricing + colon-safety (37 options), confirm strip with
"20 propositions", Cancel keeps the record, Unset writes immediately,
in-dialog empty-catalog deep-link lands Keys, Embeddings row in the popover.
2026-09-23 12:50:59 +03:00
amosblomqvist 1c27694dbc Wave 6 chunk A: sidebar context menu (Rename/Delete) + the two-option delete dialog
- Right-click any session row: MenuShell context menu, exactly two actions
  (owner ruling): Rename and Delete… — MenuShell discipline (placement,
  focus, arrow cycling, lh:menu-open announcements), agent-section's
  outside-click/Esc contract with [data-lh-menu] carve-outs.
- Rename is INLINE in the row (owner ruling): select-all entry, Enter/blur
  commits through the existing plumbing — PUT /api/chats/:id then
  lh:chat-renamed. The open chat header now LISTENS for the event too, so
  renaming the active session from the sidebar updates the header in place
  (it only dispatched before).
- Delete opens the two-option dialog (locked design, DELETE_OPTIONS pure
  seam for render pins): "Delete the session only" (preserve-graph — the
  graph keeps every contribution) vs "Also remove its graph traces"
  (delete-nodes — shared topics survive, only-this-session topics die).
  409 turn_in_flight/jobs_running and 503 extraction_winding_down render
  the server's line verbatim with a same-mode Try-again.
- Success: the sidebar patches its snapshot instantly, the active session
  navigates home (chat unmounts — drafts die with it), and lh:graph-revision
  announces the returned revision — the graph view refetches the snapshot
  AND costs (deletion is the one writer the status poll can't see).
- ChatDeleteMode type re-exported through the core barrel (the shell's one
  needed seam from core/graph-deletion.ts).

Gates: tsc clean, 909/909, lint 0, build ok. Live-verified on :3001:
context menu open/Esc, inline rename (row + API + active header), both
delete modes on scratch sessions, simulated 409 + retry-then-succeed,
active-session navigation home, graph+costs refetch deltas on home.
2026-09-23 12:45:12 +03:00