Commit Graph
331 Commits
Author SHA1 Message Date
amosblomqvist 0fc6d4ab94 feat: add verified desktop ChatGPT subscription connection 2026-10-24 12:17:08 +03:00
amosblomqvist 4bd20be529 fix: keep research available without search keys 2026-10-24 06:52:36 +03:00
amosblomqvist 6685b3d09a fix: use native Pi identity and refresh provider catalogs 2026-10-23 17:25:10 +03:00
amosblomqvist a67366005a Merge notes import reliability repair 2026-10-23 04:43:56 +03:00
amosblomqvist 2d67376b76 fix: always run explicit notes imports and surface failures 2026-10-23 04:43:56 +03:00
amosblomqvist 261a9b9927 fix: honor selected-provider credentials in visual and PDF agents 2026-10-23 04:26:25 +03:00
amosblomqvist 01e7b17fcd Merge persistent practice nudges for 0.3.2 2026-10-21 00:30:43 +03:00
amosblomqvist c4c15b8fd2 fix: keep practice problems active through failed attempts 2026-10-21 00:30:43 +03:00
amosblomqvist 7f95753cf2 fix: share question math repair with practice displays 2026-10-21 00:00:20 +03:00
amosblomqvist 94b63c6ba7 fix: own demo browser lifetime through cross-platform IPC 2026-10-20 15:56:19 +03:00
amosblomqvist b3c9f9e18b math: heal overescaped LaTeX commands in question displays 2026-10-16 21:24:14 +03:00
amosblomqvist cdd8ac047e sessions: source-message deep links from the graph + session Find (Cmd/Ctrl+F) 2026-10-13 08:26:31 +03:00
amosblomqvist 664c1bfed2 extraction: message-granular proposition anchors; factory budget back to 4096 2026-10-13 08:26:31 +03:00
amosblomqvist d653ba0e91 math: render escaped dollars inside inline math
micromark-extension-math's inline closer is code-span logic — any
size-matched $ run closes, backslash-escaped dollars included — so
"$x = \$5$" closed at the escape, errored in KaTeX, and could cascade
into later text. KaTeX itself renders the escaped dollar fine, and its
own auto-render scanner skips escaped dollars, so the author's intent
is unambiguous.

Fix at the render seam (transformMathDelimiters): inside a balanced
inline span, an escaped dollar becomes the dollar-free
\text{\textdollar} before tokenization, with an escape-aware closer
(odd-backslash dollars never close; even-backslash row-break closers
keep closing). Display spans are untouched (a lone $ can't close $$ —
the escape already survives there), prose escaped dollars, code, and
unbalanced spans pass through, and spans without escaped dollars emit
byte-identical text — corpus-verified over 489 dollar-bearing fields
in 40 real sessions (35 changed, all escaped-dollar spans; the
economics session's dollar-amount math now renders).

The latex-testing session's Test-6 question renders 14 KaTeX spans
with zero errors (was 12 + 2 errors). 1578/1578 tests both configs;
typecheck, lint, build, and Electron-runtime smoke green.
2026-10-13 06:36:05 +03:00
amosblomqvist bf29a2070f math: dollar delimiters end-to-end (exact answers, escaping examples, context rendering)
- switch the math authoring/rendering contract from \(...\)/\[...\] to the
  standard $...$ inline / $$...$$ display: prompts (20-question-tools,
  40-formatting), singleDollarTextMath on the renderer, legacy reader kept,
  and escape-debris recovery extended to dollar spans (mirroring micromark
  delimiter-run rules)
- exact answer membership now precedes debris cleanup in
  coerceCorrectAnswer (calculus incident: an exact sentence-final-period
  label was stripped as model debris, then rejected); option keys are
  passed at the boundary/grading seams, the dock no longer pre-coerces
- a complete, phase-correct JSON-escaping example rides the probe/quiz
  tool descriptions (serialized from one canonical body; quiz leads with
  type+nodeId), plus the one-sentence JSON-escaping rule in 40-formatting
- ask option descriptions render markdown/LaTeX in the pending dock;
  settled cards keep the details line and option descriptions
  retroactively, read from the persisted call arguments (chat passes
  part.input to AnsweredCard)

1566/1566 tests on both navigator configs; typecheck, lint, production
build, standalone packaging, and Electron-runtime smoke all green.
2026-10-13 06:24:33 +03:00
amosblomqvist 0540bf697c ci: isolate test setup and fix Windows PDF font paths 2026-10-11 10:41:30 +03:00
amosblomqvist e266c3c028 desktop: tri-platform window chrome, packaging and draft-release CI 2026-10-11 10:12:13 +03:00
amosblomqvist 8ebaf0da6e wave E stage 3: demos run in the app's own Chromium (browser-service helper)
Demos execute JS and need a browser; the app's own binary now provides
one: main.cjs boots a hidden window + CDP port under --browser-service
(before the single-instance lock - a helper is deliberately a second
instance of this binary), lifetime tied to the server's stdin (EOF =
quit, no orphans). headless.ts connects over CDP as PRIMARY (owner
ruling 2026-10-10), system Chrome stays the fallback, graceful refusal
when neither. Electron's CDP has no Target.createTarget, so the helper's
pre-created window is THE page, reused and reset per job behind a
serialized queue. PDFs and SVGs never reach a browser anymore (native
since stages 1-2); stale Chrome wording in model-facing tool copy made
engine-neutral. Live-proofed end-to-end from the dev tree (helper seen
by process watch, js-ran 42 probe, no orphan). Gates 1523/1523.
2026-10-11 07:03:45 +03:00
amosblomqvist e9cf841ce7 wave E stage 2: renderSvg goes native-first - resvg in-process
Visual-maker SVGs never execute script, so a browser added nothing but a
process. resvg rasterizes in-process now (white background and fitTo
width mirror the old Chrome-page semantics; svgViewport unchanged,
quirks included for parity). The old Chrome path stays verbatim as the
fallback for exotic-SVG parse failures - it was the only path before,
so nothing regresses.

