Commit Graph
462 Commits
Author SHA1 Message Date
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 07b25c4ffd de-meta the system prompts; method leads the composition (owner, 2026-09-27)
The owner's capture-vs-composition check confirmed the wire sends exactly
what composes (byte-identical save the nodeId paragraph ebfe058 had since
changed). This wave removes the implementation meta that leaked into the
prompts and reorders so the method carries weight:

- base: owner's new opener verbatim (install understanding; the
  understanding definition — which absorbs the old 'Understanding is
  structure' paragraph); Voice moved in from shared/10 (deleted); the
  philosophy keeps edge-dependence + logistics ('standard learning'
  dropped per owner); intro's two-agents/fragments sentence gone.
- teacher/05-method opens with the owner's methodology bridge ('No other
  teaching methods come close') — paragraph 3 of the opener, placed here
  so the shared base never points the prober at methodology it doesn't
  carry.
- deleted: teacher/60-probing-in-brief (self-evident from shared history;
  its mid-lesson mini-probe move salvaged into 20-teaching as one
  sentence), the 'Session shape — the durable artifact' section
  (extraction-pipeline meta), the fragments-meta lines in shared/20 and
  05-method, 'the transcript's seam' phrasing, prober's 'the teacher
  that takes over' / 'the handoff is automatic'.
- composition reorder (listSourceSegments): base -> the agent's own
  fragments -> shared/ appendix. Teacher method now starts at 12% in
  (was 62%); prober doctrine at 34%. shared/20 opens with the owner's
  decision rule (quiz only for known-correct-answer, ask for everything
  else).
- HANDOFF_TEXT: the owner's user-message version — first-person, paces
  the planning (unconditional truths, motivated derivations), ends at
  plan_init + accept-or-changes.

