Commit Graph
8 Commits
Author SHA1 Message Date
amosblomqvist de0c286311 feat: model + thinking control — the stats bar's picker, on pi-ai's catalog
The model-control wave (user-decided design, 2026-09-12): ONE controller
for the conversation agents (prober/teacher are functionally one agent);
background sub-agents stay config-pinned. pi-ai owns the registry — the
shipped 366-model OpenRouter catalog (cost, context, modality,
thinkingLevelMap), getSupportedThinkingLevels/clampThinkingLevel for the
per-model thinking truth, and streamSimple's automatic clamp. No second
registry to drift out of sync.

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

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

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

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

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

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

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

Gates: tsc, lint, build, 335/335.
2026-09-12 05:14:40 +03:00
amosblomqvist b2c89b8c91 feat: the plan verdict gate — acceptance is the plan→teach seam
The accept-plan UI lands (docs/probe-teach-split.md §4, formerly the
interim rule): presenting the plan no longer moves the phase — the
learner's ACCEPT does. While the presented plan awaits its verdict, the
composer's send square becomes two stacked buttons (green Accept plan /
red Decline); typed text rides the verdict as his note, Enter keeps
sending plain side questions (phase unmoved), decline asks for a
revision and the buttons stay.

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

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

Gates: tsc clean, 304/304 (phases tests rewritten to the acceptance
seam incl. decline/side-question/failed-init cases; composer verdict
render tests), lint, build. Live GLM smoke ALL PASS: phase pinned at
'plan' after plan_init, presentation turn carried NO check, accept
click flipped the summary to 'teach', then tagged checks moved the map
(current=rates, visited=composite); zero console errors.
2026-09-11 21:26:29 +03:00
amosblomqvist 5653c77a29 feat: probe/teach agent split — explicit phase markers, position-only plan map
Diagnosis (2026-09-11 user testing, minimal-effort GLM): sessions stuck on
'plan' forever. Two causes: (1) the three-phase view read the stored tree's
currentNodeId, which nothing ever writes post author-once (2026-09-03) — a
structural dead seam; (2) the teacher stochastically left quiz.nodeId
untagged, and done-by-quiz-result semantics would have falsely completed
nodes anyway.

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

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

Gates: tsc clean, 294/294 tests (phases/plan-tree/plan-blocks/question-
tools rewritten to the new semantics; probe-done + handoff-wake tests
added), lint, build. Live GLM smoke PASS: prober concluded with an honest
assessment, teacher auto-woke, authored a 10-node plan with the block
attached, then tagged checks on slope-instinct → secant derived current/
visited exactly as designed; grading honest both ways (driver off-by-one
proved a wrong answer grades false).
2026-09-11 06:44:20 +03:00
amosblomqvist 741ff416d6 Fix turn-usage keying: idempotent per request turn id, not messageId
Live smoke exposed two bugs in the turn usage log:
- onFinish messages carry no ids (withMessageIds fills copies inside
  persistTurn), so fresh turns keyed messageId "" — provenance lost.
- The SDK coalesces continuation turns into ONE response message (a whole
  probe conversation lands in a single assistant message), so per-messageId
  idempotency swallowed every continuation turn's usage.

TurnUsage gains id: string (unique per request, the append idempotency
key); messageId stays as transcript provenance, taken from the id-filled
list. appendTurnUsage dedupes by id; continuation turns with the same
messageId each keep their usage.
2026-09-11 04:16:05 +03:00
amosblomqvist 9174cde55e Extraction observability: usage capture, live status payload, true abort (Wave 4)
Extraction is real spend and background work; the dock needs to see
and stop it. User decisions: persist all cost data (chat record),
per-stage usage granularity, and TRUE cancellation for abort.

USAGE CAPTURE (live + persisted):
- extraction.ts: per-call onStepFinish captures each billed step's
  usage (SDK LanguageModelUsage -> CallUsage: input/output/cache
  read+write/reasoning) into the registry's live per-stage usage
  (extractor/auditor; the merger is deterministic — no model call).
