Commit Graph
109 Commits
Author SHA1 Message Date
amosblomqvist 9eb30edb40 Plan-graph refinement round 6 (owner): grayed arrows, static glow, glow-sized frame pad
- Arrows into not-yet-visited nodes are grayed out like the nodes
  (stroke-border line + a fill-border copy of the arrowhead); the
  in-chat block stays all-white.
- The yellow glow is static — the pulse is gone (owner).
- Smaller blur: stdDeviation 7 → 5, glow rect margin 10 → 8.
- The glow clipped at the frame on narrow graphs: its visible reach
  (GLOW_MARGIN + 3·GLOW_BLUR = 23 units) far exceeded GRAPH_PAD (8).
  Surfaces that show the glow now pad by GLOW_REACH — the popup's
  viewBox AND its fit box both use POPUP_PAD = GLOW_REACH, so the blur
  never clips; the monochrome in-chat block stays tight at GRAPH_PAD.
2026-09-14 07:06:25 +03:00
amosblomqvist 43ada6ce23 Plan-graph refinement round 5 (owner): the owner's arrowhead lands
arrowhead_1.svg (the CM/MJX long-arrow head) baked in — transform chain
collapsed to a clean scale 0.2 + y-flip: viewBox 0 0 50.2 104, tip at
(50.2, 52), notch mouth at x 27.2, tail opening 9.6 tall (≈10 at the
owner's full height 104).

Sizing per the owner's spec: line width stays 1.5; head height 16 makes
the mouth opening tuck the line flush (the LaTeX proportion). markerUnits
userSpaceOnUse, refX at the mouth (27.2) so the line terminates at the
notch — never at the narrow tip, where it would show through. Edges
shorten by the tip overhang (≈3.54 units) so the head's tip lands
exactly on the target node's border.
2026-09-14 06:59:38 +03:00
amosblomqvist 739a6aed56 Plan-graph refinement round 4 (owner): the simple white look + no entrance fly-in
Look (rectangular, white language — owner spec):
- Boxes: no corner rounding (rx dropped everywhere).
- Visited: fill-none, white stroke, white text. Upcoming: today's gray
  (fill-card, border stroke, muted text) — kept per owner. Current:
  white stroke/text + a faint yellow highlight — a blurred yellow fill
  (feGaussianBlur) that bleeds out over the node's edges, still breathing.
- Edges + interim arrowhead: white (marker swaps to the owner's SVG when
  it lands: right-pointing, solid fill, tight viewBox, tip at right edge).
- The IN-CHAT plan block renders monochrome — every node white, no
  status language, no glow; the popup/plan-window show the progression.

Popup entrance fix: the transition from round 3 animated the first fit
(graph flew in from the top-left zoom:1 state). The transition now stays
off until the first fit has been applied — the popup paints settled.
2026-09-14 06:55:10 +03:00
amosblomqvist b92237fd56 Plan-graph refinement round 3 (owner): higher zoom ceiling + eased zooming
- ZOOM_MAX 2× → 4×: node titles reach ~50px rendered, comfortable for
  close reading. Single tunable constant.
- Smooth zoom: a 180ms ease-out CSS transition on the transform wrapper —
  CSS retargets from the current computed value, so chained wheel ticks
  and the fit/button zooms all glide instead of snapping. Disabled while
  dragging (per-frame pans must not rubber-band). panning is React state
  toggled at drag start/end.
2026-09-14 06:30:05 +03:00
amosblomqvist 6464b34a79 Plan-graph refinement round 2 (owner): the popup fits, centers, and never clips
Root cause of the popup's fit/center failure: PlanDiagram rendered
w-full inside the popup's shrink-to-fit absolute wrapper — the svg
resolved to the popup's width regardless of its viewBox extent, so the
transform math (1 svg unit = 1 px at zoom 1) was off and the 'fit floor'
neither fitted nor centered.

- naturalSize: the popup's diagram renders at the viewBox width in px,
  so fit/pan/zoom math holds.
- The popup's fit box is now the PADDED ink box (GRAPH_PAD all around):
  centered strokes and the glow halo never clip at the frame edge.
- fitZoomFor lost its 0.15 artificial floor — for wide graphs it sat
  ABOVE the true fit and broke 'fully zoomed out = the whole graph
  visible, whichever axis binds' (owner's rule). The fit itself is the
  floor; a 0.02 epsilon only guards degenerate viewports.
2026-09-14 06:26:55 +03:00
amosblomqvist fdeacc42f5 Plan-graph refinement round 1 (owner): chat-font zoom cap + stroke-safe padding
In-chat full plan block:
- TEXT_CAP_SCALE (16.5/12.5): node text renders at text-xs in svg units;
  the block now carries maxWidth = extent × cap + mx-auto, so it still
  fills the column whenever the column is narrower than the cap, but
  never scales node text past the chat prose size (--lh-prose-size).
- GRAPH_PAD (8 svg units) around the full-extent viewBox: centered
  strokes (half outside the geometry) and the −4 glow halo were clipping
  at the frame edges — the padded origin (-8,-8) keeps all ink visible.
  Only the full-extent render pads; the plan window's override viewport
  and the popup's fit margins are untouched.
Test pins updated: mx-auto + max-width:232.32px + viewBox='-8 -8 176 152'.
2026-09-14 06:22:26 +03:00
amosblomqvist d7195e3659 Wave-5 refinement round 8 (owner): marks a hair smaller
size-4 → size-3.5 (~14px vs the 16.5px lh-prose row) — vertical center
kept by the row's items-center. Owner will refine further if needed.
2026-09-13 02:16:31 +03:00
amosblomqvist bd1c452bb9 Wave-5 refinement round 7 (owner): the correct pifont x glyph
\ding{55} = U+2717 BALLOT X — I'd extracted U+2715 (\ding{53}, a
different multiplication cross) last round by mistake. Marks now match
pifont exactly: check \ding{51} (U+2713), x \ding{55} (U+2717).
2026-09-13 02:14:07 +03:00
amosblomqvist 71792ccb64 Wave-5 refinement round 6 (owner): the true LaTeX dingbat marks
components/ui/glyph-mark.tsx: extracted vector outlines from the system
Zapf Dingbats font — U+2713 (LaTeX \ding{51}, the hand-drawn check) and
U+2715 (\ding{55}, the bulky cross) — as static SVG geometry on an
ink-bounded viewBox with a y-flip path transform (inverse of font axes).
This matches the pifont shape LaTeX renders, not a random glyph. Colors
stay: green/red like the verdict chips; the span keeps data-mark pins.
2026-09-13 02:05:04 +03:00
amosblomqvist cdc697749c Wave-5 refinement round 5 (owner): glyph-shaped SVG marks, vertically centered
- Option rows: items-start → items-center — the icon span's top-misalign
  came from the row alignment; now marks center across the row.
