Commit Graph
332 Commits
Author SHA1 Message Date
amosblomqvist af666aaa3f physics: bundles join their cluster; depth runs over the logical graph
Owner-approved brief (orchestrator, physics arc): two laws.

1. IMP-NODE CLUSTER ASSIGNMENT. Imp nodes were unclustered (the partition
   runs over stored propositions only) — no centroid pull, outside the
   painted hulls, bundles sitting on the periphery pointing outward.
   Now: a derived imp node's cluster is a function of its derivation —
   MAJORITY among its PREMISES' birth sessions (owner: premises, NOT the
   conclusion), ties to the chain's OWN birth (its provenance's first
   point — the asserting session; deterministic, coincides with the
   unanimous common case). Imp nodes are always re-derived, never
   birthed, so the assignment must be exactly this.

   Seam chosen: core/session-clusters.ts exports impClusterKey + a
   shared attachImpToCommunities that mutates a communities list's
   members IN PLACE. graphSnapshot attaches AFTER the hull filter (a
   2-proposition community + its bundle never resurrects a hull — the
   filter counts propositions); settleGraphLayout attaches to the
   unfiltered partition (settle pulls every community; hulls are a
   canvas-only concern). Both cluster-membership consumers — the sim's
   centroid pull and buildRegions' rasterization — read it through the
   members lists that went through the one helper, so the resting
   ledger and the live sim agree on where bundles belong.

2. DEPTHS OVER THE LOGICAL GRAPH. inferenceDepths ran Tarjan over the
   DRAWN edges (premise→imp→conclusion), inflating every multi-premise
   conclusion one hop (depth(imp)+1 instead of depth(premises)+1) and
   ranking the geometry point itself. Now:
   - deriveView additionally emits logicalEdges — every chain contracted
     to direct premise→conclusion arrows over PROPOSITION ids only
     (per-premise, deduped — a conclusion's rank is max over premises,
     which a single arbitrary premise edge could not express).
   - GraphSnapshot carries logicalEdges; createGraphSimulation /
     incrementalLayout accept a logicalEdges option: the depth
     machinery (orphan detection AND the rank pass) reads it, springs
     keep the drawn edges. Absent = edges (legacy behavior).
   - Imp nodes join the ORPHAN-EXEMPTION mask explicitly — never pinned
     to a level, keep the full regular forces (gravity, cluster pull,
     springs), settle naturally between premise and conclusion levels;
     the rank map is computed over propositions only, so arg_ ids are
     absent from it by construction.
   Wiring: settle + canvas (CanvasData.logicalEdges → buildSim) +
   replay-layout all pass the logical set.

   EXTRA FIX (in-slice discovery, one line, graph-server domain):
   server/graph.ts graphClusters — the practice picker's /api/graph/
   clusters — partitioned snap.NODES (the canvas image, imp nodes riding
   it since chains-primary), leaking arg_ ids into nodeIds: pickable ids
   that silently vanish inside chainsForSelection while nodeCount lied.
   Now partitions snap.propositions — same purity law as the chain
   table's 767a559 fix.