- /api/graph/status runs now carry usage (model + steps per stage);
  cost/context folds are the consumer's (core/prices.ts — nothing
  stores dollars).
- ChatRecord.extractions: append-only per-run usage log (mirrors the
  turns log): startedAt key, at, durationMs, merge stats when the
  merger ran, stages. Persisted on success AND failure — a failed run
  still spent money. Idempotent per startedAt; the list payload
  (ChatSummary) never leaks it.
- EmissionStats moved to contracts/graph.ts (its contract home);
  core/graph.ts re-exports for existing importers.
- Verified end-to-end against the real pipeline (backfill one chat):
  per-step token counts, reasoning tokens, and the auditor's growing
  prompt-cache reads captured and persisted.

TRUE ABORT (the dock's abort button, user decision):
- RunningExtraction owns one AbortController per run, shared across
  phases; abortExtraction(chatId) fires it and returns whether a run
  was aborted (idempotent no-op otherwise). The pending model call
  receives the signal — the provider cancels the request, spend
  stops. throwIfAborted() at phase boundaries ends runs aborted in
  sync gaps.
- Abort is a deliberate stop, not a failure: same ledger discipline
  (nothing merged, nothing checkpointed, partial usage persisted) but
  the run reports zero emissions instead of throwing. Exported via
  src/server/observability.ts for master's /api/activity/abort route.
- Tests: mid-run abort (hanging mock that rejects on the signal),
  live-accumulation + persisted-record assertions, store-seam
  idempotency, and the summary-payload leak guard.
2026-09-11 03:25:25 +03:00
amosblomqvist 83a00800e9 Observability contracts: usage accounting, plan blocks, price table (Phase 0)
The 2026-09 observability wave's type-level foundation, landed before the
streams so nothing renders against guessed shapes:

- contracts/chat.ts: CallUsage + TurnUsage (per-model-call token accounting,
  one entry per teacher/wake turn keyed by messageId) and ChatRecord.turns —
  a DELIBERATELY parallel append-only usage log, not per-message metadata:
  the client re-POSTs its message list every turn and its copies never
  carry server-stamped fields, so anything inside messages gets clobbered
  by the next persist (the wave-3 merge-rule lesson).
- contracts/chat.ts: PlanBlockData — the persisted data-plan-block part
  (full DAG on plan_init, one-level neighborhood on plan updates, snapshot
  semantics so history renders what was true then).
- contracts/jobs.ts: JobUsage + JobRecord.usage — a sub-agent run's
  accumulated accounting, written by the runner.
- core/ports.ts + store/chats.ts: ChatStore.appendTurnUsage — idempotent
  per messageId (abort replays / wake retries append once); listChats no
  longer leaks turns into the list payload.
- core/prices.ts: PRICES + CONTEXT_LIMITS (generated from OpenRouter's
  published pricing via scripts/sync-pricing.ts) + pure folds: callCost,
  turnCost, sessionCost, contextWindow, jobCost. Unknown model or absent
  fields render as absent, never zero.
- server/observability.ts: barrel-approved surface for the transport layer
  (fixes the import-law violation in the graph status route that slipped
  through the wave-3 merge verification).

Capture wiring (agent onStepFinish -> persist seam, plan-block emission)
lands next as master seam work; the streams start against these shapes.
2026-09-11 02:43:35 +03:00
amosblomqvist 8a9497bf59 Split contracts.ts by domain: plan / graph / chat / jobs
Purely mechanical: src/core/contracts/{plan,graph,chat,jobs}.ts carry the
sections verbatim (graph = nodes + ledgers + extraction findings; jobs =
the async ack-and-steer protocol + tool results + demo bridge), and
contracts.ts becomes a re-exporting barrel so every existing import path
(@/src/core and ./contracts.ts) is unchanged.

Wave-2 lane ownership follows the split: the graph stream owns
contracts/graph.ts, the teacher stream owns contracts/jobs.ts — same-file
contract collisions between streams are structurally impossible now;
cross-domain schema changes go through master.
2026-09-05 21:20:38 +03:00