- The literal ✓/✗ look (owner preference over the round-3 lucide set)
  re-rendered as hand-rolled SVG with thick rounded strokes (strokeWidth
  3, round caps/joins) — the glyph shape, as a true vector, sized like
  the row text. Test pins updated to data-mark=check|x.
2026-09-13 01:57:17 +03:00
amosblomqvist 5f4d962168 Wave-5 refinement round 4 (owner): explanation flush-left
The muted 'Why' box's px-3 double-inset with card p-4 — the W-label sat
deeper than the option-box edges. px removed (py-2 stays for vertical
air); the label now aligns with everything else on the left edge. The
rounded bg-muted box is bg-matched so its padding was invisible anyway.
2026-09-13 01:33:23 +03:00
amosblomqvist d4212cecf2 Wave-5 refinement round 3 (owner): SVG answer marks + uniform input sizes
- ✓/✗ in the answered card: replaced literal glyphs with lucide CheckIcon/
  XIcon (strokeWidth 2.5) inside a fixed size-4 flex box — text elements
  never carried the right size/vertical alignment, vectors do. Test pins
  updated (lucide-check/lucide-x classes).
- All user-typed INPUT text now matches markdown output: lh-prose class
  replaces the earlier font-prose (family-only) band-aid on the composer,
  question-dock text/thinking/Other fields, home topic input, model search
  box, and answered-card 'your thinking'/free-text records. lh-prose sets
  family+size (16.5px) so placeholders inherit correctly.
