Commit Graph
9 Commits
Author SHA1 Message Date
amosblomqvist 0c14ea425d Option A: incremental anchored force layout, persisted server-side
The layout now EVOLVES instead of reshuffling on every extraction. A pure
core module (src/core/layout.ts) warm-starts forceAtlas2 from the positions
ledger (data/graph/positions.json, read-modify-write like sources.json,
updatedAt stamp for future live-physics conflict handling), re-blends every
previously-positioned node toward its anchor each iteration (retention 0.85
- old placements stay recognizable, strong forces still win), seeds new
nodes near their positioned neighbors, and seeds orphans on the rim outside
the core so they read as unconnected knowledge.

- Port/store: GraphStore.readPositions/writePositions; snapshot carries
  positions when the ledger exists (optional - fresh graphs unchanged).
- Extraction pipeline settles synchronously after every merge (chat and
  notes paths), before usage persist - the next /api/graph fetch already
  reflects new nodes.
- View: the ledger IS the layout; the local one-shot FA2 pass survives
  only as a fallback when no ledger exists.
- scripts/replay-layout.ts: simulates organic growth by replaying the
  current 338-node graph one imported file at a time (real import order,
  edges activate when both endpoints exist). Real run: 338 positions in
  0.3s, 90-degree-0 orphans collecting at mean r=338 vs connected r=194.
- Also restores the cluster region fills (hull background colors) that
  6692b19 had removed on a misreading of "no glow".

Live-verified: fit zoom derives from the ledger bbox, pickAt aligns with
ledger positions at every probe, hover/click/detail-card work, 408/408
tests, typecheck/lint/build green.

Future live physics stays cheap by design: the same pure function is the
merge-time settler and the future client simulator; a pinned node is just
an anchor with full retention.
2026-09-13 08:35:42 +03:00
amosblomqvist f0267a5fd9 Clusters phases B+C: labeling records, the naming call, snapshot payload
Core-survival labelings (vision section 1, DECIDED 2026-09-13):
ClusterLabeling records in a new append-only ledger
(data/graph/cluster-labels.jsonl), immutable, id = clu_ + sha256 of the
sorted core so re-naming races and re-runs dedupe at the store seam.
matchClusterLabelings (core/clusters.ts, pure): a community keeps a
record's label while at least half its core survives in it; both sides
single-successor (a split keeps one name; greedy by overlap, ties to
the newer record).

The namer (src/agent/subagents/cluster-namer.ts): ONE singular cheap LLM
call (new 'labeler' role, glm-flash/low) names every new/changed
community at once, given member names + statements (capped at 12
members per cluster). refreshClusterLabels runs POST-EXTRACTION
(user-decided) from both front doors, fire-and-forget — naming failure
never fails the run; refreshes serialize behind a chain so parallel
imports never mint duplicate labels. import-notes.ts awaits
clusterNamingSettled before exit.

Payload: GraphSnapshot.clusters (>= MIN_CLUSTER_SIZE 3) joins detection
with labelings at read time — claimed clusters carry the record's label
and id; unlabeled ones carry a member-hash id and null label. Detection
is deterministic so no memo in v1 (ms at current scale; revisit at
vault scale).

Tests (372/372): matcher half-rule/split/merge/greedy/tie cases; naming
end-to-end through importNotes (one call, one record, claim on
re-refresh), naming failure non-fatal; snapshot derivation with
labeling join; 11 test pipelines now pin an explicit labeler seam.
2026-09-13 03:59:48 +03:00
amosblomqvist 543cf33789 Notes front door: format-agnostic import into the graph pipeline
The same extractor→auditor→merger pipeline now runs over raw personal
notes in arbitrary formats (vision §1, DECIDED 2026-09-13):

- NotesSource records (data/graph/sources.json, deterministic id from
  path hash → idempotent re-imports) play the chatId role in mappings,
  checkpoints, and observation events; usage lands on the source record
  (appendSourceUsage, idempotent per startedAt, kept across upserts).
- mergeFindings is chat-store-free: callers pass MergeSource
  {messages, foldedAt} — sessions pass the chat record, notes pass
  messages:null (no quiz-event rebuild; a deterministic rebuild needs
  structured quiz tool parts).
- Second extractor prompt (NOTES_EXTRACTOR_INSTRUCTIONS) for general-
  purpose extraction; understanding judges the AUTHOR's grasp. Auditor
  wording input-agnostic. extract/audit share one generic runSubmitAgent.
- graphSnapshot.fileSources (srcId→filePath); notes sources render as
  static path entries in the detail card (FileTextIcon, never a link).
- One shared pipeline instance (exported from chat-handler): same
  single-flight registry, live status chips (label = file path), usage
  accounting, and true abort for both front doors.