Tests (+13): impClusterKey pins (unanimous; cross-session majority; tie
→ chain's own birth, not the conclusion's); attach pins (members gain
the imp id, sorted; single-premise attaches nothing — no node exists;
dangling mints nothing; home absent from the list stays unattached);
snapshot pins (imp id rides cluster members to both consumers; the hull
filter counts propositions); logical-depth pins (multi-premise
conclusion at premises'+1 NOT +2; imp absent from the rank map);
depth-force exemption pin (rows pin crisply, arg_x floats unpinned —
old behavior stamped it onto the conclusion's row); deriveView logical
arrows (per-premise, deduped, proposition ids only).
2026-09-30 04:30:25 +03:00
amosblomqvist 719f7f9934 rendering arc: the imp node becomes the argument's geometry, not a fake proposition
Owner-approved brief (orchestrator): the canvas stops drawing implication
nodes as first-class nodes and starts drawing them as what they are — the
point where co-premise lines converge.

- deriveView (core/graph.ts): single-premise chains collapse to ONE direct
  premise->conclusion arrow — no imp node minted (a chain is always N-in/
  1-out; one premise is the degenerate case). Multi-premise chains keep
  the imp node + fan. Dangling guard unchanged.
- physics (core/layout.ts createGraphSimulation): per-link rest length —
  a link touching an arg_-prefixed endpoint gets HALF the base link
  distance, default or user-tuned alike; everything else about imp-node
  physics citizenship unchanged.
- canvas (components/graph-canvas.ts): imp nodes are PURE GEOMETRY —
  invisible (no disc, no label) and unpickable (excluded from pickAt's
  hit-test projection). Edges INTO an imp node: headless, shaft runs to
  its CENTER (toRadius 0, no head triangle). Edges OUT: shaft starts at
  the center (fromRadius 0), arrowhead lands on the conclusion's disc
  edge. Single-premise direct arrows render as today's plain arrows.
- comment repairs (same commit): the two stale double-shaft claims
  (CanvasEdge doc + the draw-pass block) rewritten to the truth — plain
  single shafts; bundles are headless converging lines + one arrowed
  out-edge; no double-shaft glyph exists anywhere.
- tests (+9): deriveView pins (single-premise collapse, multi-premise
  fan, shared-conclusion distinctness, dangling drop); imp-link half-
  distance settle pins; arrowGeometry zero-radius laws + isImpNodeId
  pin; graph.test.ts's snapshot invariant now counts only MULTI-premise
  live chains (its fixture's single-premise chains mint zero imp nodes);
  layout settle fixture updated for the collapse.
2026-09-30 04:04:29 +03:00
amosblomqvist 8d72de596a fix: chain table reads the stored propositions, not the derived canvas image
The owner's live test surfaced it: after importing notes, the chains view
listed implication nodes as bogus singleton "chains" — and each carried
the same arg_ id as the real stored chain, tripping React's duplicate-key
error (chain-table.tsx:372).

Root cause: graph-view passed snapshot.nodes — the derived canvas view
(propositions + implication nodes) — into ChainTableOverlay, whose
chainsForSelection turns every unchained node into a singleton chain.
An implication node is never a chain member (members are proposition
ids), so every imp node minted a bogus singleton with a colliding id;
its row was even selectable, leaking arg_ ids into practice input.

Fix (owner-approved shape): GraphSnapshot gains `propositions` — the
stored-truth node list riding beside `chains` for exactly this class of
consumer. The chain table now receives snapshot.propositions; the canvas
keeps snapshot.nodes (imp nodes are meant to be drawn). Practice pipeline
unaffected (it already read getGraph, which is propositions-only).

Test: the snapshot split pinned as an invariant in graph.test.ts — image
= truth + one imp node per fully-live stored chain; truth never carries
an arg_ id.
2026-09-30 03:41:41 +03:00
amosblomqvist 4985b6f9fa chains-primary: stored chains replace typed edges; edges become a derived view
Owner rulings 2026-09-30: reasoning chains are the stored truth; the graph
canvas renders a lossy derived image. No migration, no backcompat — old
graph data was wiped.

- contracts: GraphChain (arg_-minted id, premises, conclusion) replaces
  typed edges; GraphEdge survives only as the untyped derived arrow;
  GraphSnapshot carries stored chains; supports edges retired entirely
- store: chains.jsonl ledger (edges.jsonl purged at load alongside legacy
  events.jsonl); readChains/appendChain seam; merge dedupes by
  (sorted premises, conclusion), re-assertions append under the same arg_ id
- core: deriveView mints implication nodes (name ⟹, statement via
  formatChainLine) plus premise→imp→conclusion arrows; clusters stay
  proposition-only (birthSessionPartition); chainsForSelection now groups
  STORED chains (chain rides iff every member selected) — independent
  arguments for one conclusion never union; chainsToGraph/chainsToEdges die
- extraction: extractor submits chains natively (expandFindings deleted);
  auditor receives propositions only; supports fan-out gone
- deletion: full death of a proposition kills every referencing chain;
  arg_-prefixed node deletes kill that chain; chainsRemoved stats
- UI: every arrow renders the ⟹ double shaft; supports toggle + thickness
  slider and detail-card edge sections deleted (card keeps name +
  proposition); chain table + practice pipeline read stored chains
- tests: 1188 green across the new seams
2026-09-30 03:22:52 +03:00
amosblomqvist 08e64466ad chain line: the logic is the connective — LaTeX ∧ and ⟹ via KaTeX (owner ruling)
'A → B → C' misread as chained implication when A and B are
CO-premises. The honest shape (owner, 2026-09-30): premises join
with the LaTeX LAND (∧), the conclusion separates by the LaTeX
IMPLICATION (⟹) — A ∧ B ⟹ C.

The glyphs are REAL KaTeX renders — components/latex-glyph.tsx wraps
katex.renderToString (cached per command; the app's math engine, the
stylesheet already rides app-wide from app/layout.tsx). No SVGs, no
font-dependent unicode. PROMINENT in the theme foreground (white in
the dark theme) per the owner's same-round ruling — the logic is the
payload, not the old dim gray. A lonely chip carries no glyph (it
reads as a standalone proposition); the plain-text '→' survives only
in the row's aria-label (formatChainLine, the screen-reader form).
2026-09-30 01:35:59 +03:00
amosblomqvist e7f4923159 deletion screen minimal + 'node' as THE UI word (owner ruling)
Owner 2026-09-30: the confirm dialog's pre-flight impact read had to
go — what full death takes is obvious, the per-open fetch flickered,
and richer facts would only confuse as deletion semantics evolve
(rewiring is on the table). The screen is now: Delete node? / Delete
N nodes?, the names in the mono block, one permanence line, Cancel /
Delete. No loading state, no gone state (a 404 mid-run lands in the
honest failure line), no sessions join — the joins prop died too.

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

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

Rewritten dialog pins (tests/delete-node-dialog.test.tsx): minimal
contract — no facts/loading/fetch copy, singular/plural titles, the
cap, 409 dock phrasing, busy states, no 'proposition' left in the
dialog.
2026-09-30 01:19:03 +03:00
amosblomqvist 40dc1b7f85 chain table: the fenced Delete action + multi-node delete dialog (owner ruling)
"This is really the view deletion should be done in" — the chain table
becomes the graph's MANAGEMENT surface. The floating selection bar
gains Delete FENCED at its right end (owner-approved placement): a
divider quarantines the destructive action from Clear and Generate,
red destructive pair + trash icon, opening the SAME full-death confirm
dialog the canvas uses.

DeleteNodeDialog extended from one node to an array:
- one node renders exactly as always; many pluralize the title
  (Delete N propositions?) and list the names in the mono block,
  capped at 8 + 'and K more'
- the pre-flight impact read aggregates: summed edge counts, unioned
  referencing sessions (deduped, first-seen order); all-404 = gone
- confirm runs the per-node DELETEs SEQUENTIALLY — a failure stops
  the run (extraction_running is never plowed through); onDeleted
  now carries every id that actually died
- the chain table prunes its selection on delete and announces
  lh:graph-revision so the snapshot refetches

Drift fix found en route (surfaced as NaN in the aggregated facts):
the server's NodeDeletionImpact has been {edges, sessions} only since
the quiz-events ledger died in the practice-arc schema slim — the
dialog kept rendering quizEvents/observations as undefined. The dead
fact lines are removed; the dialog now shows exactly what the server
can honestly report.
2026-09-30 01:03:59 +03:00
amosblomqvist 4d58aa74b1 top bar drag standardization: auto carve-out + the overlay DOM law (owner ruling)
The chains-overlay TopBar had dead drag zones (counts, around search,
close gap) — cause: app-region subtraction resolves by DOM ORDER, not
z-index; the graph chrome's lh-no-drag column came later in the DOM and
punched holes through the overlay's drag band despite sitting visually
underneath (z-40 vs z-10).

Two laws, per the owner's standardization ruling:
- AUTO CARVE-OUT (globals.css): inside any .lh-drag band, every native
  interactive element (button/input/textarea/select/a/summary/
  [contenteditable]) is automatically a no-drag island — draggable
  anywhere that is not a clickable element becomes structural. Manual
  lh-no-drag is only for clickable non-natives.
- THE OVERLAY DOM LAW: a surface containing a TopBar that covers other
  content must be the LAST child of its container — ChainTableOverlay
  moved after the chrome column (verified live: last child, after the
  column; auto-carve rule compiled; search/close/sidebar-toggle all
  still click).
2026-09-30 00:44:08 +03:00
amosblomqvist edb10e3791 chain table round 6: the thread bracket law (owner refinement)
The tree threads no longer touch their arrows. Each thread is
BRACKETED by checkbox bottom edges (live-measured anchors):
- starts at its parent row's checkbox bottom edge — a visible gap
  below the arrow's stroke (the stroke ends ~2.4px above the anchor)
- terminates exactly at the LAST child row's checkbox bottom edge
  (cluster: top-[23px]/bottom-[8px] incl. the 1px group rule; chain:
  top-[22px]/bottom-[8px]). Verified live: 23=23 top anchor, bottom
  ends within 0.13px of the last checkbox at both levels.
2026-09-30 00:37:55 +03:00
amosblomqvist 40b0c33f93 chain table round 5: tree threads + the framed table (owner trial)
- TREE THREADS: open folders and chains drop a vertical guide from
  their expander arrow straight down through their children — at the
  arrow's own column (37px clusters / 73px chains), subtle border/50.
  Live-measured pixel-exact: arrow center = thread x (325=325, 361=361).
- The framed table: a rule under the header and under the last group
  join the between-group separators; rows stay lineless.
2026-09-30 00:28:47 +03:00
amosblomqvist b85aab6eb0 chain table round 4: double the indentation (owner refinement)
Each staircase step is now TWO columns (36px): chains pl-12 — the
checkbox aligned with the cluster's FOLDER icon — and statements pl-21
— the checkbox where a chain icon WOULD sit (the chains lost theirs;
the pattern continues anyway). Live-measured: chain checkbox = folder
icon (336=336); statement checkbox = chain checkbox + 36 (372).
2026-09-30 00:23:15 +03:00
amosblomqvist d6a7228b15 chain table round 3: waypoints icons gone + the alignment staircase (owner refinements)
- chain rows lose the WaypointsIcon (owner: not needed — the chips +
  connectors already read as chains); folders keep their icons.
- THE ALIGNMENT STAIRCASE: every level's checkbox sits directly under
  its parent's expander arrow. The expander now shares the checkbox's
  14px width (h-5 w-3.5), making each nesting step exactly 18px —
  clusters pl-3 (12px) → chains pl-7.5 (30px, the cluster expander) →
  statements pl-12 (48px, the chain expander). Live-measured: 318=318,
  336=336.
2026-09-30 00:15:47 +03:00
amosblomqvist 4fa6e2105a TopBar standardization + chain table round 2 (owner refinements, 2026-09-29 late)
- components/top-bar.tsx: THE shared top bar (standardization ruling) —
  h-15, lh-drag + carve-outs, select-none, SidebarToggle reopen with the
  Electron traffic-light gutter; adopted by chat (rename keeps its
  drag={false} escape hatch), new-session, practice-list, practice-exam,
  and the chains overlay.
- chain table: prominent indentation (pl-11 / pl-[4.75rem]), the ONLY
  lines are the separators between cluster groups (row + vertical lines
  gone), plain statement rows (no conclusion marker), all three columns
  sortable (Props too) with a small chevron on the active header.
2026-09-30 00:02:24 +03:00
amosblomqvist e2109d1c29 chain table redesign (owner refinements, 2026-09-29 evening)
- full-bleed: blur + translucent surface IS the overlay, no inner card;
  own top bar (info + search + close, lh-no-drag so home's drag mask
  never eats the X — the clickability bug)
- three levels, one click each: cluster (birth session; a chain lives in
  its CONCLUSION's cluster) → chain → proposition STATEMENTS revealed
  directly (names never twice; singletons one click); all collapsed by
  default
- proposition names as outlined chips, → connectors between; lonely chip
  = standalone proposition — the '(no premises)' annotation is dead in
  the visible UI (survives only as aria-label); Folder/Waypoints icons;
  rotating chevrons; deep statement indent
- real table look: hairline row + column lines; sortable Name + Created
  headers (click toggles asc/desc; size is text, not a control)
- selection never shifts layout: floating bottom bar (Google-Photos
  pattern) with N propositions → M chains + Clear + Generate
- fuzzy search grouped + auto-expanding clusters

live-verified end-to-end on the owner's dev server; 15 rewritten render
pins; gates: tsc 0, 1179/1179, lint 0, build 0
2026-09-29 23:21:51 +03:00
amosblomqvist 155d065fd1 practice: the chain table — every reasoning chain in one selectable overlay (owner-approved plan, 2026-09-29)
- core formatChainLine: names + arrow one-liner, singleton marker, pure
- graph-view Chains button (chrome row, empty-graph hidden) opens the
  overlay: fuzzy search (names/aliases/statements), collapsible chain →
  member → statement rows, birth-session + created columns, no
  understanding-levels column
- pinned Selected section derives the agents' actual input live
  (chainsForSelection over the selection; premise without its
  conclusion shows as the singleton it becomes)
- Generate opens the creation dialog in CUSTOM mode: selection summary
  + dated default title, posts {nodeIds, params, note}; cluster mode
  untouched on /practice
- live-verified on the owner's dev server: 219 chains, derived preview,
  search-never-filters-Selected, dialog handoff (selection pass-through
  bug caught + fixed en route), Esc/backdrop dismiss, zero writes
- practice UI files re-normalized to tabs (my earlier prettier runs had
  drifted them to the 2-space default; whitespace-only)

17 render/unit pins added; gates: tsc 0, 1174/1174, lint 0, build 0
2026-09-29 22:41:52 +03:00
amosblomqvist 06032769d2 practice: the learner's creation note — optional free-form input carried to partitioner + generators (owner, 2026-09-29)
- dialog: optional 'Anything else?' textarea (typing-only, 2000-char mirror)
- API: body gains note? — trimmed, whitespace-only omitted, hard 400 over 2000
- pipeline: note persisted on the set and threaded to both agent stages
- agents: THE LEARNER'S NOTE prompt block (present only when set) + the
  weighed-never-owed stance pinned in both doctrines
- fix en route: doubled THE PARAMETERS heading in both prompts since slice 3
  (renderParams owns the heading; the literal blocks removed)
2026-09-29 22:16:28 +03:00
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 ddce919f9b practice live-test fixes — scroll containers, session math delimiters, header guidance, sweep heartbeat (owner live test 2026-09-29) 2026-09-29 21:03:46 +03:00
amosblomqvist 88eb4b2ec0 practice restructure — per-problem jobs + ONE shared background-LLM pool (owner ruling 2026-09-29)
Rulings: each problem generation is its own visible dock job (the
extraction per-chunk pattern), and practice shares extraction's
semaphore — one process-wide 3-permit pool for ALL background LLM
jobs; total background LLM concurrency bounded at 3.

A. SHARED POOL: new src/agent/subagents/bg-llm-semaphore.ts —
   globalThis __lhLlmSlots, PERMIT_COUNT 3 (the ruling), the
   fresh-take/handed-off-permit deadlock discipline preserved for
   BOTH callers. extraction.ts's withExtractSlot is now a thin
   wrapper (job-row signal → withBgLlmSlot); __lhExtractSlots and
   __lhPracticeSlots both die. The partitioner now acquires a permit
   too (it's a background LLM job — it didn't before).

B. REGISTRY ROWS: the aggregate setId:generation / setId:grading rows
   are replaced by setId:partition (phase partitioning, label
   'Partitioning — title'), setId:gen:problemId (queued→generating,
   'Problem i/N — title'), setId:grade:problemId (queued→grading,
   'Grading i/N — title'). Each row carries its OWN abort controller
   and its OWN usage (one PracticeStepLog per row — the aggregate
   partitioner/generator/grader folding dies). Row key = feed id =
   abort key (the dock dup-key law).

C. ABORT SEMANTICS: abortPracticeRow(key) routes by shape — gen row:
   THAT problem only (error 'Aborted', siblings continue, set settles
   ready with one errored problem); partition row: the whole run;
   grade row: that grade drops (answer kept, re-submittable).
   abortPractice(setId) composes from row aborts + the synchronous
   persisted marks (a set-abort's verdict stands before the settles
   re-read it — the settle step keeps an existing error status).
   /api/activity/abort kind practice now takes the ROW key (charset
   admits ':').

D. FEED + DOCK: ActivityPractice/FeedPractice carry per-row identity
   (id = row key, problemIndex/problemTotal); dock blobs render per
   row, labels carry the i/N; the aggregate row is gone.

E. Boot sweep unchanged at set grain; the live-row check is now a
   key-prefix scan (any live row of the set protects it). The
   updatedAt heartbeat covers the new row scheme as-is.

Tests (20 in the pipeline suite): per-problem row shape/uniqueness/
i-N labels (partition row frozen behind a gated partitioner; per-row
usage ISOLATION pin — b's log never shows on c's row); abort-one-gen
(sibling completes, set ready with one errored problem); partition-
row abort; queued grade-row abort (dead-ticket path: answer kept,
re-submittable, sibling lands); shared-pool identity + contention
(practice queues behind extraction-style holders); registry/dock
feed mapping. Test isolation hardened: unique per-store set ids,
afterEach registry/lock/pool reset (a timed-out test can no longer
poison the rest).

architecture.md: jobs-pattern paragraph rewritten (per-problem rows,
shared pool, abort routing).

1149/1149, tsc 0, lint 0, build 0.
2026-09-29 20:42:33 +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 832c4f4cef practice slice 5 — UI: list page, exam page, creation dialog, sidebar entries
components/practice-dialog.tsx: portal dialog (delete-session shape),
three dial rows (SettingSlider markup, anchor text as display:
flashcard-competition / near the lesson-far transfer / theory-
application, defaults 0.5), session picker (generic mode, /api/chats)
or preselected lock (right-click path); 400 empty_selection renders
'This session has no propositions yet', 503 renders the server line +
Open settings deep link (useSettingsDialog, zero-keys precedent).
Pure body exported for SSR pins.

components/practice-list.tsx: rows (title, date, ready/total problems,
graded count, status word; destructive tint on error), click-through,
delete affordance → portaled confirm (DeleteSetBody: no mode checkbox
— a set has no preserve-graph question), 2s reveal-poll while any set
is partitioning/generating. components/practice-exam.tsx: numbered
problems; generating stubs (BrailleSpinner + 'Generating problem…',
propositions never surface); ready statements/solutions/rubrics/
feedback as MessageResponse markdown with restoreMathEscapes at the
display seam; per-problem textarea + useDictation instance with the
DictationBar rendered only on box focus; one Submit answers POST per
set; graded reveal (verdict chip, feedback, model solution, rubric)
with read-only answer; polling while not ready OR grading-live
(activity feed practice rows — dropped grades terminate the poll).

app/practice/page.tsx (force-dynamic, listPracticeSets) + app/practice/
[id]/page.tsx (getPracticeSet + notFound). Sidebar: quiet Practice nav
row under New session (GraduationCapIcon, active on /practice) +
'Practice this session's propositions' in the session right-click
below the hairline, above CoverageMenuBlock — opens the dialog
preselected.

tests/render-practice.test.tsx — 15 pins: dials + anchors, picker
selection gating, preselected lock, empty-selection line, 503 deep
link, list rows/counts/empty, delete confirm + inline failure, exam
generating (no proposition text, no answer boxes, no submit), ready
(markdown statement, gated submit), graded reveal + read-only + sibling
answerable, failed problem/set lines, math healing. 1129/1129, tsc 0,
lint 0, build 0.
2026-09-29 18:40:07 +03:00
amosblomqvist 27c5d9df19 practice slice 4 — pipeline, registry, API, feed
src/agent/subagents/practice-pipeline.ts (sibling of practice.ts —
orchestration vs calls): createPracticePipeline(graphStore+practiceStore
+chatStore+PracticeModels). Fire-and-forget front doors returning
{set, done}: createProblemSet (refuse → resolve selection (chatId =
ANY-provenance membership per brief; nodeIds raw) → chains → snapshot →
createSet partitioning → registry row → runPartitioner → stubs land
(generating) → ONE generator per problem behind the process-wide
3-permit abort-aware semaphore (__lhPracticeSlots, extraction idiom) →
per-problem failure isolation → ready (errored problems included);
abortPractice (idempotent: controllers + 'Aborted' marks; dropped
grades keep answers, re-submittable); submitAnswers (ready-only,
already-graded rejects, one grading job per answer under the same
semaphore); deleteProblemSet (abort-first). Per-set write mutex on
globalThis (__lhPracticeLocks, audit-lock idiom); registry
__lhPracticeRuns one row per set per kind; boot sweep marks orphaned
partitioning/generating sets 'Interrupted (server restarted)'. Typed
errors: EmptySelection/UnknownChat/SetNotFound/SetNotReady/BadAnswers.
Usage folds stage logs into the registry row (v1: completion-folded).

src/server/practice.ts: thin service — strict params parsing, typed
error → response mapping (503 envelope family, 400/404/409), list/get.
Routes: POST+GET /api/practice/sets (202 {id}), GET+DELETE
/api/practice/sets/[id], POST /api/practice/sets/[id]/answers (202).
Composition root: chat-handler wires createPracticePipeline (engineFor
fresh + providerKeyMissing gate) exported as ; barrel re-
exports. Feed: observability activityFeed() += practice rows
(ActivityPractice); dock blobs (process 'practice', GraduationCap
icon, set-keyed abort); /api/activity/abort kind 'practice'.

tests/practice-pipeline.test.ts — 13 tests: end-to-end (stubs mid-
flight via gated generators, coverage complete), failure isolation,
3-permit semaphore pin, abort mid-generation, registry shape pins,
refusal-before-create, chatId any-provenance selection, typed errors,
answers→grades land + graded rejects, boot sweep (orphan + live-row),
delete. 1114/1114, tsc 0, lint 0, build 0.
2026-09-29 18:31:53 +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 92146248d2 Per-chunk reveal: refetch the graph when a JOB row leaves the feed, not when the source drains
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.
2026-09-29 01:32:53 +03:00
amosblomqvist 4001c3465b Graph view: unique pill keys for chunk jobs (second duplicate-key consumer)
The graph page's pipeline pills keyed rows by run.chatId — the source id,
shared by every chunk job of a fan-out (the dock got the same fix in
8f66f47; this consumer was missed). /api/graph/status rows now carry an
id (the registry job key: source id, or 'src#i'), and the pills key by
it. chatId stays the SOURCE id — the reveal refetch and per-run cost
refresh track chatId SETS, which dedupe naturally and fire when the
source's LAST row leaves the feed.
2026-09-29 01:26:23 +03:00
amosblomqvist f72a3f3ada Activity dock: unique row ids for per-chunk extraction jobs (fix duplicate React keys)
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
2026-09-29 01:04:40 +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 14a7f0de1d per-run graph reveal: refetch the snapshot when a run leaves the status feed, not when the feed empties
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.
2026-09-28 18:21:20 +03:00
amosblomqvist fc8d91a043 first live-extraction findings: fix backwards edge-section labels; extractor naming/self-merge/completeness doctrines
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.
2026-09-28 17:48:51 +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 07d2c84461 dictation start-latency mitigations A+B+C+D (owner 2026-09-27): queue-don't-drop (frames before socket-open buffer capped and flush in order), parallel mic-gated handshake, app-lifetime AudioContext + memoized worklet module, warm-token cache (mount + post-run pre-mint, TTL 3min, reconnect mints fresh); optimistic meter stays per owner 2026-09-27 04:36:00 +03:00
amosblomqvist d680008f25 fix: teach divider prepended, not a replacement branch — first lesson block renders
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).
2026-09-27 00:48:46 +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 dcb839232c fix: plan graph visuals — reading font, 1px lines, both-ends arrow color
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.
2026-09-27 00:47:48 +03:00
amosblomqvist e9b9f52a7e fix: question dock flicker — arrival state at render, growth-stamped fold mute
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.
2026-09-26 23:53:43 +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 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 8e097752da fix: streaming appended a message per chunk - track the in-flight slot
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.
2026-09-26 02:25:04 +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 2e8bf92408 fix(answer): stash the answer input event before addToolOutput
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.
2026-09-26 00:51:16 +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 ac1c1a2818 Font picker fix round: one search field, honest empty-enumeration fallback, tooltip only on the closed trigger
- 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
2026-09-25 20:12:57 +03:00
amosblomqvist ad61301324 Appearance wave: searchable reading-font dropdown + emphasis inks default to text color
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.
2026-09-25 20:02:19 +03:00
amosblomqvist 3dfc331d7b Prompt editor affordances: version-in-use hint, dirty-gated Save, auto-on
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.
2026-09-25 19:18:57 +03:00