2026-09-13 01:20:07 +03:00
amosblomqvist f1433306db Wave-5 refinement round 2 (owner's batch)
- Home view: StatsBar under the composer (model+thinking control) — the
  DOCK geometry matches the chat view (max-w-[46rem] px-4) so nothing
  shifts when a session starts; pre-chat selection persists via deferred
  save (ModelControl chatId-empty) + PUT after POST /api/chats.
- Answer marks (✓/✗): same visual size as row text, vertically centered;
  colored green-500/red-500 matching the verdict chips.
- All user-typed inputs (composer, question-dock text/thinking/Other,
  answered-card 'your thinking' and free-text records) now render
  Source Sans 3 via the font-prose utility. Placeholders inherit the
  family via the element.
- Answered-card explanation: markdown wrapper made truly inline so the
  'Why:' label + the paragraph sit on one line — removes the oversized
  leading gap.
2026-09-13 01:04:54 +03:00
amosblomqvist a5d3c73e7a Wave-5 refinement round 1 (owner's five fixes)
- New session: literal '+' glyph removed — one lucide PlusIcon (the
  duplicated glyph was PlusIcon + text '+').
- Top bar: a small pointer-events-none gradient under the header eases
  the scroller into the bar exactly like the dock's bottom fade.
- Option subtext (opt.description): .lh-prose — Source Sans 3; 'I don't
  know' copy stays JetBrains Mono.
- 'I don't know': now layout-matched with a radio circle; dashed border
  preserved as the deliberate non-answer.
- Separator spacing: options group pb-1 → pb-2 so the gap before the
  divider equals the 8px inter-option gap.
2026-09-13 00:46:31 +03:00
amosblomqvist fd4f988289 Wave-5 reskin batch 6: learner-authored text joins the prose surface
The plain user-text div renders Source Sans 3 too (.lh-prose) — the
teaching surface reads in one family, chrome labels stay JetBrains Mono.

Build green; gates clean. The reskin's final live-browser pass runs on
this tree (dev on 3001) — no screenshot tooling in this session, SSR
checked.
2026-09-12 23:50:40 +03:00
amosblomqvist 3360181e41 Wave-5 reskin batch 5: normalize stray pixel text sizes onto the declared ladder
--text-xxs (10px) joins the ladder; every component-level text-[9|10|11px]
literal in owned components now pulls from the ladder (graph-view.tsx and
home.tsx untouched — graph stream). Cosmetic pins updated where touched.
2026-09-12 23:48:27 +03:00
amosblomqvist 649495bf29 Wave-5 reskin batch 4: the width split — prose full-bleed, dock centered & narrower
COLUMN now spans the session area with a thin margin (px-6, no max
width); DOCK (the composer/question shell) is a deliberate narrower
46rem centered column — same elements, same places, width polish only.
2026-09-12 23:47:03 +03:00
amosblomqvist a6d3883422 Wave-5 reskin batch 3: header title in CMU serif, markdown gets lh-prose
- Session title: font-title (CMU Serif bold) at text-2xl, per the mock.
- MessageResponse's markdown container now carries .lh-prose (Source
  Sans 3 + emphasis tokens) on every markdown surface; labels stay
  JetBrains Mono. Unlayered plain CSS beats the text-sm utility.
- PhaseChip + PhaseDivider sizes now pull from the declared text ladder
  (text-xs) instead of pixel literals.
2026-09-12 23:45:44 +03:00
amosblomqvist 4caea4c8fb Wave-5 reskin batch 2: the session sidebar — LH wordmark, New session + Search rows
- 'Learning Harness' wordmark → 'LH' (the owner's header in the mockup;
  flagged as a copy change — it's visually the same brand mark).
- New session: + NEW SESSION row wired with one POST /api/chats then
  navigate (the mock drew it as visual-only; POST already existed).
- Search: a SESSIONS label + inline input filtering the polled snapshot
  client-side (title substring, no server round trip).
- Skin: sidebar stays bg-background with the edge #434343 border-r; the
  selected row now carries the rounding + borders from the raised
  surface #1C1C1C via accent tokens.
2026-09-12 23:41:06 +03:00
amosblomqvist 953b22be57 fix: the stats bar's controls were dead — pointer-events + button affordance
Live-driving the real browser caught it (the user's report: "they don't
work as buttons"): the stats row sits OUTSIDE the composer shell's
pointer-events-auto div, inside the sticky scroll wrapper's
pointer-events-none — every control on the row was unclickable, including
the pre-existing stats-detail popover button. The row now re-enables
pointer-events itself.

Also, per the user's sizing note: bar text 10px → 11px with a touch more
breathing room, and the model + thinking controls gain quiet pill
affordance (rounded-full border, card bg, ▾ chevron) — still muted by
design, but visibly buttons.

Verified live end-to-end in the browser: click opens the model menu ABOVE
(366 rows, search autofocused), "qwen flash" narrows to 5, picking
qwen3.8-flash persists + flips the label, the thinking menu shows exactly
that model's supported levels (Off/Minimal/Low/Medium/High), Medium
persists on the record via PUT, labels update. Test session settings
restored after the drive. Gates 350/350.
2026-09-12 06:11:23 +03:00
amosblomqvist de0c286311 feat: model + thinking control — the stats bar's picker, on pi-ai's catalog
The model-control wave (user-decided design, 2026-09-12): ONE controller
for the conversation agents (prober/teacher are functionally one agent);
background sub-agents stay config-pinned. pi-ai owns the registry — the
shipped 366-model OpenRouter catalog (cost, context, modality,
thinkingLevelMap), getSupportedThinkingLevels/clampThinkingLevel for the
per-model thinking truth, and streamSimple's automatic clamp. No second
registry to drift out of sync.

Seams:
- config.ts: ReasoningEffort retired for pi's ThinkingLevel
  (off|minimal|low|medium|high|xhigh|max). GLM roles go honest at "low" —
  pi's catalog says GLM-5.3-flash accepts exactly low|high|max, and the old
  "minimal" was silently clamped to low by streamSimple since the swap;
  nothing changes behaviorally. visualMaker → qwen/qwen3.8-flash (fixes
  the dead qwen/qwen3.8-max slug — the catalog only ships -0902; also the
  user's cost pick, 0.15/0.47 per-1M).
- src/agent/models.ts: piEngineFor(role, settings?) — provider+id resolved
  from the catalog, thinking level from settings ?? config, CLAMPED to the
  model's supported list (a settings change between models can never send
  an invalid level). PiEngine gains `level` (observability/tests).
- ChatRecord.settings {model?: {provider, id}, thinkingLevel?} — written
  by PUT /api/chats/[id]/settings (replace-whole, no updatedAt churn),
  read by chat-handler at turn composition: agents are stateless per turn,
  so the change is next-turn-effective by construction. The turns log
  still records the ACTUAL billed model (the honest witness).
- GET /api/models: the configured-provider catalog as client-safe DTOs
  (core/contracts/models.ts) + config defaults; a second provider joins
  the picker by registering it in piModels() — nothing else changes.
- PUT settings validation gate (src/server/model-catalog.ts): unknown
  provider/model or unsupported level → 400 carrying the supported list,
  never a silent clamp.
- scripts/sync-pricing.ts: regenerated PRICES/CONTEXT_LIMITS from pi-ai's
  shipped openrouter.json (offline; same source as the engine + picker).
  All 366 models priced; glm's stale 0.15/0.5 → pi's 0.075/0.25, limit
  1310720 → 1048576 — cost is inferred at read time, so history re-prices.
- UI (components/model-picker.tsx): stats-bar right side becomes model +
  thinking buttons; menus open ABOVE; fuzzy search over "provider id
  name" (core/fuzzy.ts — pi's /model pattern); model rows show name/id/
  price/context; the thinking menu lists exactly the chosen model's
  supported levels (GLM: low|high|max — no dead options, no "off").
  Model switch drops an incompatible pinned level (falls back to config,
  which clamps honestly) rather than carrying a 400.

Tests (350/350, +15): catalog projection + defaults, the settings gate,
pi thinking truth pinned against upgrades (glm low|high|max; minimal→low;
off→low — GLM cannot disable thinking), the engine factory's
override/fallback/clamp/throw matrix, fuzzy ranking, store round-trip,
and the re-priced folds (usage, stats bar, activity dock).

Live smoke (disposable driver, deleted): catalog route 366 + defaults;
glm+minimal → 400 + supported; turn 1 billed qwen/qwen3.8-flash under
the override; mid-session switch flipped turn 2 to glm; clear-overrides
restored the config pin. ALL CHECKS PASS on real OpenRouter.
2026-09-12 06:02:40 +03:00
amosblomqvist 740293c361 feat: custom message types for system turns — wake and verdict messages
The custom-message scheme (user-decided 2026-09-12): system-authored
user-role messages are NOT human messages — they carry marker data parts
only, the adapter owns their model-facing wording, and the UI renders
them under control. Nothing fake is ever persisted as human speech.

The inventory (all migrated):
- probe→teacher handoff wake: was a text-bearing user message rendering
  as a fake user bubble; now data-probe-handoff marker-only — the
  adapter flattens it to HANDOFF_TEXT (core/contracts/chat.ts), the UI
  renders a quiet 'Preparing your plan…' system line.
- plan accept/decline: was boilerplate text ('The plan is accepted…',
  'I'd like the plan revised…') masquerading as learner speech — the
  decline wasn't even marked; now data-verdict {accepted, at} on its own
  message, adapter flattens to verdictText(), UI renders the quiet
  verdict marker ('✓ Plan accepted' / '↻ Plan revision requested').
  sendVerdict sends TWO REAL MESSAGES when the learner typed a note
  (the note as a genuine user message, then the verdict) in ONE turn
  via setMessages + sendMessage() — no 409 dance, no double teacher turn.
- sub-agent steers: the abort steer gains its data-subagent-aborted
  marker part; the UI keys steer rendering off PARTS (artifact/aborted),
  not metadata — metadata stays as pump mechanics only.
- record-adapter: systemTextOf() flattens marker-only messages; steers
  keep their text (payload — briefs are content, not boilerplate).

phases.ts: plan→teach advances on data-verdict{accepted:true} only;
learner text alone can never sanction teaching (pinned). data-plan-
accepted retired, no back-compat (user-decided: wiped-data doctrine).

Pinned: record-adapter.test.ts (verdict/handoff flattening, note+verdict
order, steer payload kept, UI-only parts skipped), phases.test.ts
(verdict marker semantics, out-of-order, text-never-fakes), jobs.test.ts
(abort marker part), demos.test.tsx (badge renders off the part, not
metadata).

Live GLM smoke (disposable driver, deleted): probe→conclude→wake→plan→
decline-with-note (revised, still plan)→accept-with-note (teach) — ALL
CHECKS PASS, including marker-only handoff/verdict on the wire and zero
fake human speech in the transcript.

Gates: tsc, lint, build, 335/335.
2026-09-12 05:14:40 +03:00
amosblomqvist 4ba594bab5 fix(arch): parse, don't cast — the UI parse seam for model-authored input
The architectural answer to the validation-crash question: validation IS
first at the model-facing boundary (pi validates arguments before
execute; failures return to the model same-turn — all three live
incidents retried successfully). But the same bytes also exist UNVALIDATED
by design in two other places: the live wire streams raw tool-input
deltas DURING generation, and the transcript keeps the raw input of
REJECTED calls (honesty + the replay invariant). The consumers were
supposed to trust part STATES — but that trust was enforced by
TypeScript casts ('part.input as X'), which are compile-time lies with
no runtime check behind them. That is the hole both crashes came
through, and it should not have been possible.

The structural rule now: model-authored INPUT is consumed only through
parsers running the SAME zod schema the model faced — boundary-valid ⇔
consumer-parseable, mechanically. Server/learner-authored OUTPUT stays
trusted (our code authors it).

- src/core/tool-parts.ts (new): parseQuizInput (display parse over the
  shared quizInputBase — strips the teacher-only nodeId, accepts both
  prober and teacher boundary shapes), parseAskInput, parseProbeDoneInput.
- contracts/probe.ts: probeDoneInputSchema is now core-owned zod — the
  probe_done tool declares it (single source of truth), the seam runs
  the literal same schema.
- question-dock.tsx: the dock parses its input at entry; unparsable
  renders the quiet malformed-question fallback, never a crash.
- probe-done-card.tsx: the input preview goes through the parse; the
  old state reasoning and shape guards collapse into it.

Pinned in tests/tool-parts.test.ts: both exact live crash shapes
(stringified quiz options, stringified assessment), boundary-parity
cases for all three schemas, nodeId stripping, mid-stream partials;
render-cards pins the dock fallback markup.

Gates: tsc, lint, build, 328/328.
2026-09-12 04:51:39 +03:00
amosblomqvist e821c3277d fix: multi-select Other coexistence + the malformed-assessment probe-done crash
Two live bugs from the modal-realism stress runs:

1. Multi-select ask: picking the custom 'Other…' row cleared every
   already-picked option, and picking any option cleared a typed custom
   answer — single-select displacement logic applied to both modes. The
   dock's selection is now a pure state machine in question-core
   (toggleOption/toggleOther over DockSelection, unit-tested): single
   displaces (unchanged), multi coexists — neither channel clears the
   other; the typed text always survives.

2. ProbeDoneCard crashed on a REJECTED probe_done call: the model
   stringified 'assessment' (the same double-encoding habit as the
   options bug), zod rejected the call, the model retried — but the
   card's input-preview fallback trusted the errored part's unvalidated
   input shape and died on assessment.strands.map. The preview now reads
   input only while the call is pending or streaming (guarded with
   Array.isArray); an output-error part renders a quiet
   malformed-and-retried note instead of the garbage.

Pinned: question-core.test.ts (3 dock-selection machine cases),
render-cards.test.tsx (4 probe-done card cases incl. the exact live
stringified shape, plus the multi-select ask markup pin).

Gates: tsc, lint, build, 315/315.
2026-09-12 04:45:58 +03:00
amosblomqvist 377d4b6548 fix: the handoff freeze + the free-form mermaid pass
Two user-reported problems from the muscle-protein-synthesis session,
both diagnosed from the live data (chat_mtxeml9e_6118e59f).

The freeze: after probe_done, the open session never showed the
teacher's wake turn — only navigating away and back loaded it. Not an
agent-switch problem: the server-side handoff worked perfectly
(write-ahead persist, liveness registry, the sidebar's busy dot saw the
wake the whole time). The gap was client attach: the only mid-session
wake detector the open page has is the jobs poll, and probe-phase
sessions have zero job traffic, so that poll is OFF; the post-turn
adoption poll read turnInFlight=false at ~0ms (the wake fires at
~0.75-1.2s, after the pump's grace), adopted, and stopped. Fix: the
post-turn adoption effect now keeps a short WATCH WINDOW after adopting
(~4s, 8x500ms) — if turnInFlight flips true, it hands off to the
existing wake-adoption poll (busy shimmer, adopt at finish).

The mermaid failures: four failed plan_init calls, all caused by ONE
hidden rule — underscores are forbidden in node ids (mps_translation,
net_balance, per_meal). The id regex rejected '_' BEFORE the slug check,
so the specific, actionable message never fired; the generic 'can't read
the edge — expected a[title] --> b[title]' lied (the syntax was exactly
that), and the teacher spent three attempts hunting punctuation it
suspected (colons, commas, multi-word labels) — visible in its own
reasoning trail. Per the user's principle — enforce only the slugs,
free everything else: underscores are first-class slug chars; the id
CANDIDATE class is broad so every bad id reaches the specific slug
message; chained edges (a --> b --> c) expand to pairs; ==>, -.-> are
edge synonyms; any direction header accepted (we layout ourselves);
id["title"] quote pairs are stripped. The tool description, zod
describe, and the teacher's planning prompt now state the one strict
rule instead of the old subset liturgy.

Verified: tsc, 305/305 (the exact muscle-protein mermaid is now a
passing test), lint, build; real-browser puppeteer smoke on live GLM —
the teacher's wake turn appeared in the open page ~50s after the probe
concluded with ZERO navigation, verdict gate + first teach check +
plan window all live in-page (the single console 404 is the app's
missing favicon, environmental noise).
2026-09-11 23:54:06 +03:00
amosblomqvist b2c89b8c91 feat: the plan verdict gate — acceptance is the plan→teach seam
The accept-plan UI lands (docs/probe-teach-split.md §4, formerly the
interim rule): presenting the plan no longer moves the phase — the
learner's ACCEPT does. While the presented plan awaits its verdict, the
composer's send square becomes two stacked buttons (green Accept plan /
red Decline); typed text rides the verdict as his note, Enter keeps
sending plain side questions (phase unmoved), decline asks for a
revision and the buttons stay.

The seam: accept sends a user message carrying a data-plan-accepted
part (PlanAcceptedData in contracts/chat.ts, TeacherUIDataParts
'plan-accepted'); phaseAt3 reads it as the plan→teach event (plan_init
demoted to presentation-only); convertToModelMessages drops data parts,
so the teacher sees only the text. Mid-lesson rewrites need no fresh
verdict — acceptance happens once, at the first presentation.

The teacher's planning prompt now ends the turn at presentation and
waits for the verdict (never teaches into an unaccepted plan).

Gates: tsc clean, 304/304 (phases tests rewritten to the acceptance
seam incl. decline/side-question/failed-init cases; composer verdict
render tests), lint, build. Live GLM smoke ALL PASS: phase pinned at
'plan' after plan_init, presentation turn carried NO check, accept
click flipped the summary to 'teach', then tagged checks moved the map
(current=rates, visited=composite); zero console errors.
2026-09-11 21:26:29 +03:00
amosblomqvist 41d6ae531c feat: the plan window — the plan's ambient, live, auto-moving presence
The successor of the injected plan blocks (docs/probe-teach-split.md §6):
always on once a tree exists, a zoomed-in window (2:1, ~2¼ node widths)
centered on the CURRENT node, re-centering by pure derivation as tagged
checks move the lesson — statuses derive live from the transcript on
every render (derivePlanStatuses), glow on current, fallback focus on
the lesson's first node, click opens the existing full-DAG popup.
Bottom-left, mirroring the activity dock's bottom-right; hidden on
narrow viewports; the learner's visual polish pass comes later.

PlanDiagram gains a viewBoxOverride (exported); latestTree exported.
Tests: viewBox centering/clamping (pure) + render (hidden pre-plan,
windowed viewBox, glowing current, starting fallback).
2026-09-11 21:21:43 +03:00
amosblomqvist 5653c77a29 feat: probe/teach agent split — explicit phase markers, position-only plan map
Diagnosis (2026-09-11 user testing, minimal-effort GLM): sessions stuck on
'plan' forever. Two causes: (1) the three-phase view read the stored tree's
currentNodeId, which nothing ever writes post author-once (2026-09-03) — a
structural dead seam; (2) the teacher stochastically left quiz.nodeId
untagged, and done-by-quiz-result semantics would have falsely completed
nodes anyway.

The redesign (docs/probe-teach-split.md) abolishes derived occupancy in
favor of explicit events:

- Two agents over one transcript: createProberAgent (quiz without a
  nodeId field, ask, probe_done) and createTeacherAgent (full toolset,
  plan_init onward). The chat-handler construction branch keys on a
  probe_done part in the transcript. MODELS.prober added.
- probe_done: server-side seam tool — carries the learner assessment
  (strands + free-form edge read), refuses while a question is open or
  the probe is already concluded, and queues a handoff steer through the
  generalized wake pipeline (steers now dedupe by id, not jobId; job
  finalization only for job steers). The teacher auto-wakes and authors
  the plan; the learner sees the assessment via the probe-done card.
- phases: probe ends at a concluded probe_done, plan ends at a
  successful plan_init — strictly sequential, no skipping, no legacy
  compatibility (data wiped at the pivot per doctrine).
- Plan map = position, not completion: derivePlanStatuses reads only
  tagged quiz CALLS (state/outcome feed nothing); current = latest
  tagged node with consecutive duplicates collapsed, visited = earlier
  distinct stays (a revisit stays in both — current wins at render).
  PlanTree is structure-only; currentNodeId/doneNodeIds and the dead
  mutation helpers are gone. No fallback current.
- Prompts split per agent (prompts/prober/, prompts/teacher/ over the
  trimmed shared base); graph-context injection removed entirely
  (shelved, docs/probe-teach-split.md §5); knowledge-graph context no
  longer reaches either agent.
- Plan blocks are full-only snapshots at plan_init (neighborhood
  machinery retired; the persistent DAG window is the future UI wave);
  usage capture unchanged.

Gates: tsc clean, 294/294 tests (phases/plan-tree/plan-blocks/question-
tools rewritten to the new semantics; probe-done + handoff-wake tests
added), lint, build. Live GLM smoke PASS: prober concluded with an honest
assessment, teacher auto-woke, authored a 10-node plan with the block
attached, then tagged checks on slope-instinct → secant derived current/
visited exactly as designed; grading honest both ways (driver off-by-one
proved a wrong answer grades false).
2026-09-11 06:44:20 +03:00
amosblomqvist f4abad3f15 Master seam: plan-block emission + turn usage capture
- src/core/plan-blocks.ts: pure emission builder — full block on successful
  plan_init, neighborhood block when the derived plan state moved since the
  newest existing block; the as-of state reads from the block's frozen
  snapshot (transcript quiz parts mutate in place on answer, so
  re-derivation is post-mutation blind). Idempotent by construction.
- turn-service persistTurn enriches with withPlanBlocks at the canonical
  write; chat.tsx adopts the record once the server agrees the turn is
  done (endTurn flips only after persist).
- chat-handler: per-request usage collector via agent-level onStepFinish
  (StepResult.usage), appended at onFinish to ChatRecord.turns via
  appendTurnUsage, idempotent per response message id.
2026-09-11 04:07:12 +03:00
amosblomqvist 3e74f28e4f Dock END button speaks the real abort contract: already-gone is success, only true failures report
POST /api/activity/abort landed on main (fde46c2). 404 not_found /
409 not_running mean the process reached the desired end state on its
own — refresh and clear the abort state, no error surfaced. Genuine
failures (network/400) show 'Abort failed — try again' on the blob.
2026-09-11 03:34:03 +03:00
amosblomqvist 3b1d0ed18d Merge main: wave-4 backend (activity abort endpoint, lastStep capture) 2026-09-11 03:33:15 +03:00
amosblomqvist 99ba029a3c Retire the chat right sidebar: chat = sessions sidebar + chat column (wave-4 item 4)
The plan now lives IN the chat (plan blocks, wave-4 item 3) and background
processes live in the activity dock (item 1), so the right sidebar's
tree panel + sub-agent fleet column is retired from the chat view:

- delete components/tree-panel.tsx (three-mode rail/list/graph panel) and
  components/sub-agents-panel.tsx; their graph interaction machinery lives
  on in plan-block.tsx and fleet observability in activity-dock.tsx.
- app/layout.tsx drops TreePanelModeProvider (the mode no longer exists).
- chat.tsx drops the xl:flex right column; the middle column is the view.
- tests: render-tree-panel.test.tsx deleted; the sub-agents panel suite
  removed from demos.test.tsx (dispatch cards' jobs-context stays — the
  central poll drives transcript dispatch cards).
2026-09-11 03:33:13 +03:00
amosblomqvist bb369a077b Plan blocks in chat: full DAG borderless in the flow, fragment + glow, live popup (wave-4 item 3)
- components/plan-block.tsx: hand-built SVG (user decision) — deterministic
  layered layout (Kahn topo + longest-path depth, rows centered, authoring
  tie-break), bezier edges, the retired panel's status color language.
- PlanBlock renders data-plan-block parts AS CARRIED (frozen snapshots,
  never re-derived): kind full = borderless, viewBox = layout extent so
  nodes hug the column edges, natural height; kind neighborhood = own
  small labeled 'Plan fragment' container with the current node glowing
  (pulsing primary halo).
- PlanPopup: full-screen overlay with the LIVE plan — latest tree from the
  transcript, statuses derived at click time via derivePlanStatuses
  (contracts/plan.ts). Esc / backdrop / X close; drag pan, wheel
  zoom-to-cursor, zoom buttons; fit-floor semantics harvested from the
  retired tree panel (zoom floor = the fit, pan locks at it).
- chat.tsx: data-plan-block case in the part switch + popup mount.
- src/core/ui-message.ts: TeacherUIDataParts gains "plan-block": PlanBlockData
  (additive, type-only — lane touch flagged to master).
- tests/render-plan-block.test.tsx: pure layout (layering, centering,
  dangling-edge tolerance), wrap, frozen-snapshot render, live derivation.
2026-09-11 03:26:56 +03:00
amosblomqvist 17ed37a471 feat: abort sub-agent jobs (real kill) + runner usage capture into JobRecord.usage
Wave 4 (agent-side observability + control), reconciled with the dock
contract (master relay 2026-09):

- abortJob in src/server/jobs.ts, exported through the server barrel for
  POST /api/activity/abort (master's thin route): fires the job's
  AbortController (real kill — the in-flight model call aborts, not just
  the next loop check), drops any queued result steer, flips the record
  to error "Aborted by learner" + activity "aborted" synchronously, and
  queues a one-line abort steer (buildAbortSteer) through the same wake
  pipeline so the teacher's promise doesn't hang in the lesson. Shares
  the result steer's deterministic id space, so abort + racing result
  can never both deliver (steerDelivered dedupe).
- Abort registry (src/agent/subagents/aborts.ts) on globalThis, mirroring
  the wake pipeline's module-state pattern; runner registers/unregisters
  per run, so the route's module instance sees the loop's controller.
- Runner usage capture: LoopContext (abortSignal + onUsage) threads into
  all three kind loops; per-step usage folds via accumulateJobUsage into
  JobRecord.usage (model from config MODELS, absent-never-zero), flushed
  in runJob's finally so an aborted job's money record survives the kill.
- Contracts: JobUsage token fields optional + lastStep (most recent
  call's accounting, for the dock's per-blob context line vs
  CONTEXT_LIMITS); SubagentResultMeta.aborted; AbortSteerMessage;
  buildAbortSteer; SteerMessage union generalized.
- demo-builder/visual-maker accept abortSignal + onUsage (visual folds
  generateText usage per iteration).
- stillRunning/finishJob/jobContext guards: an aborted job is never
  overwritten by the runner (terminal no matter what the wake does);
  failJob and the pump's finalizeJob keep the flip.
- UI: abort steer renders a quiet aborted chip (job-aborted-badge).
- Prompt 20-jobs.md: one line about aborted jobs.

Tests: accumulateJobUsage folds (undefined-preservation, lastStep),
runner usage capture, real-kill abort (sync flip, silent unwind,
abort steer wakes), usage-so-far survives the kill, aborting a
delivering job drops the queued result steer, outcome reasons,
abort-chip render. 330/330, typecheck/lint/build green.
2026-09-11 03:25:09 +03:00
amosblomqvist df58bb87f2 The stats bar: context + cost beneath the composer, per-turn detail
One quiet row beneath the composer/question shell (not flush to the
screen bottom; static height, outside the morphing shell):

- LEFT: context window + session cost, clickable → per-turn detail
  popover (context, total, unpriced count, one row per turn with
  model/steps/cost). All folds from core/prices.ts — sessionCost,
  contextWindow, turnCost — no cost math in components.
- RIGHT: model + "Thinking —" — a placeholder slot for the coming
  model/thinking-level CONTROL (later work with its contract changes);
  display-only today, model read from the last turn's usage,
  pretty-printed (Glm 5.3 Flash).
- Data: the record's turns log, seeded on mount, refreshed when the
  local stream settles, kept fresh by the existing record re-syncs.
  No turns yet → "—" everywhere; unpriceable turns never fold into a
  fake $0 (the doctrine).
- formatUsd/formatTokens moved to components/usage-format.ts (shared
  with the activity dock); prettyModel joins them, pure + tested.
2026-09-11 03:15:55 +03:00
amosblomqvist e22f0bddb4 The activity dock: every background process, every view, bottom-right
One reserved corner of the shell, one blob per running BACKGROUND
process — session sub-agents (research/demo/visual) and global
extraction runs. Teaching turns are deliberately NOT here: the dock
exists to surface what the learner can't otherwise see; a running
teaching turn is already fully visible where it happens (user decision).

- Data: ONE poll of GET /api/activity (jobs across ALL chats,
  title-joined, oldest-first + extraction runs), folded client-side
  with core/prices.ts (jobCost). Adaptive interval — 2.5s while
  anything runs, 10s idle, 4× slower on hidden tabs; a failed fetch
  keeps the last feed (no flicker to empty on a blip).
- Per blob: kind icon, elapsed (ticking), cost, activity line, and an
  explicit END on the blob's face. Click → detail popover (context
  line, model, steps, open-session link). The context line is the LAST
  model call's tokens vs the model's limit (JobUsage.lastStep — pending
  agents-stream contract; "—" until it populates), NEVER a cumulative
  sum. Absent data renders "—", never zero (the doctrine).
- Header: total running cost (sum of priced blobs, "—" when nothing
  is priced). Narrow viewports collapse to a count badge → drawer.
- END posts to /api/activity/abort {kind, id} — the backend is being
  composed by master over the streams' mechanisms; until it exists a
  click locks the button and the popover says "Abort not available
  yet" (never a dead control).
- Idle renders NOTHING. Mounted once in the root layout; every view
  gets it for free.

toBlobs / formatElapsed / formatUsd / formatTokens are pure + tested;
SSR render tests pin blobs, the "—" doctrine, the face END, and the
priced total.
2026-09-11 03:12:32 +03:00
amosblomqvist b62fdbda64 Merge feat/graph-rendering: understanding-level closed scale, label declutter disabled 2026-09-09 06:47:52 +03:00
amosblomqvist a7e44a17cd Merge feat/ui-polish: plan-graph fit scaling, Your-thinking voice, Other option row, universal submit shortcut 2026-09-09 06:47:11 +03:00
amosblomqvist f6028524bd Merge feat/sub-agents: author-once plan protocol, steer-delivery starvation fixes, research-steer affordance
Wave 3: the plan protocol collapses to one re-callable mermaid plan_init
(seven mutation tools deleted by design; statuses derive from quiz checks;
live quiz-nodeId validation with mid-turn retry). The steer-starvation arc:
steers deliver coalesced with the learner's answer (claimSteers), persist
dedupes same-id continuation copies, jobs settle-sync defers while a turn
is busy. Research steers gain the collapsed full-brief affordance. Model
reasoning effort set to minimal across roles.
2026-09-09 06:46:34 +03:00
amosblomqvist 2535d7ad35 fix: defer the jobs settle-sync until the turn is over (coalescing follow-up 2)
With continuation coalescing, a job flips done at CLAIM time — while the
learner's own answer turn is still streaming. The poller's settle re-sync
fired on the flip, swapping in a mid-turn record snapshot mid-stream; the
steer after the assistant message broke the SDK's in-place continuation
merge, and the streaming response landed as a SECOND message with the same
id — duplicate React keys in the live transcript (the user's live test hit
it; the record itself stayed clean).

A settle during a busy turn (local stream OR server turnInFlight) now marks
a pending sync; the first idle poll tick adopts the finished record. This
also fixes the latent multi-job race: steer B settling while steer A's wake
turn is in flight would have grabbed the same mid-turn snapshot.

Out-of-lane surgical touch (flagged for master review): the wave-1 poller
block in components/chat.tsx.
2026-09-09 05:54:04 +03:00
amosblomqvist ba642416cd Universal submit shortcut: ⌘/Ctrl+Enter sends in every input
Key-convention policy, settled: every field accepts ⌘/Ctrl+Enter
(metaKey || ctrlKey — Mac gets ⌘↵, Windows/Linux Ctrl↵, the Gmail
convention); plain Enter stays matched to the field's species —
sends in message fields (chat composer, free-text answers — the
universal chat convention, Shift+Enter still a newline), newline in
the thinking field (composition, for dumping).
2026-09-09 02:25:27 +03:00
amosblomqvist abed10e13d Other renders as an option row in history; ⌘/Ctrl+Enter submits thinking
- The learner's own typed Other answer now renders INSIDE the options
  container in the answered card, styled exactly like its selected
  siblings (border-primary bg-accent) — it is an option, not a loose
  quote floating outside the list. Quotes keep the learner's words.
- The thinking field submits with ⌘↵ on macOS, Ctrl↵ elsewhere
  (metaKey || ctrlKey — the Gmail convention, same handler on every
  platform). Plain Enter stays a newline: the field is for dumping.
  A quiet "⌘/Ctrl + ↵ submits" hint sits next to Submit for
  discoverability (text mode keeps Enter-to-submit, no hint).
2026-09-09 02:16:03 +03:00
amosblomqvist 5f0a1ca30b Labels: disable sigma's label-grid declutter — the zoom gate is the only culling
Some labels (clustered nodes: Flux, The signifier rules the subject,
The body as window onto the thing-in-itself) appeared later in zoom than
others, without fade. Root cause: sigma's label grid — viewport cells
of labelGridCellSize (100px default) compete, only ceil(labelDensity /
ratio^2) winners per cell draw (one at fit with default density 1) —
so clustered nodes' labels were culled until zoom changed the quota,
then popped in with the global fade already complete.

labelDensity: MAX_SAFE_INTEGER turns the quota off. The camera-ratio
gate (LABELS_CAMERA_RATIO + LABELS_FADE_SPAN) is now the ONLY label
culling: every label appears at the same zoom with the same fade —
the wave-1 'all labels at once' intent, finally true.

Verified pixel-level at fit view: all 31 node labels render, including
the three previously-missing ones; no console errors.
2026-09-09 02:06:26 +03:00
amosblomqvist d057a5a616 "Your thinking": the learner's voice, first-class in the question dock
The note was the transfer channel of the whole product presented as an
afterthought: an unlabeled one-line slot whispering "optional". Now:

- A labeled composer-TWIN field ("Your thinking") between the options
  and Submit, docked with the submit button inside it exactly like the
  chat composer's send. The placeholder invites dumping ("Why this
  answer? What are you unsure about? Say as much as you like — the
  teacher reads all of this."); the word "optional" is banished.
- Enter makes a NEWLINE in the thinking field (it is for dumping, soon
  dictated); the button submits. Text-mode answers keep Enter-to-submit.
- Phase-scaled prominence: teaching = a generous dump area, probing =
  one quick row (threaded from the session phase via DockOverlay).
- "Other…" (ask questions) is now an editable OPTION ROW — radio circle
  that fills on selection + inline input, styled like its siblings — so
  picking Other never displaces the thinking field; typed text survives
  deselection.
- The answered card promotes the thinking to a quoted first-class
  block above the verdict, labeled "Your thinking" — the transcript
  reads as a dialogue of thinking, not a buried "Note:" footnote.

The frozen core is untouched (the teacher-facing "Note:" transcript
line in question-core.ts is protocol text, flagged to master).
2026-09-09 01:57:44 +03:00
amosblomqvist 0c12d53fca Background pipeline status: registry + /api/graph/status + graph-view chips
The background extraction pipeline is invisible on the home surface —
essential observability per the user's directive: the graph view must
show what is currently running (extracting / auditing / merging) and
for which chat.

- extraction.ts: module-level registry of in-flight runs (chatId,
  phase, startedAt), updated at each phase transition in runExtraction
  and cleared in finally (done or failed). extractionStatuses() is the
  read; one process, one registry.
- GET /api/graph/status: thin transport over the registry, chat titles
  joined from the chat store.
- graph-view: polls every 2s and shows one chip per running pipeline
  (spinner + phase + title + elapsed seconds, bottom-left); when a run
  finishes (some -> none) the graph refetches, so merges land live.
  Status is cosmetic — poll errors never surface.
- Unit test drives a mocked pipeline run and observes the registry
  mid-flight: 'extracting' during the extractor's generate, 'auditing'
  during the auditor's, empty after.
2026-09-09 01:26:49 +03:00
amosblomqvist 0fccbbe2ad Detail card: per-session understanding notes next to the session list
The extractor's per-proposition understanding observations (named-word
scale: solid/shaky/accepted/...) were ledger data since the redesign,
with no surface. This exposes them at read time and shows each
session's note as a small chip on its session row in the node detail
card — the per-session confidence history the user asked for, one
observation per (node, chat), latest wins within a chat.

- GraphSnapshot.nodeObservations: nodeId -> chatId -> note, built in
  graphSnapshot() from observation events (pure read-time join).
- Sessions list rows: link + observation chip.
- Session-relative judgments stay session-scoped: they render next to
  their session, never as node state (the state model stays pending).
2026-09-09 01:25:14 +03:00
amosblomqvist 54f88f37e1 Strip the transitional node-state classes (2026-09-08)
The five-class NodeStateClass (taught/known/missed/dont-know/candidate)
was transitional scaffelage from the 2026-09-02 redesign, deriving state
by latest-quiz-wins — exactly what the philosophy rejects: understanding
is inferred at read time from the real state model (still pending
ideation), never encoded as quiz-derived classes.

- contracts: NodeStateClass deleted; GraphSnapshot no longer carries
  nodeStates (objective graph + sessions only).
- core: deriveNodeStates replaced by latestQuizOutcomes — the raw
  event fact (nodeId -> latest quiz kind), consumed ONLY by the
  teacher's graph-as-context selection, never by the home renderer.
- graph-context: hotspot selection re-keyed to outcomes — groups are
  now 'Hotspots (last answer wrong or don-t-know) / Answered correctly /
  Never probed'. Same diagnostic selection, no state taxonomy.
- graph-view: nodes render uniformly (accent color, size 8); the panel
  is search-only; the detail card's state badge is gone. Quiz outcomes
  remain ledger data for the future state model.

Verified live (headless): uniform discs (single color/size, zero state
pixels), search-only panel, card = name + statement + sessions, no
console errors.
2026-09-09 01:24:08 +03:00
amosblomqvist bdd9cac2b7 Node schema pivot: short name (label) + full-precision statement; description dropped
With real extracted data, full-sentence statements as canvas labels proved
unworkable: 10-line label blocks, illegible overprint clusters, bottom-edge
clipping. Per the user's 2026-09-08 decision:

- GraphNode/ExtractedProposition gain `name` — a very short, glanceable
  label (glossary-headword style: "Will to power"), presentational only:
  not unique, not identity, never precise. Set at creation; link-appends
  never change it (first line defines; labels stay stable across sessions).
- `statement` keeps its full precision and becomes the identity anchor;
  the detail card shows it under the name. `description` is dropped.
- Extractor coins the name (freshest session context); auditor carries it
  through on create, rewording only clearly unglanceable names; linked
  propositions keep the existing node's name.
- Renderer: canvas label = name; search matches name + statement;
  detail card = name (bold) + statement (muted).
- Store guard requires name (pre-pivot node lines are skipped as legacy);
  vision.md node-schema bullet + amendments table updated (2026-09-08).
2026-09-09 00:40:02 +03:00
amosblomqvist 021c659d14 graph-view docs: labels now render statement (post-redesign rekey follow-up) 2026-09-08 22:43:17 +03:00
amosblomqvist 56ed1fea40 Sync main (graph redesign b07c907): extraction observability ported onto the redesigned pipeline
Conflict resolution: extraction.ts takes main's redesign wholesale and
re-applies 072671f's observability deltas (stage durations, pipeline
failure line) — log shape only, no behavior change. graph-view.tsx's
legend/filters (094bafa) rekeyed from node.name (removed) to
statement/description, matching the new GraphNode shape.
2026-09-08 22:23:07 +03:00