The resvg binary (4MB per platform, one per build) rides the standalone
via the same tracing pattern. Gates 1519/1519; native path pinned
against real SVGs (intrinsic size, viewBox-only 900 default, opts
override, system-font text).
2026-10-11 06:52:32 +03:00
amosblomqvist 951c7704b9 wave E stage 1: pdf_render goes native - pdfjs paints, Skia hosts
The browser was never the PDF engine - pdfjs always painted the pages;
Chrome only hosted the canvas tab. Now pdfjs-dist v6's own Node
integration (@napi-rs/canvas, its optionalDependency) hosts in-process:
same paint code, Skia canvas, zero browsers, zero temp files, no base64
hop. The whole pdf-reader runs browser-free - the Chrome gate on
pdf_render is gone.

The Skia binary (27MB per platform, exactly one per build - npm installs
only the building machine's) rides the standalone via the same
outputFileTracingIncludes pattern as pdfjs-dist. Verified: builds green
(1515), native render pinned against the real fixture (canonical
single-IDAT PNGs, dpi/72 arithmetic, page-range skipping), and the
canvas loads + paints from inside .next/standalone.

Stages 2-3 (resvg for SVG, the --browser-service demo helper) follow.
2026-10-11 06:49:13 +03:00
amosblomqvist eda5491010 schema stamps: lazy v1 for chats + the graph family, honest refusal
Chat records gain version (CURRENT_CHAT_VERSION), stamped by a single
writeChat choke point in the store; absence reads as v1 — existing files
migrate on their next write, no mass rewrite. The graph family (four JSONL
ledgers, sources.json, render-settings.json) shares one lazy sidecar,
graph/format.json; positions stays unstamped (rebuildable) and jobs stay
unstamped (transient).

Downgrade scenario ruled honest-refusal: records stamped newer than the
running build read as absent (chats, logged) or throw (graph family) — a
read-modify-write store never rewrites what it doesn't understand.

Golden fixtures stay the unstamped legacy shape on purpose (lazy v1 is
current-valid by design); the stamped write path is pinned by
tests/schema-stamps.test.ts. Reads never write — the golden byte-identical
pin holds.
2026-10-10 13:54:17 +03:00
amosblomqvist 53112d2861 practice round: dead session starts, prompt laws, preamble, label unify
THE START BUG (owner report, reproduced live both kinds): new sessions
(?prompt flow — practice and teach alike) sat dead until a second
message, which then persisted WITHOUT the first. Root cause, proven
both directions: the submit POST rode the view's consumeAbort
controller; React StrictMode's simulated remount fires Diet A's
unmount cleanup (model.stop()) ~8ms in and killed the POST mid-flight;
the page's sentInitial guard had already flipped, so it never retried.
Teach was equally broken — only unnoticed, because every teach chat in
use predates the diet and practice starts are always fresh. Fix: the
submit law — POSTs are not abortable by view teardown (they are ~100ms
acks since Diet B and cannot starve the pool); after the ack, a
consumeAbort identity check guards the stream attach. Pinned with the
stop-mid-submit regression test. Live: StrictMode on, practice draft
POST 200/67ms, stream attached, message persisted, problem posed.

PRACTICE PROMPT (00-base): the opening rule — material arrives two
ways, both first-class (picked propositions primary; free-form —
topics, course material, files/PDFs — fully supported, never refused);
as soon as material exists the reply poses, at most one short line
before the problem call, no warm-up interview. Plus the hard law: no
problem is ever conversation — every problem through the tool (no
thinking field/no reveal/no verdict attaches to prose).

GRAPH-CONTEXT PREAMBLE: custom markers flatten to user-role on the
wire, so the old "Contextualization — the learner picked…" read like a
system broadcast in the learner's voice channel. Now the bracket
convention: "[Session context] — picked from the learner's knowledge
map…".

SIDEBAR: busy rows unify to "Working…" for both kinds (the spinner's
default label included); "Practicing…"/"Teacher is working…" retired.

Gates: 1490/1490, typecheck, lint. doctrine's stale doc pointer fixed.
2026-10-10 12:19:31 +03:00
amosblomqvist 26bc305d66 ui: plan canvas ink extent + the keyboard-first round
Plan canvas (owner report: arrows clipped at the viewBox edge in dist —
measured 14px/20px cuts in the owner's real plans): blockLayout now
unions node boxes with every edge path's control-point hull (a bezier
never leaves it; the layout calls edgePathD with the renderer's exact
inputs). Dagre's node extent stays the floor, so ordinary plans render
byte-identical; the viewBox origin shifts only when ink bulges past
zero. Verified on both real chats; pins use the owner's real
linear-algebra tree (golden-real-data).

Keyboard-first (owner's law: Enter always submits, arrows always cycle;
text fields the only exception):
- Enter guard split (enter-submits): a focused BUTTON keeps native
  Enter only when keyboard-walked (:focus-visible) or inside an open
  overlay (menus/dialogs); pointer-clicked transcript buttons submit,
  preventDefault suppressing the re-activation underneath.
- Focus hygiene (focus-hygiene): pointer clicks on transient controls
  (agent/user thinking toggles, copy, mermaid expand, plan chip) blur
  to body — the state clicking empty space produces.
- Session search owns the walk: Up/Down wrap-cycle the filtered rows,
  Enter opens the walked row or the first match (quick-open), then
  blurs and keeps the query. The Alt-arrow session cycle is RETIRED.
- Registry: model-menu walk/pick entries removed (obvious in-field
  navigation, not settings material); question-dock keys stay
  displayOnly per owner ruling.

Gates: 1489/1489, typecheck, lint. Verified live end-to-end.
2026-10-10 09:43:12 +03:00
amosblomqvist 643e7596f0 streaming: connection diet — the parallel-session freeze, fixed
Root cause (postmortem, verified live): Chromium caps 6 concurrent
HTTP/1.1 connections per origin. Every running turn pinned slots for
its whole life — the POST /api/chat response stream plus a reattach
GET stream per visit that never closed on navigate-away — so two
concurrent thinking sessions starved navigation RSC fetches in the
browser queue (147s measured) until the turns settled. Server was
exonerated (3ms curls during the freeze); main thread idle throughout.

- COMPACTION LAW + WIRE DIET (ui-stream): store the states, not the
  chunks — replace-in-place snapshot retention keyed by updateSlots,
  ~100ms coalesced latest-wins fan-out, slim wire drops pi's duplicate
  assistantMessageEvent payload.
- DIET B: POST /api/chat acks and closes ({ok:true}) — the turn wire
  is GET /stream for everyone, submitter included; send() reattaches
  via refetch-first resumeStream(true).
- DIET A: unmount cleanup calls model.stop() — navigating away aborts
  the view connection (turns are server-side background objects).
- Answer-flicker fix: resumeStream's eager tail clear moved off the
  refetch path (the refetch swaps base+tail atomically; clean-slate
  guard restored on refetch failure) — the optimistic answer card
  survives refetch latency (1.7s stalls measured on dev).
- Tests converted to the ack contract (drainTurn/drainRunning helpers,
  dietFetch mock) + a pin that the POST body reads while the turn runs.

Gates: 1489/1489, typecheck, lint. Live: dual streaming sessions,
navigations 335-648ms, in-page settings 19ms (was 147s).
2026-10-10 09:42:47 +03:00
amosblomqvist 1f41fb05a5 question tools: phase-visible names, hardened teach schemas, context-slim seams
The tools_fails_2 diagnosis (glm-5.3 dropping trailing required fields):
- probe-phase quiz/explain renamed to probe/recall — a shared name carried
  the probe call shape into teaching (the first teach quiz dropped nodeId);
  display seams accept the old names forever
- teach quiz+explain schemas lead with the required pair (type, nodeId);
  explain gains the predict/check type and joins the reveal law (checks
  collapse, predictions arrive open; no-type records render check-style)
- plan_init/probe_done results go context-slim (owner, context diet):
  content = validated nodeIds + binding-id reminder / fixed ack; details
  keeps the tree for the popup and {ok:true} — serializer-verified that
  details never reaches the model; failure echoes dropped
- the cards carry the tool's own name (Probe/Recall) and the quiz type
  reads on the label (Prediction/Check) in composer and transcript
2026-10-09 07:59:23 +03:00
amosblomqvist c23eb1f15b tools: the standard argument heal — every registry, both seams
The whitehead incident (2026-10-09): glm-5.3 emitted quiz type with
quotes baked in ('"predict"') — rejected ten times, byte-identical
retries, the lesson never started — and correctAnswer with the JSON
key-value colon glued on ([':parmenides']). Third GLM wobble incident,
and each prior heal was wired only where its incident landed; this one
landed precisely where the practice heal never reached.

Layer 1 — healEncodedArguments moves to core/argument-heal (the display
seam needs it too — the tool-parts law) with withArgumentHeal +
healToolRecord (agent/tools/heal) wired at EVERY registry: prober,
teacher, practice, bareQuestionTools (it had no prepareArguments at
all), demo-builder, and submit-agent (composing after
healStringifiedArrays; the auditor's extraTools wrapped).

Layer 2 — stripModelValueDebris (core/value-debris): leading/trailing
colons, quotes, backticks, whitespace strip at the MEMBERSHIP seams —
coerceCorrectAnswer (boundary check + display + grading in one),
normalizeQuizType (persisted wobbled records keep their role),
resolveTargetId (copy-syntax on targets).

Layer 3 — schema description hygiene: the quiz type and practice
mode/outcome/rung/missClass values now read as bare words — the quoted
JSON-key style invited quote-copying.

Display mirror — parseQuizInput heals the raw record on read, so a
boundary-healed quiz whose record keeps raw args renders instead of
becoming an invisible pending quiz (learner lockout).

Pins: tests/tool-heal.test.ts (the incident shapes end-to-end through
pi-ai validation, the wiring, the strips, the mirror). The stringy-
options pins flip to boundary-parity per tool-parts' own law; the
chat-flow garbage pin keeps its honest-reject intent with an unhealable
shape. 1471 tests, typecheck, lint green.
2026-10-09 04:54:34 +03:00
amosblomqvist d5c4c3bf9f ui: the chat comeback — side-based authorship, restored quotes, tucked whys
Blockquotes: the callout override had displaced Streamdown's own styled
blockquote component, so plain quotes rendered as bare unstyled text
since that commit. The pass-through branch now restores Streamdown's
verbatim classes (border-l-4, muted italic).

Explanations tucked for ALL questions (owner): the prediction's Why /
Hide-why toggle is universal — every quiz answered card sits its
explanation behind the same collapsed button. Same pattern, one law.

The thinking row's rhythm: my-1 -> my-3. The 4px margin hugged the
options on non-graded answered cards (owner report); 12px matches the
card's mt-2/mt-3 spacing grammar everywhere the row renders.

Side-based authorship (owner round, 2026-10-07): the document shape
blurred user into agent — three left-rail idioms (user text, user
thinking, quoted prose) collided once quotes rendered. User rows go
RIGHT: 'You' messages and practice thinking cards hug the right edge at
85% width with the rail mirrored (border-r), text still reading
left-to-right. In-card thinking (inside the answered card, the agent's
context) stays left. Transcript thinking rows arrive EXPANDED — the
thinking was clearly sent. Cmd+T (toggle-user-thinking, chat scope,
cheat-sheet row) sets every user-thinking row to one state via a module
store: explicit sets stomp individual toggles, late mounts honor the
last set (the state initializer reads the store, so static render
reflects it too).
2026-10-08 02:29:05 +03:00
amosblomqvist cdf9305eea prompts: no size ceilings, the detour keeps the pace, the probe enters at the top
Limits out (owner rulings): the 'Typically 5-15 nodes' cap and the
'keep the map small' pressure are dead — a plan is as elaborate as the
subject needs; what keeps it readable is correct structure (unconditional
truths as the hanging points), not compression. Labels stay short; size
is free. The method section now carries the goal reiteration: thorough,
deep, real understanding — sessions run as long as the understanding
needs, plans as elaborate as the subject requires; no schedule, nothing
to rush. (Code was already limit-free: no maxTurns, no parser caps.)

The adaptation + detour law, merged into one section ('The map moves
with him'): when the ground shifts mid-lesson — a missing foundation, a
deeper confusion — never rush the repair; no wall of text that 'covers'
the ground to get back on plan. Update the map if the structure changed,
then build the missing ground the only way anything is built here: one
reasoning step at a time, verified, at the session's one and only pace.
Detours are the teaching, not interruptions to get through.

The probe enters at the top (owner-observed failure: eigenvectors, then
high-school chain rule, then partials — strands opened at textbook
floors). New entry-point rules: open near the hardest thing the goal
needs and let a miss walk you down; strands inform each other (a strand
crushed at height says neighbors start high); the verdict + confidence +
thinking field TOGETHER set the next question's difficulty — confident
correct jumps sharply, hesitant correct steps gently. Efficiency is
resolution: never descend into already-bracketed territory; the
questions you save buy the specific, nuanced ones that locate the edge.
2026-10-08 01:13:05 +03:00
amosblomqvist 5d9cebc3d5 prose: the key-idea callout — GitHub alerts, one sharp white box; tables hug
The one emphasis device for load-bearing ideas: GitHub-style alert
callouts. The prompt teaches only > [!IMPORTANT] (one device, one
meaning — an unconditional truth laid down, a payoff landed, a hinge
definition) with the restraint law: everything else stays in prose, if
everything is boxed nothing is. The renderer accepts all five GitHub
markers and boxes them identically — a stray [!NOTE] still meant 'this
deserves emphasis', and a marker line must never leak as text.

Rendering (owner rulings): SHARP corners, white stroke, no background,
no label, no icon — just the outline — and the box hugs its text
(width: fit-content capped at the column) instead of stretching
full-width into empty space. Same grammar as the plan blocks: this is
structure, not chrome.

Seam: the Streamdown blockquote override in md-blocks. parseAlertMarker
is pure and exported — Streamdown wraps blocks in its own styled
components, so detection reads the hast node's tagName, and a standalone
marker drops its <br> hard-break artifact along with the empty
paragraph. Plain blockquotes pass through untouched.

Rider (owner, same round): tables hug their contents — width:
max-content on the data-streamdown table (width:auto would still
stretch, the classic table trap); wide ones keep the md-blocks
overflow-x scroller. Graceful in exports: a callout is still a
blockquote there.

6 render pins: the box mounts with white stroke + fit-content + no
rounding, all five markers box, standalone-marker drops the empty
paragraph, multi-paragraph structure kept, plain quotes untouched.
2026-10-08 00:27:30 +03:00
amosblomqvist c34cc029ae prompts: the fact-check bar, the thinking fields, adaptation, LaTeX everywhere
Accuracy (shared, one law across every agent): the target shifts from
surface facts to understanding itself — 'the deeper risk isn't a wrong
name or date, it's a wrong understanding: an interpretation of an idea
that seems solid and isn't.' The bar is the doubt itself: checks are
cheap, and weighing whether a doubt is worth checking is how errors slip
through. Thinking stays the agent's own.

Research (teacher 40-jobs): floor framing — verify your understanding
before it becomes his; dispatch before the fact crosses your lips, teach
around the gap until the brief lands. Planning (10-planning): planning is
the natural moment for verification; the map is a hypothesis and the
probe only its first sketch — rewriting is a normal, common event (the
session adapts to what teaching reveals); and plan_init IS the
presentation — never draw the plan again in prose (the double-plan bug).

New shared segment 16-mental-model.md: the thinking fields are the
session's primary data — read them in full, measure what they reveal,
adapt everything on the model they build. Composes into teacher, prober,
and practice (shared-fragments list updated).

Practice: the rarely-needed framing is dead — fact checks get their own
law (a wrong model solution grades the learner against a lie; pose once
the brief lands), files and map readers become plain side tools.

LaTeX: the question-tools craft list gains the point-of-use bullet
('LaTeX everywhere — plain-text math in a question is a craft failure'),
the formatting law names EVERY field, and the practice problem statement
line carries it too.

Prompt pins updated: felt-doubt bar + mental-model doctrine in both
teacher and practice compositions, no-double-plan rule, rewrite-is-
normal law, rarely-needed framing asserted gone.
2026-10-07 23:56:21 +03:00
amosblomqvist 8dfe621748 researcher: PDFs from the web — the pdf-reader's text tools in the loop (design B)
web_fetch_pdf downloads (25MB cap, probe-validated) into a per-job temp
dir and returns the triage summary — page count, size class (the pdf
doctrine's <=10/<=40 thresholds), flagged math/image-heavy pages. The
researcher's toolset then gains pdf_info / pdf_extract / pdf_search over
that dir, scoped by a resolve-and-startswith wall: the researcher's
filesystem ends at the temp dir. Render is deliberately absent — the
researcher slot has no vision; visual reading stays the pdf job's job.

web_fetch: a PDF URL now returns a POINTER to web_fetch_pdf instead of
dead-ending, and every output carries 'Source: <final url after
redirects> (HTTP <status>)' — the 'ill-formed request' lesson: a
200-served error body must be distinguishable from a real page.

The runner owns the temp lifecycle (mkdtemp before, recursive cleanup in
the job's finally — never enters the record) and web_fetch_pdf urls ride
the sources ledger. Doctrine step 5 added: fetch, triage, extract only
the pages that matter, cite as (p. N).

Tests build a REAL minimal PDF programmatically (correct xref offsets —
pdfjs parses it like any download): zero network, real parse.
2026-10-07 22:36:11 +03:00
amosblomqvist d450689a8b fix(web_fetch): the protocol gate never accepted a URL — the colon bug
URL.protocol carries a trailing colon ("https:", WHATWG); the colon-less
comparison (a direct port from the py harness, where urlparse().scheme
has none) rejected EVERY valid URL ever fetched — the tool never fetched
a page in its life. Owner-reported via OpenRouter logs: 8 valid URLs in,
8 'invalid URL' results out.

The new pin stubs globalThis.fetch so the happy path runs hermetically —
a valid https URL must REACH the fetch stage (and ftp:// still rejected).

dist rebuilt (desktop:dist) — bundle verified: "http:"!==t.protocol
2026-10-07 22:08:29 +03:00
amosblomqvist 34f498b128 artifacts: the height ratchet fixed, invert switch persisted + restyled (release polish 7.2)
- bridge measures the BODY (scrollHeight + rect + margins) instead of the
  viewport-clamped documentElement — documentElement.scrollHeight latches
  at the parent's own height, so demos could grow but never shrink on
  window resize (owner-reported); smoke: 1036 → 2386 → 1036 px
- invert now PERSISTS as details.ui.inverted on the steer record, written
  by the silent artifact-ui route (dismissal pattern: no wake, no SSE) and
  seeded through the projection (SubagentArtifactData.ui); survives
  navigation + reload, never enters teacher context or exports
- invert restyled as the house Toggle switch (settings controls) on both
  cards; demo header is the caption grammar: 'INTERACTIVE DEMO — title'
  left (title normal-case, same size/tracking), switch + download far
  right; figure header: label left, switch/download/open far right

1439 tests green (route + pure-seam pins added); live smoke passed
2026-10-07 19:13:24 +03:00
amosblomqvist a763b9fc5c graph: the sliders re-centered on the new defaults (release polish 6.2)
The owner speaks in display values: "labels until zoom" shows ratio/0.5,
so the old default displayed 8× — and "default one" means display 1× =
ratio 0.5, one stop tighter than the round-1 value. Both ladders are
re-centered so the new defaults sit dead-center again (stop #5):

  labelZoom: [0.125, 0.175, 0.25, 0.35, 0.45, 0.5, 0.75, 1, 1.5, 2, 3]
    — default 0.5 (displays 1×); finer steps below the default, the old
    familiar run above; old stored 4/24 snap to 3.
  blur: [10, 15, 20, 25, 30, 40, 50, 60, 80, 100, 120]
    — default 40 dead-center, min 10 per the owner's suggestion, old
    ceiling kept; old 4/5/7.5 snap to the new floor.

The dead-center ladder law is restored with no exceptions; migration
pins updated (blur 8.1 → 10, labelZoom 0.5 now a member).

1428 tests, tsc, lint, build green.
2026-10-07 12:55:11 +03:00
amosblomqvist 22ab520b5c graph: new default render tuning — blur 40, labels until zoom (release polish 6)
Two defaults moved by owner ruling (2026-10-07): the region blur
doubles 20 → 40 (softer, wider cluster tints) and labelZoom drops 4 → 1
(node labels hidden at the overview, shown once the camera reaches fit
zoom or closer). Stored values are untouched — only the absent-field
fallbacks moved. The dead-center ladder law gains two owner-moved
exceptions (labelZoom index 1, regions.blur index 7); every other
default stays stop #5.

1428 tests, tsc, lint, build green.
2026-10-07 12:46:28 +03:00
amosblomqvist 0b2e4e03fb sessions: export as markdown — the sidebar right-click, the route, the practice join (release polish 3)
Owner round (2026-10-06): markdown export of sessions, actioned from
the sidebar context menu — "Export as Markdown…" sits with the
management actions, above Delete (the destructive action stays last).

- GET /api/chats/:id/markdown (404 unknown): the whole session as one
  attachment — text/markdown; charset=utf-8, filename from the slugified
  title ("relational-databases-joins-normal-forms.md", non-ascii titles
  fall back to "session.md"). Both session kinds export.
- The client is a same-origin anchor download; the route's
  Content-Disposition names the file.
- sessionMarkdown's user-facing debut — minted ANCHORLESS (message-id
  anchors are extraction provenance, not learner-facing). The engine
  joins the core barrel (pure, browser-safe) for its first consumer.

The practice join (owner-approved mid-round): practice tool results are
bare acks — statement, outcome, verdict, and the model solution all
live in the assistant's call arguments. The engine now folds them the
same way the view seam folds cards: Problem blocks render the pose's
statement (solution never leaks at pose time); Concluded blocks render
the outcome as prose ("Solved with hints.") + verdict, then the
pose-committed model solution as a blockquoted reveal via
problemInputForConclude (clean-poses-only, its refusal law). A
dismissed problem concludes as "The learner skipped this problem." —
no reveal. Errored poses render nothing (the failed-tool law).

1427 tests (route pins: headers, slug, anchorless body, 404; engine
pins: pose-time statement, the reveal, the dismissal refusal, the
errored-pose silence); tsc, lint, build green.
2026-10-06 10:23:16 +03:00
amosblomqvist 962d3086c0 graph: the rendering switch — chains view as the home (release polish 2)
The settings Graph section splits into two switches (owner, 2026-10-06):

- EXTRACTION (was the lone "Graph" row): the DATA channel — pipeline,
  auditor, prober graph tools. Key unchanged (graph.enabled), label now
  names its job; off = the graph stops growing, nothing is deleted.
- GRAPH RENDERING (new, graph.rendering, default on): display-only.
  Off = the home's main view is the CHAINS VIEW instead of the canvas;
  data, reasoning-chain model, and the pipeline keep working untouched.

The chains home (components/chains-home.tsx): ChainTableBody in flow —
same body as the overlay, same powers (query + sort, selection, the
delete fence with its confirm dialog), but onClose is absent so the
top-bar X does not render (the table IS the page; ChainTableBody now
takes onClose optionally). The chrome survivors float top-right below
the top bar: Import notes (ALWAYS — the entry-point law) + the cost
pill (GraphCostControl, now exported). The composer blob persists over
everything; the table scrolls CLEAR of it via a fixed pb-40 band (text
under a floating box is noise — unlike the canvas, which runs under).

Pick flow mirrors the overlay contract exactly (home lifts the pick
set; selectMode swaps the bar's confirm to Done). The chip relabels to
"Pick propositions" (no "from the map" — there is no map); while
picking, the chip row hides so the floating bar above the composer is
the one voice. Settings resolve rendering through the one core
resolver + renderingEnabled(); read server-side per navigation, so the
switch applies on the next visit home.

Smoke-verified live (headless browser): chains home renders without the
X, import + search + composer clearance confirmed, pick flow checked 2
propositions -> Done -> chip count -> practice session started with the
graph context carried (2 props, 2 chains), settings restored.

Known follow-ups (flagged, not built): after Done the bar morphs to the
table's Clear/Delete management bar while the chip also shows the count
(two voices, same set — the table's own editor law); /chat/new's pick
chip still says "from the map".

Tests: rendering in factory/sanitize/parse/resolve pins, the GraphSection
render pins (Extraction label + rendering row), the home-variant
chain-table pin (no close X). 1414 pass; tsc, lint, build green.
2026-10-06 08:26:55 +03:00
amosblomqvist 8a6eab0b6a titles: smart truncation — the cut snaps to word boundaries
Owner round (2026-10-06, release polish #3): first messages are always
long, and the auto-title's hard 72-char cut landed mid-word. The cut now
snaps to the last word boundary at or under TITLE_MAX_CHARS (72, exported
and tunable); whitespace collapses first so boundaries are single-space.
The one special case (owner-specified): a single word longer than the cap
— rare but real: identifiers, URLs — splits at the cap. A boundary landing
exactly at the cap is a clean cut. Seam unchanged: titleFromPrompt
(core/contracts/graph.ts), applied once server-side at chat creation.
Pinned in tests/title-from-prompt.test.ts (6).
2026-10-06 07:49:38 +03:00
amosblomqvist f82318c9e4 graph: persist the arg_ implication nodes — the splatter fix
Diagnosed (owner report 2026-10-06, 'splattered from the middle on
every navigation'): the canvas's world is 350 nodes — 262 stored
propositions + 88 derived arg_ implication nodes the chain arrows
bend through — but the drag-commit sanitizer accepted only stored
proposition ids, and writePositions REPLACES the file: every
drag-commit purged the arg_ entries, so every mount re-seeded them
on a ~1306-unit ring (seedMissingNodePositions) — arrows streaking
out of the middle, bbox inflated, fit zoomed out. A drag annealed
them back (impLinks springs), the commit stripped them again.

Fix: the sanitizer's known set = deriveView(getGraph()).nodes —
propositions AND arg_ ids (stable: minted from the chain ledger).
The grew-check becomes the honest test — some CURRENT view node
absent from the payload (a merge landed mid-drag) → exactly one
absorbing settle; full coverage → none (the old proposition-count
comparison broke once args counted).

Live ledger backfilled once: 88 arg_ positions seeded at their
chains' centroids, propositions byte-identical (no settle, no
re-anneal). Pinned in tests/commit-positions.test.ts (hermetic
LH_DATA_DIR): args persist, unknown ids drop, no-settle on full
coverage, absorbing settle on a stale payload, garbage sanitizing.
2026-10-06 04:46:26 +03:00
amosblomqvist 793638d6ce practice: the agent's data drawer — full transparency on every conclude card (round 17)
Owner ask (2026-10-06): reveal everything the agent filled in when
writing the problem and concluding it, at the very bottom of the
conclude card, collapsible, arriving collapsed.

The fields, both calls:
- problem: mode (recall/discrimination/composition), target
  proposition ids, statement, modelSolution (the last two ARE the
  visible card — the drawer shows the metadata only)
- conclude: outcome, hint rung (R0–R5/null), miss class
  (cue/discrimination/execution/careless/structural/null), retest.

Seams: problemInputForConclude (native-messages) walks the reveal's
walk and folds the WHOLE problem-call input onto the conclude part
(projectView join; same refused-pose law — an unacked pose folds
nothing); ArtifactPartProps grows problemInput. Targets resolve to
proposition NAMES at render (one lazy module-cached /api/graph fetch;
failure degrades to raw ids — observability never breaks the card).
The drawer: divider + chevron 'AGENT'S DATA' toggle (mono-caps
eyebrow register), definition rows with mono values, 'untagged'/
'none' honest empties, absent entirely when the calls filled nothing
(the dismissal line, legacy bare records).
2026-10-06 04:18:26 +03:00
amosblomqvist d1f085550b practice: the next affordance IS the send button — wide green morph in the pause (round 16 refinement)
Owner correction (2026-10-06): the floating chip clipped inside the
composer band. Replaced with the button morph: an EMPTY composer in
the between-problems pause renders a wide green 'Next problem' button
(bg-green-600 — the answered-card correct register, mono-caps label,
arrow glyph); typing anything returns the regular white send; busy
(steer/stop) outranks the morph. Click and Enter share the one
act() lane, so the button also teaches the Enter-on-empty behavior.
The wrapper div + chip are gone from chat.tsx; inputAppends widens
its record param to { messages; kind? } (the practice gate reads it
without a cast).
2026-10-06 03:55:14 +03:00
amosblomqvist 573d89ae66 practice: the between-problems pause — conclude ends the turn, the learner's go-ahead paces the stream (round 16)
Owner ruling (2026-10-06): every concluded problem opens a pause. The
agent may speak its closing words about the learner's last thinking
after the conclude result, then ENDS ITS TURN — a system-prompt law
deliberately, per owner correction, NOT tool-level terminate. The next
problem is never posed until the learner explicitly moves on; the pause
is the session's teaching space (repairs, clarifications, follow-ups —
still surgical, never re-teaching).

The move-on signal is a first-class input: { type: "next" } appends a
practice-next marker (content = single-sourced NEXT_PROBLEM_TEXT — the
model's unambiguous go-ahead: pose per the selection rules or wrap if
the arc is complete). Honest 400s: practice sessions only, nothing
active (mid-problem input rides the dock), riding after any pending
dismissal synthesis exactly like text.

The fast path (owner approved: composer-band chip + Enter-on-empty):
- Composer.enterAction gains "next" — Enter on an EMPTY composer in
  the pause fires the signal; typing anything makes Enter a send again;
  busy (steer/stop) outranks the pause.
- The Next chip hovers at the composer band's top-right (pick-pill
  chrome register) whenever the pause is live and a problem has ever
  been posed; the placeholder becomes the hint ("Ask about this
  problem — or press Enter for the next one…", exact);
- the transcript renders the signal as a compact centered NEXT PROBLEM
  line (mono-caps chrome); session-core appends it optimistically.

CONCLUDE_ACK_TEXT rewritten to carry the law; doctrine §VIII gains the
pause law (owner-sanctioned edit #3); 00-base's conclude/dismissal
bullets rewritten + the between-problems section added.
2026-10-06 03:47:05 +03:00
amosblomqvist a2db80b72d compaction: the usage-anchored trigger + visible session state (round 15)
The relational-databases incident, diagnosed and fixed:

TRIGGER — pi's estimator law, adopted exactly: anchor on the last
assistant message's provider-reported usage (totalTokens || the field
sum) and chars/4-estimate ONLY what was appended after it. The anchor
scan skips aborted/zero-usage assistants and STOPS at the newest
compaction marker (pre-cut usage described context the next request
will not carry — pi's distrust rule). The old estimator
(JSON.stringify of every message /4) tripled the true context
(measured: 84,412 'tokens' estimated vs 38,799 provider-reported on
the incident record) — scaffolding, escaped LaTeX, thinking blocks
and app-side details all counted. estimateMessageTokens now counts
wire text only (text + thinking + toolCall name/args; content text
for user/toolResult/custom). The unanchored fallback runs the plain
heuristic over the CUT-HONORED projection plus the additive prompt
term; the anchored path adds nothing (usage already billed prompt
and tools).

OBSERVABILITY — compaction is a visible session-wide state (owner
spec): the transcript-bottom placeholder register gains COMPACTING
CONTEXT… (BrailleSpinner + Shimmer, the THINKING placeholder's twin,
mutually exclusive by construction — the gate precedes beginTurn),
and the sidebar row shows 'Compacting context · <ago>' with the
spinner (sessionMeta outranks busy; compacting-changed joins
turn-started/turn-settled as refresh triggers). Liveness rides the
API like turn-busy: ChatListItem.compacting + compactionInFlight on
both GET /api/chats and GET /api/chats/[id] (initiallyCompacting
covers the navigate-back-mid-compaction mount — the event already
fired), exported through the server barrel.

Also: the relational-databases record rolled back to pre-compaction
(marker, post-marker turns, tree reset; backup in /tmp) for a clean
retest. The summarizer-usage 'gap' was a misread — steps[] was
populated all along.
2026-10-06 00:38:15 +03:00
amosblomqvist 6dcad55175 compaction: the leading system message is request chrome, never cut (fix the no-tools request)
Owner report (OpenRouter logs, 2026-10-05): the post-compaction request
arrived with NO system prompt and NO tool definitions — everything
before the cut replaced by the compaction message.

Root cause: pi-agent-core's Agent seeds a SystemMessage carrying the
system prompt AND the tool declarations at state.messages[0]
(createInitialSystemMessage — 'messages declare the provider-visible
loadout'). The record never persists it (ui-stream's settleWith filters
the role), but convertToLlm DOES receive it — and the cut projection
sliced from firstKeptMessageId, dropping [0] from the wire: no prompt,
no tools.

Fix (pi's own law — verified against pi-agent-core + pi's coding-agent):
the projection now carries the leading system message through AHEAD of
the compaction head, exactly as pi's prepareRequest re-applies
context.systemPrompt + context.tools around its compaction projection.
The cut slices the RECORD's history; the chrome is never compactable
payload. Loop start skips [0] when chrome is present (no duplicate);
record-path callers are unchanged.

Pinned: chrome-first-through-cut, exactly-once without a cut,
record-path head-first.
2026-10-05 23:52:19 +03:00
amosblomqvist ffc4df1938 compaction: the surface — settings row, two ruled dividers, summary dialog, send gate (round 14 slice 3)
- settings: compaction.thresholdTokens row on the Agent tab (owner pick)
  in a Context group — CommitInput mirroring the graph budget row, hint
  resolved through the SAME core resolver the trigger reads; PUT rejects
  below the 32k floor with the anti-churn rationale; own section
  (compaction) in SETTINGS_SECTIONS + the settings-signal union
- view fold: one marker → two dividers — the minimal MOMENT divider at
  the marker (retrying-message register, no rule lines) and the longer
  CUT divider emitted immediately before the first kept message
  (phase-divider geometry, chip carries '· Nk tok'); both carry the full
  summary payload; full history stays visible to the user
- components/compaction-divider.tsx: the dividers + summary dialog
  (pure exported body for SSR pins, the delete-dialog pattern; Esc +
  Close dismiss, backdrop does not)
- chat.tsx: compaction-changed SSE flips the running state — composer
  canSend, onSend/doSend/doSteerOrSend, and both docks' submit + hint
  ('compacting session…') gate on it (the server's registry mutex is the
  structural gate; this is the affordance); dialog mounts beside the
  abort dialog
- pins: settings-body (4), view fold (2), divider SSR (4)
2026-10-05 22:51:08 +03:00
amosblomqvist dd159cc8fb compaction: the service — settle kick, turn-start gate, registry mutex (round 14 slice 2)
- src/server/compaction.ts: ensureCompacted (the turn-start gate — awaits
  in-flight compaction, compacts synchronously when still over threshold),
  kickCompaction (the settle kick — summary starts inside the learner's
  reading/answering pause; question/problem poses end runs so the pause is
  always a between-runs window), registry mutex on globalThis, anti-churn
  guard (empty-span skip), summary via the zero-tool one-shot sub-agent
  seam on the session's OWN engine (owner ruling), usage to the turns log
  keyed by the marker, record-changed + compaction-changed events
- chat-handler: the gate rides with the BYOK/key gates (before appends,
  beginTurn; record re-read after); the kick at the onFinish tail
  (re-resolves engine fresh; silent no-op when engineless)
- events: compaction-changed {chatId, running} — running=true is the
  client send-gate signal
- settings: compaction.thresholdTokens (factory 150k, floor 32k,
  clamp-never-reject) resolved concrete in EffectiveSettings
- effective threshold = min(setting, contextWindow − 16k) over the live
  catalog index
- overflow recovery needs no special path: the next turn's gate compacts
- 8 service pins (mutex, events order, chained spans, failure re-arm)
2026-10-05 22:17:33 +03:00
amosblomqvist 7e9597d969 compaction: the pure core — cut walk, marker, and the model-facing projection (round 14 slice 1)
- core/compaction.ts: compactionCutIndex (backwards ~20k-token walk, NEVER
  separates a tool result from its call — the cut extends to the call's
  assistant message; a pending pose always survives), estimateTokens
  (chars/4, pi's method), newCompactionMarker (header + summary; details
  carry firstKeptMessageId/tokensBefore/model/at), serializeForSummary
  (labeled text, tool results truncated ~2k), planTreeToText (verbatim
  tree with transcript-derived statuses), buildCompactionPrompt (the four
  ruled sections: learner understanding + evolution, teaching arc, plan
  tree verbatim, current moment)
- toLlmMessages honors the cut: the ACTIVE (newest) marker renders ONCE
  as the head; everything before firstKeptMessageId drops; corrupt cut
  degrades honestly (everything renders, never throw)
- constants: KEEP_RECENT 20k, RESERVE 16k, DEFAULT_THRESHOLD 150k
- 16 test pins
2026-10-05 22:06:14 +03:00
amosblomqvist 0c9f82e027 prompts: Voice + Accuracy become shared fragments — one law across every agent (round 13 part A)
- shared/10-voice.md (agent-neutral) + shared/15-accuracy.md (research-tool
  truth contract) compose for teacher, prober, AND practice
- PRACTICE_SHARED_FRAGMENTS grows beyond 40-formatting.md
- practice 00-base.md drops its stale near-copy ("You have no research tool
  here" was false since round 10); the solve-and-shortcut-hunt line folds
  into the problem paragraph
- teaching system-prompt.md slims (Voice/Accuracy sections removed)
- pins updated: practice-agents composition, prompt-overrides segment mirror
2026-10-05 21:44:34 +03:00
amosblomqvist 4f3bbf61d0 practice: untagged problems — target optional, topic sessions work (owner round 12)
A session whose material is not in the map is a SUPPORTED mode now:
target is optional (absent = untagged problem, poses freely), an
explicit [] heals away at the seam (the record's canonical form is
tagged-or-absent), and when targets ARE present the round-9 law is
unchanged — whole-graph validation, names normalized to ids. The
rejection message teaches the exit ('omit target entirely'), so a
model inventing targets for an off-graph topic converges to omitting
on the retry; omitting always works, no brick is possible. Prompt
carries the clause: material in the map → ids; not → omit, problems
stand on their own.
2026-10-05 20:23:48 +03:00
amosblomqvist a1596556df practice: the rarely-needed set — files, research, pdf, map readers (round 10 slice 1)
The practice registry widens beyond problem+conclude: read/ls/find/grep
(the learner-filesystem set, always), research + pdf jobs (research
honestly absent when no engine is live; demo/visual stay teach-only),
and the prober's map readers behind the SAME settings gate as the
prober's (graphToolsEnabled at the composition root). Base prompt
carries the conditional doctrine: reach for them only when the
situation genuinely requires — no propositions → look at the map;
learner references a file → read it; load-bearing uncertainty →
research it. Not toggle-filtered: problem/conclude are the session,
these are exceptions.
2026-10-05 06:49:15 +03:00