- Transport: POST /api/graph/import (multipart, 3-wide worker pool) +
  Import notes… button in the graph view; scripts/import-notes.ts for
  batch runs (concurrency 4).
- real-data-extraction/ gitignored (raw import sources never committed).

355/355 tests; typecheck, lint, build green.
2026-09-13 02:08:55 +03:00
amosblomqvist de0c286311 feat: model + thinking control — the stats bar's picker, on pi-ai's catalog
The model-control wave (user-decided design, 2026-09-12): ONE controller
for the conversation agents (prober/teacher are functionally one agent);
background sub-agents stay config-pinned. pi-ai owns the registry — the
shipped 366-model OpenRouter catalog (cost, context, modality,
thinkingLevelMap), getSupportedThinkingLevels/clampThinkingLevel for the
per-model thinking truth, and streamSimple's automatic clamp. No second
registry to drift out of sync.

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

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

Live smoke (disposable driver, deleted): catalog route 366 + defaults;
glm+minimal → 400 + supported; turn 1 billed qwen/qwen3.8-flash under
the override; mid-session switch flipped turn 2 to glm; clear-overrides
restored the config pin. ALL CHECKS PASS on real OpenRouter.
2026-09-12 06:02:40 +03:00
amosblomqvist 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 b07c907ffd Graph redesign: true propositions, follows/supports, auditor-owned identity (vision §1 minimal implementation)
The minimal implementation of the 2026-09-02 graph vision (docs/vision.md §1):

- contracts/graph.ts: every node is a TRUE proposition (statement is both
  rendering label and identity key; required description; no kind, no name).
  Edges are follows (derivable) / supports (grounds without entailing) with
  message-granular provenance points {chatId, messageId}. Events reduce to
  correct/miss/dont-know plus observation (understanding notes); no taught
  events, no misconception kind.
- extraction.ts: extractor is TREE-BLIND (transcript only, no plan tree) and
  free-form; auditor sees findings + the current graph, owns faithfulness and
  identity (link-or-create verdict per proposition, recorded as linkTo);
  transcriptView tags every line with its message id (provenance anchors).
- core/graph.ts merger: applies verdicts deterministically — linkTo appends
  a link line (phrasings join aliases, provenance unions; reads merge by id),
  absent creates; NO string matching anywhere. Quiz events rebuilt from tool
  parts with attribution-only nodeId resolution; understanding notes become
  observation events (obs:<sourceId>); mapping lines are the verdict audit
  trail and make re-merges idempotent.
- deriveNodeStates: transitional — latest quiz event wins (known/missed/
  dont-know, else candidate); taught unreachable until the state-model
  ideation lands.
- graph-context.ts + 50-graph-context.md: follows chains, hotspot grouping,
  state vocabulary updated for the new event kinds.
- graph-view.tsx: node label is the statement; detail shows statement +
  description.
- tests: merger verdicts (link, no-string-match, cross-chat assertions),
  tree-blind pipeline end-to-end, follows vocabulary in context tests.
  276/276 green.
2026-09-08 22:19:37 +03:00
Amos 1167157c23 Storage ports: ChatStore/GraphStore/JobStore + adapters + core inversion (stage 3)
Per-entity semantic ports (src/core/ports.ts), one adapter family
(src/store/*, fs JSON/JSONL), exactly one composition point
(src/server/stores.ts). Dependency direction is now machine-checked:
adapters implement core interfaces; core never imports an adapter.

The inversion:
- core/graph.ts (emitChat/getGraph/graphSnapshot/foldStaleChats) is now
  pure — it takes the storage ports as explicit parameters (GraphDeps).
  No store imports; newGraphNodeId is local; node id format unchanged.
- core/graph-context.ts buildGraphContext() no longer defaults to the
  live ledgers: the chat-handler hands in getGraph(graphStore) per turn.
- store adapters export exactly one object each implementing their port
  (chatStore/graphStore/jobStore); bodies otherwise verbatim.
  NodeMapping + SessionCheckpoint moved store/graph -> core/contracts
  (they ARE contract shapes); CreateJobInit/JobPatch live in ports.
- titleFromPrompt moved store/chats -> core/contracts (pure helper,
  planNodeStatus precedent). Store modules now contain persistence only.
- server/stores.ts is the composition root; server/{chats,graph,jobs}.ts
  and chat-handler wire from it; agent (plan tools, sub-agent runner)
  call the port objects. The shell surface (@/src/server exports) is
  unchanged.

Lint law extended: the whole of src/core is now a purity zone (was a
file glob with a graph/graph-context exemption). Verified it fires on
core->store imports; tsc clean, 190/190 tests, eslint clean, prod build
clean (prerender 6/6). Tests pass deps explicitly — graph.test uses the
same fs-adapter pair stores.ts wires in production; graph-context.test
keeps its in-memory ledgers as a GraphStore implementation.
2026-08-31 20:00:12 +03:00