Teacher 31.4k -> 28.7k chars; prober 10.0k -> 9.6k. Gates: typecheck 0,
1018/1018, lint clean, build green. (Suite runs 16s; the earlier 10-min
timeout was stale node_modules after master's df1328e 0.87.1 sync —
npm install fixed it.)
2026-09-27 04:14:42 +03:00
amosblomqvist 921cacbb8f display-math newline preservation (Option A ruling 2026-09-27): inline spans restore all six debris chars, display spans keep real newlines — multi-line equations never corrupted; \right/\rho CR-debris still restores; sloppy \neq-in-display accepted as the rarest slice 2026-09-27 02:24:10 +03:00
amosblomqvist 8faf97edc7 LaTeX-escape restoration at the display seam (owner ruling 2026-09-26): JSON-escape debris (FF ate \forall's f, VT \varphi's v, TAB \to's t) restored inside balanced math spans — one seam (tool-parts parse) heals new turns and poisoned records; outside spans untouched 2026-09-27 02:19:27 +03:00
amosblomqvist d6d7c09e2e quiz nodeId required (owner ruling 2026-09-26): no untagged teach quizzes — boundary rejects, model retries tagged; off-map checks route to ask_user_question; anti-rush doctrine in schema+prompt; docs+comments updated 2026-09-27 02:05:51 +03:00
amosblomqvist 8c0f0f7fbf docs: planning prompt cut from 76 to 41 lines — thinking kept, machinery cut
The probe intro was redundant with 60-probing-in-brief.md (and its
no-probe fallback described a state the code can't produce — the teacher
only exists post-probe). 'How the map tracks itself' folded into one
tagging paragraph; grain and rewriting compressed; the live-map context
kept to one sentence. The discovery-chain thinking block stays verbatim,
and the stress-test stays. Presenting: ask him to confirm the plan
before teaching begins — medium unprescribed (owner ruling 2026-09-27;
dock-question confirmation leaves the teach divider floated next to the
planning divider when no text reply splits the group — accepted as
cosmetic).
2026-09-27 01:19:27 +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 876185eb6e chore: pi family 0.85.1 -> 0.87.1 (finishTurn, transcript contexts, typebox 1.3.27)
Mechanical fallout only; semantic deltas flagged in the wave report:
- shouldStopAfterTurn -> finishTurn ({action:'end'} wrap; loop order verified
  equivalent for Wave C stranding)
- transcript rework: seeded leading SystemMessage filtered at both
  state.messages read seams (ui-stream settleWith, pi-run slice); prompt
  and tool declarations now ride the transcript
- streamOnce sites normalizeContext() (Context -> TranscriptContext)
- AgentToolResult details / ToolCall.arguments JsonValue narrowing (casts)
- AUTH_PROVIDER_KEYS regenerated (meta added); glm-5.3-flash price pin
  follows the 0.87.1 catalog (0.15/0.5/0.05)
- vanish probe: 0/12 vs 2/6 baseline; live smoke green on PORT=3004
2026-09-26 21:45:51 +03:00
amosblomqvist 9853075331 fix: quiet Retrying lines, quiz option descriptions nuked, dividers at the markers
Three owner rulings from the gradient-descent session
(chat_muhskq6p_e8c3600b — 7 failed quiz calls, answers leaked in
option descriptions, planning divider floating at the transcript top):

1. RETRYING LINES (general): any tool call that errors back to the
   agent (validation failure, malformed args — the retry loop) renders
   one quiet "Retrying…" SystemLine instead of silence. Question tools
   previously rendered null, so a flailing quiz loop read as a stall.
   Aborts are terminal ("Operation aborted") and stay silent. Verified
   live: exactly 7 lines render at the 7 failure sites of the session.

2. QUIZ OPTION DESCRIPTIONS: NUKED. The model used them to telegraph
   correctness in every quiz of the session ("that moves uphill — it
   climbs f instead of descending it") — answerable by elimination.
   The field is gone from BOTH quiz schemas (probe + teach); ask keeps
   it (ungraded — nothing to leak). The dock additionally suppresses
   descriptions for quiz kind, so pre-fix records stop leaking too.
   Verified live: the session's pending quiz renders bare labels.

   Plus the diagnosed failure-mode fixes: GLM sometimes emits `options`
   as a JSON-encoded string (6 of 7 failures). prepareArguments hooks
   on all three question tools coerce it BEFORE pi's validation (the
   call lands instead of bouncing); the client coerces again in
   normalizeOptions (pi persists raw call args). Schema descriptions
   hardened ("REQUIRED ... never a JSON-encoded string").

3. DIVIDERS AT THE MARKERS: the probe-handoff SystemLine
   ("Preparing your plan…") IS NOW the Planning divider; the accepted
   verdict IS NOW the Teaching divider ("✓ Plan accepted" line gone) —
   one signal per seam, not two. The fold-based divider block is
   DELETED: Wave D grouping merged the whole probe into one giant
   assistant group, so the inclusive-fold comparison floated the
   divider to the transcript top (reproduced against the real record:
   6 view messages, phase flipped at the giant group). The markers
   render in the right spot by record construction. A DECLINED verdict
   is not a seam — it keeps the "↻ Plan revision requested" line.
   Verified live: plan divider after the probe concluded, teach
   divider after the plan presentation, nothing at the top.

Tests: 1004/1004 — string-coercion pins (normalizeOptions + the
prepareArguments hooks on all three tools, identity fast path),
quiz-schema-has-no-description pin (ask keeps it).
2026-09-26 19:08:39 +03:00
amosblomqvist b84ab1cdc0 fix: reasoning always renders above its message; dock drops delivered subagents at delivery
TWO owner-reported bugs from the live red-circle session
(chat_muhpyoll_27305e23), both investigated to root cause first:

1. THINKING BLOCKS UNDER THE MESSAGE. GLM emits trailing reasoning
   tokens, so the persisted content lands [text, thinking] (4 of the 6
   thinking-bearing messages from [21] on in that session) - and the
   view canon presented arrival order verbatim, rendering the reasoning
   UNDER its own message. Fix: projectView stable-partitions each
   assistant message - reasoning parts first (their own order kept),
   then text/tool parts. Owner ruling 2026-09-26: the reasoning belongs
   above, always. Covers live partials and settled records alike (one
   projection canon). NOT addressed (owner ruling): the plan_tools
   "missing" claim in the same session - wire-verified 11 tools incl.
   plan_init; GLM confabulation, no mitigation wanted.

2. DOCK CARDS LINGERING AFTER DELIVERY. /api/activity lists jobs with
   status "running", and the status flipped only in settleSteers at the
   DELIVERING TURN'S onFinish - so a subagent whose card was already
   visible in the transcript (marker injected mid-run) stayed in the
   background dock until the teacher finished teaching. Fix: flip the
   job at the moment the result reaches the learner - noteSteerVisible
   (jobs.ts) called from BOTH visibility seams: the marker's message_end
   wire event (live steer injection, ui-stream) and the write-ahead
   persist (the wake's input appends, chat-handler). finalizeJob is
   idempotent; settleSteers/runner-200 re-run it as a no-op, and the
   registry's delivery promise still settles exactly as before.

Tests: 1001/1001 - view-canon hoist pin (trailing [text, think, call]
projects reasoning-first; think-first unchanged), noteSteerVisible unit
pin (flip at the seam, idempotent, handoff no-op), and the mid-run
steering e2e now holds the turn open past the injection and pins the
job flipping done at the marker's message_end WHILE the turn still
streams. Live-verified read-only on the owner's server: the original
session reloads with every reasoning trigger above its message.
2026-09-26 05:46:33 +03:00
amosblomqvist ad0a6e5201 feat: Wave C pivot - steer markers, not follow-ups
Owner correction (2026-09-26, later): a real lesson is ONE continuous
teach->quiz->teach run - there are no run-would-stop moments mid-lesson,
and the run ends that DO exist are quiz stopper exits where delivery
must not fire. Subagent results must land BETWEEN messages INSIDE the
run, never splitting a toolCall from its toolResult.

Vehicle 1 now hands markers to agent.steer() - pi's STEERING queue:
the loop drains it at TURN BOUNDARIES (after a completed assistant
message AND its tool results - the bonded pair survives injection) and
again at RUN START (a continuation drains before its first LLM call),
then CONTINUES the run with the marker in context.
steeringMode "all" replaces followUpMode.

registerLiveAgent moved BEFORE runPiTurn and now hands pending markers
itself: the start poll drains the queue SYNCHRONOUSLY inside the
runPiTurn call, so a later registration would miss it. A marker
stranded by a quiz-terminated run therefore rides the ANSWER run's
first LLM call - the native replacement for the old continuation
coalescing.

Settlement reconciliation, the idle wake, probe-handoff exclusion,
and the registry-as-truth design are UNCHANGED; stranding is now
exactly the stopper-exit case.

Tests: jobs 45/45 (fakeAgent.steer; new pin - registration steers
pending markers into the new run, parked wake never fires); chat-flow
24/24 (new e2e through the real composition - a gated mid-run turn, a
marker delivered while the turn streams, record shape
[call, result, MARKER, response] with the marker adjacent to the
completed unit, the model SEES the marker in its next prompt, zero
wakes); full suite 999/999, tsc/lint/build green.

Live smoke (PORT=3004): research dispatched in the probe turn parked
behind the dangling quiz; on the quiz answer the marker injected at
the answer run's START poll - record [answer result, marker, teacher
response], job flipped done/delivered at settlement, brief card
rendered with its sources toggle.

Plan doc: docs/PI-NATIVE-MIGRATION-PLAN.md gains the pivot section.
Deferred (owner list): thinking blocks rendering after messages;
vanishing tool-call output tokens.
2026-09-26 04:31:07 +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 7f3d8aadcc docs: Wave C planning — pi-native follow-up delivery (forks resolved) 2026-09-26 03:15:14 +03:00
amosblomqvist 43ef004ab0 fix: pump wake turns double-appended their steer (Wave B regression)
A pump-fired wake turn carries its steer in the input appends, but the
pump only shifts its queue entry AFTER the turn registers - so the
handler's claimSteers popped that very steer too. The record-side
steerDelivered filter checks the PRE-write-ahead snapshot and cannot
see the carried copy: the marker persisted twice with the same
steer_<jobId> id, two view messages shared one React key, and the
client crashed on delivery (live: steer_job_muhlokbf_84e9373c).

This exact bug was known and fixed 2026-09-18 (the old claimSteers
received the incoming body messages and finalized-without-appending
anything the request already carried). Wave B's turn-root rewrite kept
only the record-side filter and lost the carried-side half.

The fix restores the carried filter: a steer whose id the input already
carries is claimed-and-finalized but never appended a second time.

Regression pin in chat-flow.test.ts drives the REAL pump end to end
(deliverJobResult with a real job record -> waitForIdle -> scripted
wake turn) and asserts exactly one copy + the wake response. Two test
lessons baked into comments: chatIdOf resolves a job steer's chat
through the JOB STORE (a synthetic jobId with no record silently
no-ops the pump), and deliverJobResult resolves at CLAIM time, not at
turn completion (the record assertion waits for the wake response).

The live-corrupted record (chat_muhlcltz) was deduped by hand.
2026-09-26 03:14:40 +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 7c0e8d9a50 docs: Wave D planning — native wire + client rewrite (forks resolved) 2026-09-26 01:15:25 +03:00
amosblomqvist b3ebbad040 docs: track the pi-native migration plan (recon + Waves A/B records)
Owner-approved home: docs/ instead of the repo root. Wave C (stateful
agents, seam 5) stays optional and deferred; Wave D (SSE dialect +
client rewrite) is next.
2026-09-26 00:57:24 +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 b87a6925af Wave A: pi-native tool layer — delete LhTool/toPiTool, TypeBox everywhere, terminate on questions
- src/agent/lh-tool.ts deleted: the zod-wrapped LhTool + toPiTool bridge
  (z.toJSONSchema per call, clientQuestion flag, hand-rolled result mapping)
  is gone. Tools are pi-agent-core native AgentTools now.
- New src/agent/tool-execute.ts owns what the wrapper added: the abort guard
  (runWithAbortGuard: run signal + 30s per-call timeout + 250ms grace,
  'Operation aborted' / timeout wording preserved), ToolInputError (the
  retryable '- problem at path' format), result mapping (string verbatim /
  object JSON / image parts, details rides the record's tool part output),
  and questionResult() — placeholder + terminate:true (owner Option A:
  belt of terminate + slim makeQuestionStopper with the clean-question
  rule; maxTurns deleted from the teaching turn entirely per owner).
- All 11 tool files + tools.ts registries converted to TypeBox parameters
  (typebox@1.3.7, pinned to pi-agent-core's copy). zod refinements
  (quizInputProblems, askOptionProblems, live-nodeId) moved from schemas
  into execute — the model sees the same retryable error shape.
- question-core + contracts/probe TypeBox-authored (single source shared
  with the UI); tool-parts parse seam runs the boundary's full chain
  (Convert → Check → Clean → refinement), fixing the live-smoke lockout:
  pi's boundary Value.Converts correctAnswer: 17 → '17' but the record
  keeps raw args, so a parse without Convert rejected a posed quiz
  (invisible pending question, turn ended). Pinned with the real smoke
  emission shape.
- Subagents converted: demo-builder/researcher AgentTool-native,
  visual-maker/pdf-reader declaration-only pi-ai Tools with local execute
  fns, extraction submit schemas re-authored in TypeBox (owner option 2:
  zod defaults → Type.Optional + fill at the submit seam).
- Accepted delta, flagged: TypeBox-native schemas skip pi's JSON-Schema
  primitive coercion fast path... (boundary still Value.Converts), and
  prober-quiz unknown keys ride through (Value.Clean strips at display).
- Tests: 988 passing (was 967 at the recon baseline); tool-abort and
  read-tool rewritten against the native seams; question-core/question-
  tools/tool-parts/demos/web-search/graph-tools/plan-tree/probe-done/
  tool-toggles converted to Value.Check/execute pins.

Live smoke (PORT=3004, real model): native ls turn streams end-to-end; a
real quiz pose (first attempt missing correctAnswer → tool-input-error →
model retry → posed) terminates the turn cleanly and persists pending.
2026-09-25 23:32:13 +03:00
amosblomqvist b07cb313f0 delete the getApiKey hook — pi-ai resolves keys natively per provider (owner, 2026-09-26)
baseAgentOptions (teacher-agent.ts) and runSubagent (subagents/pi-run.ts)
passed getApiKey: () => openRouterApiKey(). pi-agent-core resolves
(config.getApiKey ? await config.getApiKey(provider) : undefined) || config.apiKey
(agent-loop.js:191) — a provided hook OVERRIDES pi-ai's native per-provider
env resolution, and this one ignored its provider arg: every non-OpenRouter
provider was sent the OpenRouter key and 401'd (owner repro: openai and
anthropic direct). Deletion restores the architecture's stated intent —
the keys seam reflects file-saved keys into env and pi-ai reads them per
request. openRouterApiKey() export kept (now consumer-less; visual-maker/
pdf-reader read process.env.OPENROUTER_API_KEY directly for their own
OpenRouter-specific fetch paths — correct, untouched).

Pinned by tests/api-key-hook.test.ts: built teacher + prober expose NO
getApiKey hook. Gates: typecheck 0, 987/987, lint clean, build green.
2026-09-25 21:24:43 +03:00
amosblomqvist e89d4c2e23 serial questioning + maximal formatting (owner ruling, 2026-09-26)
The prober was planning perfect 7-question batches instead of probing. Three
surfaces now say the same thing — one question, adapt, next question:

- prober/10 rewritten to ~2.6k (from 5.6k): serial discipline leads (ask,
  wait, choose the next question from the answer); 'How your questions
  arrive' deleted (owner: does nothing); 'You cannot get there by thinking'
  deleted (owner: didn't reduce thinking); 'never plan a batch' and the
  first-message-length line dropped per owner — no overcorrection, no
  strong-instruction inflation. Binary search compressed to four moves.
- tool descriptions: the async-era fossil 'Ask exactly one question per
  tool call; after calling, end your turn and wait' — which permitted
  multi-call batches — swapped for 'ONE question per turn: ... his answer
  determines the next' in all four places (prober quiz + ask descriptions,
  teacher quiz description, both zod question-field describes).
- shared/40-formatting: markdown/LaTeX coverage now explicitly names quiz
  questions, options, and explanations ('in prose and in quiz questions
  alike') — the prober wasn't using LaTeX in questions.

Prober composed prompt ~13.2k -> ~10.0k chars. Pins preserved verbatim
('measurement should be efficient', 'the read tool can inspect it directly').
Gates: typecheck 0, 986/986, lint clean, build green.
2026-09-25 20:51:45 +03:00
amosblomqvist 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
amosblomqvist c25ee44bfd formatting chunk: from restraint to maximal use — documents, not chat messages (owner ruling, 2026-09-26) 2026-09-25 19:08:09 +03:00
amosblomqvist e79f85ebe4 Prompt-override orphan trap: delete clears the switch, writes heal orphans
Contract-fix interlude (2026-09-26), two bugs from UI's prompts-overhaul
(6edc220):

(a) ORPHAN-400 TRAP: an ON switch whose override file dies orphaned the
record; every agent-section write then 400'd (ON-must-have-file) while
the disabled toggle couldn't reach the OFF escape hatch — a user stuck
unable to write settings. Verified live on 3004 before the fix.
Two-layer fix:
- DELETE /api/settings/prompts/[...segment] now clears the record's
  switch in the same operation (switch-first order: a crash between the
  two leaves the valid off+file state, never an orphan). Response gains
  switchCleared (additive). Overrules the 2026-09-19 switch-survives-
  delete note — the delete route and the record agree by construction.
- PUT /api/settings/[section] agent writes HEAL an orphaned ON to OFF
  (key dropped) inside the write — never a 400 (defense in depth: disk
  wipes outside the app can still orphan). A valid ON with its file
  stays valid; the invariant: a user can never get stuck unable to
  write settings, and the record never contradicts file reality for
  long. Heal logs at info.

(b) TEST WIPES REAL DATA: tests/prompt-overrides.test.ts wiped
data/prompts/ (REAL user override files) in after() and its empty-list
case — every npm test killed actual overrides and orphaned their ON
switches (feeding trap a). Isolated via the established LH_DATA_DIR
seam (mkdtemp + env set before the first config.ts-loading import; all
app-module imports dynamic). after() now snapshots the REAL repo
data/prompts before/after and fails the suite if it changed; the seam is
pinned (writes land under the temp dir).

Pins: delete-clears-switch (incl. idempotent no-op), the exact repro
(disk-level wipe → mirror-the-orphan + tool toggle → 200, healed key,
rest of the write landed), vanished-fragment ON heals, valid ON stays.
Live: the 400-forever write now lands 200, heal info-logged, record
clean.

Gates: 967/967 (+5), typecheck, lint, build green.
2026-09-25 19:07:12 +03:00
amosblomqvist 178995c987 Agent prompts section: re-read on write, dedupe, owner-ruled layout
The prompts area's five owner-reported symptoms, root causes found
in code and verified live before fixing:

1. STATE ECHO — the toggle's truth (SegmentInfo.enabled) derives
   from the settings record's agent switches, but the segment view
   was read ONCE per section mount: the switch write landed (PUT
   /api/settings/agent) yet the toggle painted its old state until
   a remount re-fetched. Root-cause fix in the settings-saved
   family: the section now re-reads /api/settings/prompts when the
   'lh:settings-saved' signal lands for the agent section — the
   reader the signal law was missing. Toggle flips verify at ~40ms,
   no navigation.

2. DUPLICATION — the server contract returns the shared segments
   in BOTH per-agent lists; the UI rendered both lists back to
   back, so every shared chunk appeared twice (18 rows, 5 dupes).
   sectionedSegments (pure, exported, pinned) dedupes: the agents'
   own chunks plus the shared chunks ONCE.

3. ORDERING — Teacher first, then Prober, then Shared last
   (owner-ruled), each section headed, chunk order within a section
   preserved from assembly order (the server lists ARE assembly
   order: base, shared sorted, own numeric-prefix sorted).

4. GRAYING — the whole-row dim died; only the TOGGLE dims now when
   there is no override file (a wrapper span on the control; the
   file name is always full-contrast).

5. DESCRIPTION LOGIC — the 'No changes yet' line died. No
   description when there's no override (the empty state is clean:
   toggle + name); 'Your version is saved' (PROMPT_SAVED_HINT,
   pinned) appears ONLY when an override exists. The redundant
   'Shared base — appears in both agents' editor note died too —
   the Shared section header carries 'Used by both agents'.

Also: saveSegment/deleteSegment reuse the readPrompts callback
(keeps last-good on a failed re-read; the mount effect owns the
loading state).

Live-verified on :3001: sections in the ruled order with 13 unique
rows, clean-row and overridden-row states per the rulings, toggle
flip instant (view + record agree), save/delete round-trips, and a
shared-segment save + toggle through the single Shared control.

Open finding (NOT fixed here — needs a ruling): an ON switch whose
override file is later deleted orphans the record, and the
agent-section write validation 400s every subsequent write (an
ON switch must have an existing file) while the UI's disabled
toggle cannot reach the OFF escape hatch the validation
anticipates. Repro: Save + switch on, Delete the override, flip
any tool. Server validation + row semantics are Platform's /
owner's call.
2026-09-25 19:00:11 +03:00
amosblomqvist 95bc8d2261 launch message: reasoning before the plan (owner ruling, 2026-09-26)
The probe→teach handoff wake read as a dispatch order ("Author the session's
plan map now") and the teacher skipped the deliberation the planning fragment
asks for. The wake now paces it: take your time, read the assessment and the
probing transcript, reason out the optimal way to teach this person this
thing — floors, ceilings, misconceptions, the minimal tree so every step
lands as obviously true — and only then call plan_init. Machinery honest:
plan_init is still the first tool, the plan map still the first artifact, and
the tail now says to wait for his verdict (which is what presenting actually
does — the old "then begin teaching" tail skipped the verdict seam).
2026-09-25 18:55:31 +03:00
amosblomqvist 18c84b8a93 prober rescoped to a tight five-step process; method moves out of the shared base; logistics chunks compressed
- base keeps intro + the four philosophy paragraphs + accuracy (9.0k -> 3.0k);
  the click/arc/hedge paragraphs and Principles i/ii move verbatim to
  teacher/05-method.md (teacher-only — the prober never teaches)
- prober/10 rewritten as one process doc: read map -> make the goal concrete
  (questions, not orientation) -> reason out strands -> bracket with quiz ->
  probe_done; teaching-in-brief compressed into the opening, probe-done folded
  into step 5; prober/20 and prober/30 deleted
- shared logistics chunks compressed per owner ruling 2026-09-26:
  question-tools 5.1k -> 2.3k (option-construction procedure kept whole),
  learner-files 771 -> 374, formatting 1.8k -> 549
- prober composed prompt 25.9k -> 13.2k chars (~6.5k -> ~3.3k tok, -49%);
  teacher 36.6k -> 32.8k (method content identical, order shifted)
2026-09-25 18:17:10 +03:00
amosblomqvist 79e7c8648b Stream reattachment client half: resume via same rendering path
Reattach to a turn that outlived its view through the SDK's native
resumeStream() so replay burst + live deltas flow through the exact
same processUIMessageStream chunk pipeline as first-subscriber
streaming — no second rendering dialect.

chat-transport.ts: ReattachingChatTransport subclasses
DefaultChatTransport and repoints reconnectToStream at GET
/api/chats/[id]/stream (the server's reattach endpoint, not the SDK
default /api/chat/{id}/stream). It peeks the first SSE event:
- turn-settled terminal: cancel body, fire onSettled (refetch), return
  null — the SDK treats null as no active stream, status stays ready.
- start chunk whose messageId equals the seeded last assistant message
  id (continuation case): rewritten to a fresh id so the resumed run
  mounts beside the seeded message instead of replacing its parts —
  settle-time record sync merges to the server's assembled message.

chat.tsx: mount effect resumes when initiallyInFlight (no once-ref:
React StrictMode's dev double-mount aborts the first attempt at
dispose — the retry must run). Mid-session discovery sites (jobs
poll, handoff watch) funnel into attachToRunningTurn().
turnInFlight is demoted to fallback liveness: the 1500ms record poll
self-heals a failed attach and clears a stale error banner on settle.
A status watcher clears the flag once the local stream represents
the turn. Stop/Esc unified in abortInFlightTurn: local stop() when a
stream runs here, always POST /api/activity/abort {kind:'turn'} with
409 not_running treated as a benign raced settle. Composer busy now
covers reattached turns (Stop square shows, send gated).

Live-verified on :3001 with real model turns: navigate away mid-turn
and back (replay + live deltas in-view, fallback poll disengaged),
Esc-abort of a reattached turn (partial persisted server-side,
composer restored), two concurrent sessions (raced-settle handled
via turn-settled + record refetch; live settle adopts the final
record), and no leaked stream readers after unmount.
2026-09-25 18:12:29 +03:00
amosblomqvist 7cfd8b5bc7 Background turn runner: every turn is a server-side process, clients are views
Owner ruling 2026-09-25. runPiTurn executes a turn decoupled from any HTTP
response: an unbounded memory-only event log (finite by construction; the
30-min liveness TTL bounds the pathological case) plus a subscriber set.
The POST /api/chat stream and the new GET /api/chats/[id]/stream are both
subscribers — identical SSE dialect; late subscribers get FULL replay
(starting at the start chunk) then live until finish + [DONE]. A view
cancelling (reload, navigate) now detaches ONLY — never aborts the turn.
The remote kill is the abort channel: POST /api/activity/abort gains
kind: "turn" (id = chatId; not_running 409 like jobs); handle.abort sets
sawAbort + agent.abort(), every subscriber closes cleanly, the partial
persists through the usual settle (onFinish runs once, then endTurn,
then unregisterTurn LAST so a view that finds nothing finds the record
current). No running turn on reattach: the cheap terminal — one
{"type":"turn-settled"} event + [DONE] (settledTurnResponse).

The live smoke caught what the unit suite had masked: the agent_end
close() had been left behind in the restructure — ReferenceError every
turn, steered into the error path; fixed and pinned (clean turns must
never emit an error chunk; finishReason stop present).
2026-09-25 17:56:12 +03:00
amosblomqvist 369fe497ae question-tool semantics: the async-era 'end turn and wait' doctrine dies — tools are ordinary tools now (owner ruling, 2026-09-26) 2026-09-25 04:22:20 +03:00
amosblomqvist 5f5fa32482 Teach questions arrive collapsed in their first paint — no phantom scroll
Owner report (2026-09-25): in the teaching phase a new question arrives
collapsed behind the reveal button, but the transcript scrolled up as if
it had OPENED — the room it made was the expanded form's height, never
shown. Root cause: the expanded state is stale-true from the previous
question (nothing resets it when the dock returns to the composer), so
the new question's FIRST paint rendered open; the dock measured itself
tall and offered that height to the spacer, and the collapse one tick
later could never give it back (fold immunity refuses drops while a
question pends). Collapsed bar, tall reserve — scroll as-if-opened.

Fix (owner-shaped, deliberately minimal): apply the arrival state DURING
RENDER, before the first paint (the render-time state adjustment
pattern) — revealArrival(wasQuestion) decides how a teach question
lands: after the composer → collapsed (the learner reads the teaching
first); after ANOTHER question (a queued parallel check) → OPEN — the
reading already happened, and the height it already has is the honest
height. The paint measures the truth, so the reserve never learns a
height that isn't on screen. Probe arrivals are untouched — instant-open
at the bottom stays. The old [pendingId] effect keeps only its probe
branch.

Live-verified on :3001 with a fabricated teach-phase session driven
through the real settle/adoption path (zero model calls): composer→teach
arrival lands as the 59px bar with the spacer growing 206→211 (+5) and
the transcript scrolling only 77px (the new message itself) — the
previous behavior jumped the reserve +339px and scrolled 660px;
teach-check reveal click still expands (425px) and scrolls to bottom;
a queued parallel question arrives open with the reserve unchanged
(577→577) and +40px scroll.

Pins: tests/reveal-arrival.test.ts — revealArrival(false) = collapsed,
revealArrival(true) = open (the owner ruling). 946/946.
2026-09-25 04:15:25 +03:00
amosblomqvist d1542cdcbf quiz argument order: explanation before correctAnswer — reasoning precedes the pick (owner ruling, 2026-09-25) 2026-09-25 03:31:53 +03:00
amosblomqvist b3c07f89f4 Fix ⌘B: dispatch/listen event-name mismatch; thinking pill → "Thinking (⇧⇥)"
Root cause (interlude, owner 2026-09-25): keyboard-shortcuts dispatches
registry actions on the lh:kbd-<action> convention, but the sidebar's
listener sat on the pre-convention name "lh:toggle-sidebar" — a name
nothing ever dispatches, so ⌘B silently did nothing while its tooltip
and cheat-sheet row kept claiming the binding. A sweep of every lh:
listener confirmed the sidebar was the lone stray (chat-renamed and
graph-revision are deliberate non-kbd events).

Fix, structural: kbdEventForAction(action) exported from
src/core/keyboard.ts — the ONE spelling — and both sides compose
through it (dispatcher's default branch, sidebar's toggle listener);
keyboard-shortcuts re-exports the helper so the contract pin can assert
no second spelling exists. The mismatch is now impossible by
construction.

Thinking pill: label trimmed to "Thinking" with its real shortcut via
the registry-composition path — "Thinking (⇧⇥)" (cycle-thinking,
rebind-live like every Hint suffix).

Pins (tests/keyboard-toggle-sidebar.test.ts): the full chain — mod+b →
actionForBinding → toggle-sidebar → kbdEventForAction →
lh:kbd-toggle-sidebar; formatBinding("shift+tab") → ⇧⇥; the
dispatcher-imports-the-same-helper contract.

Live-verified on :3001: ⌘B visually toggles on home, /chat/[id], and
/chat/new (sidebar 0↔288px, hamburger slot 0↔36); with the rebind
lh:keymap {"toggle-sidebar":"mod+j"}: ⌘J toggles and ⌘B is inert
(rebind-live); thinking pill renders "Thinking (⇧⇥)"; keymap restored,
sidebar left open.
2026-09-25 02:39:11 +03:00
amosblomqvist 6b83d3248b Tooltip subtlety round: 500ms rest delay, quiet chip, parens suffixes, drops + trims
Owner round 2026-09-25, over 09c0f6e. The wave's tooltips read as
distracting; this round makes them quiet:

- Delay: TOOLTIP_DELAY_MS = 500 in the primitive — tooltips appear when
  you pause on a control, never in passing (Radix focus-opens stay
  instant by design; keyboard users don't wait).
- Vanish on leave: disableHoverableContent — the chip is un-hoverable,
  so the instant the cursor leaves the trigger it's gone, even mid-move
  onto the chip's own pixels.
- Look: bg-accent (#2a2a2a, the hover-chip tone) instead of the white
  bg-foreground slab; 11px; px-2 py-1; arrow culled (nothing purely
  decorative); text-foreground/85.
- Suffix: parentheses, not em-dash — "Dictate (⌘D)"; the registry
  source unchanged (still override-aware).
- Drops (owner-selected): send square, start square, stats summary —
  the icons/numbers describe themselves.
- Home logo kept but to the RIGHT with its real shortcut "Home (⌘G)"
  (jump-graph routes to /, the same destination).
- Trims: "Model selector (⌘K)", "Thinking level", "Graph costs —
  click for details", "How much of this session isn't mapped yet".
- Added: Import Notes gets "Import notes into the graph" — the one
  button whose destination wasn't obvious.

Pins: TOOLTIP_DELAY_MS=500; hintText parens expectations updated.
Live-verified on :3001 — 508ms hover delay measured, close within 10ms
of pointerout, chip styles probed (rgb(42,42,42), 11px, 4px/8px), arrow
gone, all new labels + sides confirmed, drops carry no tooltip and no
aria-describedby.
2026-09-25 02:26:37 +03:00
amosblomqvist b19e7e7eae Production tooltips: styled tooltips everywhere, shortcuts surfaced, dev-speak culled
Owner-approved full pass (tooltip wave, 2026-09-25). Mechanism: icon/pill
controls ride the styled Radix tooltip via a new Hint composition
(components/ui/hint.tsx) — the shared primitive adopted as-is, no
restyling; the shortcut appends as an em-dash suffix off the LIVE registry
(a rebind in Settings → Keyboard changes the suffix): "Dictate — ⌘D".
Surface-owned keys (Esc closes, Enter submits) ride a raw binding prop.
Native title= stays only for truncation-reveals (dock spans, the rename
field) and menu-interior abbreviations (MenuShell untouched). One
em-dash pattern; per-state truth on the send square (busy = "Stop the
reply — Esc", idle = "Send — ↵"), Enter on the plan verdict's accept,
"Stop dictation (Esc)" kept verbatim per the owner's flag.

Copy: the dev-speak cull — no pipeline/prober/tier/wave vocabulary
anywhere a user reads. New pins in tests/tooltip-mechanism.test.tsx
(hintText composition, asChild no-wrapper-DOM, the auto-update hint
constant); dock truncation titles pinned native; graph-chrome + settings
pins updated to the new copy.

Live-verified on :3001 — 12.6ms hover appearance (no native 1s lag);
glyphs render on every suffixed surface (⌘D, ⌘K, ⌘⇧F, ⌘,, ⌘B, F2, ↵,
Esc); per-page probes confirm Settings entry, Home logo, session search,
Start session, Close sidebar, model + thinking pills, stats summary,
rename title, plan chip, coverage ring, send square, cost button, node
search (⌘F), graph gear, settings close (Close — Esc), keyboard section
"Fixed — part of this screen", auto-update hint, light-mode "Coming
soon", the three slot rosters, graph-tools hint. Esc still closes the
settings dialog.
2026-09-25 01:52:20 +03:00
amosblomqvist 81be55f35c Empty-graph chrome: Import Notes stays, search + settings wait for nodes
Owner ruling on the empty graph (honesty principle — dead controls are
lies): the top-chrome actions row now gates per control.

- Import Notes renders ALWAYS (graph enabled) — the one action that
  works on an empty graph, the entry point itself. It used to hide with
  everything else, leaving the empty state a dead end with no entry.
- Search + settings render only when the snapshot has nodes, and their
  appear-once mount carries a deliberate 200ms fade (graph-chrome-fade-in,
  globals.css — the settings-pop register, reduced-motion appears
  instantly). Computed animationName verified live.
- The row's body is now the exported pure GraphChromeRow (the
  DeleteNodeBody pattern) — the no-match note's truth (visibleIds +
  snapshot) stays upstream, computed by the wrapper.
- The empty-state placeholder copy is LEFT AS-IS (SCOPE rule: it already
  has copy — icon, title, and the session-mapping paragraph).

Pins (tests/render-graph-chrome.test.tsx): empty → Import Notes present,
search/settings absent, no fade carriers; populated → all three, exactly
two fade carriers; the no-match note follows its upstream truth. SSR of
GraphChromeRow both ways.

Live-verified on :3001 with scratch data: empty graph → Import Notes
only + placeholder intact; a real import through the button (file input
via DataTransfer) → nodes land, search + settings fade in (computed
chrome-fade-in on both carriers); wipe → reload → back to Import Notes
only (the reverse path). Scratch graph restored byte-identical.
932/932, tsc/lint/build green.
2026-09-25 01:26:42 +03:00
amosblomqvist 66090ed769 Pre-send key guards: no orphan error-shell chats on first send
The owner's asymmetry find (interlude over the zero-keys wave): the
models-unconfigured case guards BEFORE send — no request, no chat
created, setup opens — but the key cases only reacted to the 503 after
POST /api/chats had already created the record, leaving orphan error-shell
chats in the sidebar. The guards now twin the models mechanism on BOTH
creation paths (home start(), /chat/new send()):

- Zero keys (useZeroKeys reuse): no provider key present -> skip creation
  and send entirely, open SetupDialog (stage 1 first-key screen).
- Strong-provider key missing (owner-approved extension): keys exist
  generally but the strong slot's pinned provider has none — the original
  deleted-key scenario. New pure strongKeyless(catalog) in setup-cta.tsx:
  slots.strong resolved pin + providers[] (present-key only, the catalog's
  own federation surface), false when unconfigured (the models guard owns
  that case) or catalog not loaded (the server 503 gate remains the belt).
  useStrongKeyless rides useModelCatalog, re-reading on the 'keys' AND
  'models' signals (a deleted key or a re-pinned slot flips it live).

Both guards run after the configured check, same
skip-everything-and-open-setup treatment; 'force' (setup's onDone replay)
bypasses both — completing setup implies a keyed provider. /chat/[id]
sessions keep the 503 revert-draft interception unchanged: those chats
legitimately exist.

Pins: strongKeyless fires on a keyless strong pin (keys elsewhere or
none), never fires with the pin's key present (incl. the keyPresent=false
belt), false on unset pin / unloaded catalog. Normal-path pins ride the
existing startDraftSession contract suite. Live-verified on :3001 under
LH_IGNORE_ENV_KEYS=1 with a file-key anthropic + openrouter-pinned strong:
both guards on both pages (setup opens stage-1 / stage-2 respectively,
draft preserved, 154 chats unchanged, zero sidebar entries), normal path
with a keyed strong provider still creates and lands the prompt redirect.
929/929, tsc/lint/build green.
2026-09-25 00:46:10 +03:00
amosblomqvist 1f777f770c Zero-keys wave: provider-key gate renders honestly everywhere
Over Platform's 66dbea9 key-presence seam, the five owner-locked tasks:

1. Zero-keys pill — components/setup-cta.tsx: pure hasProviderKey
   (classifyKeys reuse — tool keys like Deepgram never count), useZeroKeys
   (GET /api/keys, re-read on the new lh:settings-saved 'keys' signal),
   ZeroKeysPill (testid setup-cta-keys) taking PRECEDENCE over the models
   CTA on chat/home/new-session. SettingsSection gains 'keys'; keys-section
   dispatches after save AND delete.

2. Setup stage 1 is self-contained: provider picker (MenuShell +
   ProviderMenuList) + key paste + Save via PUT /api/keys/:id — the dialog
   STAYS MOUNTED; its own save dispatches the keys signal, the fetch effect
   re-runs on it, and the stage flips to the pickers on its own.
   onOpenKeys + setup-open-keys die. SetupKeysBody exported for SSR pins.

3. parseProviderKeyRefusal (chat.tsx, envelope-verbatim twin of
   parseModelsRefusal) joins the revert-draft-and-open-setup interception;
   activity-dock reads 'needs a provider key'; dispatch-card shows
   'Add a key' — both deep-link setup, which lands on stage 1 by itself.

4. Dictation trio: the token route's only 503 (missing Deepgram key)
   becomes the client-authored DICTATION_KEYLESS short line; the errored
   idle click clears the error and deep-links Settings -> Keys ->
   DEEPGRAM_API_KEY via the new open(section, row) seam (dialog landOnRow
   -> SettingsDialogBody initialRow -> KeysSection openRow follow-effect).

Live-verified on :3001 under LH_IGNORE_ENV_KEYS=1 (the fresh-user switch):
pill on all three surfaces + precedence over the CTA, stage-1 save ->
auto-advance with dialog mounted, real 503 send refusal reverting the
draft and opening setup, dictation 503 -> short line -> Settings lands on
the unfolded Deepgram row with the error cleared, keys-section save and
delete both flipping the pill live, and a fabricated refused research job
rendering the 'Add a key' dispatch card. 926/926, tsc/lint/build green.
2026-09-24 23:20:59 +03:00
amosblomqvist 9f0ecccc7a Merge branch 'feat/platform' 2026-09-24 22:53:22 +03:00
amosblomqvist e4049e4bf8 Key-presence refusal at every gate: a pinned model is not enough
Owner-diagnosed: the turn gate checked only model coordinates, so a
pinned slot whose provider key was deleted passed the gate and died
mid-stream masked to 'An error occurred.' The key is now gated where
the model is — by what would RUN (env-first keyValue truth, so an
env-shadowed key passes).

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

Known residual (pre-existing, flagged for master): the manual extract
route logs BOTH config refusals as console.error (fire-and-forget path);
the coverage ledger keeps everything uncovered either way.
2026-09-24 22:51:12 +03:00
amosblomqvist 90b74206cc app icon: liquid-glass LearnHarness icon (Icon Composer PNG exports packed via iconutil) 2026-09-24 21:20:15 +03:00
amosblomqvist 085fdc1a98 Payload slimming, pre-signing half: the asar stops shipping the world
The measured problem (330MB DMG / 1066MB app): electron-builder's
dependency collector packed the whole production dep tree into
app.asar (502MB) regardless of the files allow-list, because
normalizeFiles turns the array into one {filter} fileSet whose
entries ALL feed the per-dep copier (its implicit '**/*' passing
everything), and unpacked the native rungs into a 150MB
app.asar.unpacked. The launcher needs nothing but electron + node
builtins — the server ships via extraResources.

The fix (electron-builder.yml files): gitignore-style negation
ordering — '!**/*' first excludes everything, then only
electron/** and package.json re-include. The same list reaches the
per-dep filter, where app-builder-lib's destination-path quirk
(stat.moduleFullFilePath in util/filter.js) means no dep file can
ever re-include: all 566 modules collect zero files (DEBUG-mode
verified). Locale trim rides along (mac.electronLanguages: en).

Result on a fresh desktop:dist: app.asar 502MB -> 16KB (shell only:
electron/main.cjs, preload.cjs, package.json), app.asar.unpacked
gone, Frameworks 288MB -> 239MB, app 1066MB -> 367MB, DMG 330MB ->
144MB. Resources/server curation untouched (127MB, sharp native +
pdfjs traced and verified live).

Live verification against the packaged app: server spawn via
ELECTRON_RUN_AS_NODE on its kernel-assigned port, model slots via
PUT /api/settings/models, chat round-trip streaming an exact echo,
read tool on a PDF through the pdfjs path (exact text extracted),
image attach through the vision slot, GET /api/graph snapshot and
render-settings.

Docs full-accuracy pass (owner-approved scope): README +
docs/architecture.md swept of the dead embeddings / graph-context
injection / cluster-namer / semantic-clusters story (wave-7
casualties), MODELS/config roster rewritten to the BYOK slot
chain, EXTRACTION.chunkTokens -> graph.extractionChunkTokens,
route count 16 -> 25, steer_ctx_ dropped, seam table re-pointed at
the prober graph tools; docs/research/stt.md:50's "we already ship
transformers.js" premise corrected; config.ts orphaned EXTRACTION
doc block (left over from eed4b4e) deleted.

Note for master: npm prune still owed in the master tree (~690MB
stale @huggingface/onnxruntime trees absent from the lockfile);
worktree tree is clean and verified.
2026-09-24 20:50:52 +03:00