diff --git a/.github/workflows/mindmap-tests.yml b/.github/workflows/mindmap-tests.yml new file mode 100644 index 00000000..7f12698d --- /dev/null +++ b/.github/workflows/mindmap-tests.yml @@ -0,0 +1,97 @@ +name: Mindmap launcher tests + +on: + pull_request: + paths: + - "prototype-mindmap/**" + - "frontend/src/api/handoff.ts" + - "frontend/src/api/wordEditorAPI.ts" + - "frontend/src/pages/tools/**" + - "frontend/src/vite-env.d.ts" + - "backend/src/app.ts" + - "backend/src/openaiProxy.ts" + - "backend/src/toolGrants.ts" + - "backend/src/__tests__/handoff.test.ts" + - "backend/src/__tests__/openaiProxy.test.ts" + - "scripts/get_env.py" + - "scripts/test_get_env.py" + - ".github/workflows/mindmap-tests.yml" + push: + branches: [main, mindmap-main] + paths: + - "prototype-mindmap/**" + - "frontend/src/api/handoff.ts" + - "frontend/src/api/wordEditorAPI.ts" + - "frontend/src/pages/tools/**" + - "backend/src/app.ts" + - "backend/src/openaiProxy.ts" + - "backend/src/toolGrants.ts" + - "scripts/get_env.py" + - ".github/workflows/mindmap-tests.yml" + +permissions: + contents: read + +jobs: + integration-units: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: 24 + cache: npm + cache-dependency-path: | + backend/package-lock.json + frontend/package-lock.json + - name: Backend dependencies + working-directory: backend + run: npm ci + - name: Backend typecheck and tests + working-directory: backend + run: npm run typecheck && npm test + - name: Frontend dependencies + working-directory: frontend + run: npm ci + - name: Frontend typecheck and tests + working-directory: frontend + run: npm run typecheck && npm test + - name: get_env formatting and idempotency tests + run: python -m unittest scripts/test_get_env.py + + mindmap: + runs-on: ubuntu-latest + defaults: + run: + working-directory: prototype-mindmap + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: 24 + cache: npm + cache-dependency-path: prototype-mindmap/package-lock.json + - run: npm ci + - name: TypeScript + run: npx tsc --noEmit + - name: Vitest + run: npm test + - name: Eval typecheck + run: npm run eval:typecheck + - name: Production build + env: + VITE_BACKEND_URL: https://ci.invalid/api + VITE_REQUIRE_LAUNCH: "true" + run: npm run build + - name: Install Chromium + run: npx playwright install --with-deps chromium + - name: Playwright mocked smoke + run: npm run test:e2e + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@v6 + with: + name: mindmap-playwright-report + path: prototype-mindmap/playwright-report + if-no-files-found: ignore + retention-days: 7 diff --git a/AGENT-HANDOFF.md b/AGENT-HANDOFF.md new file mode 100644 index 00000000..7bb98b20 --- /dev/null +++ b/AGENT-HANDOFF.md @@ -0,0 +1,826 @@ +# Agent Handoff — Mindmap Prototype + +Last updated: 2026-07-24 America/New_York. Tracked successor to the old +untracked `CODEX-HANDOFF-next-chat-untracked.md` (retired). Audience: any +agent or human building features on `mindmap-main`. +This doc tells you what's here, **what is about to land** (so you don't +collide with it), and what must never change without asking. + +Prototype path: `prototype-mindmap/` + +## Read first (in order) + +1. `prototype-mindmap/docs/DESIGN.md` — canonical invariants (current). +2. `prototype-mindmap/docs/airtightness-report.md` — enforcement appendix. +3. `prototype-mindmap/docs/refactor-plan.md` — staged rationale + resolved decisions. +4. Pass docs: + - `docs/6c-turn-progress-narration-pass.md` — self-contained impl spec for + the ONE remaining Tier-2 code change (item 6c). Build this next. + - `docs/stage-4-eval-harness-pass.md` — the ACTIVE eval track. + - `docs/multilingual-grounding-pass.md` — multilingual pass (landed/accepted). + - `docs/stage-1_5-fix-pass.md` — historical; context for recent changes. + +## Current verified state (baseline `9ec90e6`; see the 2026-07-27 note below for the current branch tip) + +Stage 1/1.5 typed-proposal runtime, all three assistance contracts (L0/L1/L2), +passive draft anchors, immutable draft snapshots, controlled recall, terminal +recovery, L1 draft-grounded mirrors, three-tier provenance +(`user_asserted` / `ai_connected` / `ai_suggested`), sticky suggestion-adoption +tracing (incl. the no-retroactive-adoption boundary fix), and `grounded_recap` +are complete. The legacy regex controller is deleted. + +Verification at this checkpoint: `tsc` and eval type-checks pass, 270 Vitest +tests pass, production build passes, browser smoke green. Outstanding: a live +provider run for recap/explicit-nesting and the tuned 20-scenario reportable +eval. + +**[2026-07-27] Tool-launcher integration landed on +`feat/mindmap-tool-integration`** (`04e3324` + review fixes in `df44321`; **not +pushed, not merged**). The mindmap is now a launched tool: it exchanges a +`wt_grant` fragment for a `wtk_` bearer at `/api/handoff/exchange` +(`platform-session.ts`), gates boot on that session (`PlatformBootstrap.tsx`), +hydrates a read-only draft snapshot from the handed-off `DocContext`, and sends +the bearer on both provider transports plus reader translation via +`ProviderRuntimeConfig`. `makeLLM` now takes an options object, not five +positional params. Server telemetry is gone: `mirrorSanitizedEvent` and +`/api/mindmap/events` were dropped, and the `EventLedger` is IndexedDB-only. +Persisted session schema is at **version 7** (`draftSource`, `lastSavedAt`); +the storage key is unchanged at `prototype-mindmap-session-v1`. 352 Vitest, +typecheck, eval typecheck, production build, Playwright green. + +**Outstanding before that branch is PR-ready: a live end-to-end launch has +never been run.** Every check so far is mocked — the Playwright spec stubs +`/handoff/exchange` and `/openai/chat/completions` with `page.route`. The live +run needs `BETTER_AUTH_ENABLED=true` (`get_env.py` writes `false`) and a +non-anonymous `@calvin.edu` sign-in; an anonymous session with no +`OPENAI_DEMO_API_KEY` makes `attributeRequest` return null, so the proxy 401s +and the mindmap misreports it as an expired token. + +Live pipeline: + +``` +user input → SourceBank capture → typed AssistantResponse + → contract allowlist → validator (reflections) / action-gateway (map) + → proposal shown → explicit user click → gateway applies + audit event +``` + +Key enforcement files: `validator.ts`, `action-gateway.ts`, +`proposal-store.ts`, `assistance-contract.ts`, `map-store.ts`, `store.ts`. +Orchestrator: `stage1-loop.ts`. Transport: `api.ts` + feature-flagged +`provider-tools.ts` (`chat_json` is the default; keep it so until +`responses_tools` passes a live propose-only smoke run). + +## Planned / in-flight work — collision map + +If you build on this branch, check this list first. File lists are the areas +each item will touch. + +1. **[IN FLIGHT NOW] Multilingual grounding pass** — branch + `feat/mindmap-multilingual-grounding`, merging back here when its + checkpoints land green. Spec: `docs/multilingual-grounding-pass.md`. + Explicit typed-lookup localization (`src/ui-strings.ts` + `src/i18n/*.json`, + ~35 locales; **no DOM MutationObserver layer**), a `ui-locale` module, + centralized mutation gating (`mutation-policy`), and edits to `App.tsx`, + `Map.tsx`, `action-gateway.ts`, `main.tsx`. Grounding profiles: English + + Chinese first; language-neutral normalizer folds full-/half-width + punctuation + CJK quotes; Simplified↔Traditional deliberately fails + grounding; `Intl.Segmenter` zh needs a full-ICU canary test. + **`origin/feat/mindmap_translation` is a donor only — never merge it** (it + forked pre-Stage-1 cutover and would resurrect deleted subsystems). + *Avoid deep edits to `App.tsx`/`Map.tsx`/`action-gateway.ts` until this + merges — you will conflict.* + + **Checkpoint 1 review — `f0c99d3`, now `7448891` after the rebase onto + `mindmap-main` (ACCEPTED 2026-07-23).** Independently verified by Claude: `tsc` clean, full 281-test + Vitest suite passes (exit 0), `zh.json` covers every `source.json` string + (other locales partial by design, degrade to English), `t()` is applied + only to UI-string literals in `App.tsx`/`Map.tsx`, and `App.test.ts` + covers the key trap — authored text `"Clear map"` stays verbatim while + surrounding chrome localizes to 确认, all buttons disabled in + `translated_view`. Session schema and provider contracts unchanged + (`action-gateway.ts` gained only the `read_only_view` reason code). + + *Carry-forward — fold into the checkpoint that makes `translated_view` + reachable (translation overlay); do not rework now:* + - **Gateway-level read-only enforcement + rejection feedback.** + `translated_view` is latent (`main.tsx` hard-codes `mode="authoring"`; + only tests exercise it), and enforcement is ~30 distributed + `if (!mutationAccess.allows(...)) return;` call-site checks — a future + mutation path that forgets the check silently bypasses policy, and most + App-side rejections are silent early-returns (only Map's `dispatchCanvas` + returns a localized detail). Before the view ships: (a) also enforce at + the gateway/store layer — the `read_only_view` reason code exists but + nothing emits it — or add a test enumerating every mutation entry point; + (b) give rejected mutations user-visible feedback (reuse the "Switch back + to the writing view to edit." pattern). + - **Cleanup (anytime):** `source.json` duplicates `"Enter to send"` + (lines 35/95), propagated into `zh.json` as a duplicate key — dedupe + both. `mutationAccess.run("canvas_edit", captureMapUndo)` (`App.tsx` + ~3241) files undo-capture under `canvas_edit`; give it its own intent or + reuse `map_undo`. Cosmetic. + + **Checkpoint 2 review — original-language coach context (ACCEPTED + 2026-07-23; commit before starting checkpoint 3).** The feature branch was + rebased onto `mindmap-main` (`ff2b165`) first, with the three colliding + untracked pass-doc copies removed. Independently verified by Claude: `tsc` + clean, 291 Vitest tests pass, build green. Landed per the agreed + checkpoint-2 plan and its four review amendments: + - `src/language-context.ts` — advisory script-pattern classifier + (`single | mixed | unknown`); Han/Hiragana/Katakana/Hangul/Bopomofo are + one CJK family, so monolingual Japanese and Korean-with-Hanja classify + `single`; an unfamiliar letter script returns `unknown` rather than + guessing (tested with Ge'ez). Decided edge: Latin + unfamiliar script + also returns `unknown`, not `mixed`. + - `LLMContext.language` (`LanguageContext`) threaded through + `buildContext`/`processTurn`; `latestUserLanguagePattern` persists in + `ConversationState`/`PersistedSession` (legacy sessions default + `unknown`), updates only on non-empty user turns, and survives clone, + coach-only continuation, and reload. `uiLocale` comes from + `useUiLocale()` per call and is never persisted. + - Rendered context labels `uiLocale` "presentation-only … not a + response-language preference"; the system prompt adds original-language + preservation guidance and "the interface display locale is never an + instruction to translate or change reply language". Both test-asserted. + - The validator-independence test pins the advisory invariant: identical + valid and invalid claims produce byte-identical validation outcomes + under all three forced pattern values. **Checkpoint 3 must keep this + test passing untouched — grounding may not consult the pattern field.** + - `preferredCoachLanguage` stays an unused extension point; a direct + language request remains ordinary authored conversation (test-pinned). + + **Checkpoint 3 review — English/Chinese exact-phrase grounding (ACCEPTED + 2026-07-23; commit before starting checkpoint 4).** Independently verified + by Claude: `tsc` clean, 311 Vitest tests pass, build green, plus a full + audit sweep (regex inventory, consumer-by-consumer regression trace, + rigidity assessment). What landed: + - `normalize.ts` rebuilt: NFKC + smart-quote/corner-bracket folding + (`foldWidthAndQuotes`), `Intl.Segmenter` word tokenization (zh segmenter + when any CJK present, full-ICU canary test), grapheme-accurate + **code-owned offsets** (`findWholePhraseRange` maps folded matches back + to UTF-16 ranges in the unchanged original — the model never computes + offsets), `stem()` identity for non-Latin, closed zh particle list in + STOPWORDS. Shared `CJK_SCRIPT_RE` extracted to `unicode-scripts.ts`. + - `validator.ts`: span grounding is now binary exact-phrase presence + (`containsWholePhrase` after folding), and lexical grounding draws from + the **cited evidence phrases** (`citedPhraseStemSet`), not whole cited + utterances. Citation is verbatim (mod folding); mirror prose keeps + stem-level inflection freedom for English. Simplified↔Traditional + substitution deliberately fails (test-pinned). The checkpoint-2 + advisory-invariant test is untouched — the validator imports nothing + from `language-context`. + - One regression was caught in review and fixed: `segment()` briefly split + ASCII `.!?` without following whitespace, corrupting English SourceBank + units (`3.5`, `example.com`); now only CJK `。!?` split without + whitespace, with regression tests pinning decimals/domains/parenthesized + punctuation. Known cosmetic nit (decided, not a bug): a CJK terminator + inside quotes splits before the closing `」`; fix if ever needed is + `(?<=[。!?])(?![」』"')])`. + - `spanGroundingMin` semantics clarified in `config.ts` (span scores are + binary; the threshold governs relational same-utterance coverage only). + - **Acknowledged intended rigidity:** mirrors are stricter in both + languages — content words must come from cited phrases, citations must + be exact. All new rigidity gates the AI, never the user. Watch-item + recorded in the pass doc: compare the **English** first-pass + grounded-mirror rate against the pre-checkpoint-3 baseline in Stage 4; + if it drops materially, tune the prompt to cite more precise evidence — + never loosen the validator. + - Documented later-cleanups (not now): the redundant whole-utterance + stem-ratio path in `claimRelationStatedInOneUtterance` (strictly weaker + than the phrase-based lexical check, harmless), and the Japanese + per-grapheme NFKC composition edge (ja is not a grounding target). + - Silent improvements worth knowing: Chinese turn-shape token counts and + suggestion-adoption overlap were previously near-meaningless (a whole + zh sentence tokenized as one blob) and are now real. + + **Next: checkpoint 4 — explicit translation behavior** (pass doc §8; + product decisions RATIFIED 2026-07-23, recorded there). Two slices, each + landing independently green: + + **4a — coach translation response (build first).** The + `TranslationResponse` kind (`ai_translated`, visibly labeled, never a + grounded reflection, never auto-entering the SourceBank or the map). + Decided: the request is **model-classified + schema-validated** — a direct + natural-language ask in chat ("translate that for me" / "把这个翻译成英文") + yields the labeled translation card; there is **no chat-side Translate + button**. Misclassification is low-harm by construction (conversational + only, labeled, enters nothing); add a Stage 4 precision scenario (a turn + that *mentions* translation without requesting one must not yield a + translation response). Adoption of translated wording needs no new UI: + the user typing/saying it themselves makes it authored; a dedicated + "adopt this wording" affordance is deferred polish. + + **Checkpoint 3 + 4a status: COMMITTED and ACCEPTED** — `028e430` (grounding + profiles) and `6f4d820` (explicit AI translation) as two separate commits; + feature branch HEAD is `6f4d820`. 4a independently verified by Claude + (`tsc`, 316 tests, build) and audited: `translation` kind typed + allowlisted + at L0/L1/L2, `translation_evidence_not_exact` reuses checkpoint-3 + `containsWholePhrase` so a translation cannot cite a phrase the user did not + write, `translation_advisory_not_allowed` blocks candidate/affect bookkeeping, + `invalid_translation_text` pins displayed text == `translatedText`, original + stays in the SourceBank untouched, visible `AI-translated` badge + dictionary + key. Two non-blocking notes: the badge reuses the purple `ai-suggestion-badge` + class (give translation a distinct style in the 4b UI pass), and the + mention-vs-request precision guardrail is prompt-only pending its Stage 4 eval + scenario (owed there, not in 4a). + + **4b — reader view.** Everything translated **including user words** → + read-only, activating `translated_view`. The visible language picker + lives here (a mode change is a control; a conversational request is not). + Port the donor display-only overlay (`translate.ts`, + `translation-memory.ts`, `translation-context.ts`). This slice owns the + two checkpoint-1 carry-forwards: gateway-level read-only enforcement + + user-visible rejection feedback, and the `source.json`/`zh.json` + "Enter to send" dedupe. + + *Control Room in the translated view (ratified):* it is a mixed surface — + translate app-authored narration/labels/section-titles (chrome), but + leave machine codes, evidence snippets, and payloads **byte-for-byte**. + Evidence snippets are verbatim user words shown as audit proof; + translating one falsifies the audit and breaks the §8 no-translated-user- + wording rule. Reading surfaces translate for comprehension; the Control + Room is the fidelity/proof layer and stays raw. Verbatim-with-optional- + gloss (tooltip beside, never replacing) is deferred, not v1. Full spec in + pass doc §8 4b. + + **4b browser-QA results (2026-07-23) — DOES NOT COMMIT until 1–4 land.** + Implementation is otherwise complete and green (`tsc`, 323 Vitest, build), + and static review confirmed the good parts: gateway now emits + `read_only_view` for the canvas path, `onRejected` feedback is wired, the + display cache touches only its own two localStorage keys (never bank / + session / history / `recordEvent` / `toLLMContext`), protected spans + + byte-for-byte restore present, dedupe done. Browser QA then found: + + *BLOCKERS:* + 1. **Blank map on reader-language switch.** Console: + `[React Flow]: The parent container needs a width and a height to + render the graph` (error#004, ×4). The map container measured + zero-width/height and React Flow bailed; **data was never lost** (the + "6 cards" counter stayed correct and a refresh restored everything) — + this is a render failure, not a mutation. Likely trigger: the reader + banner is inserted into the layout and reflows the panel containers + while React Flow measures. The map container needs a height that + cannot collapse when siblings are inserted. + 2. **The failure is sticky.** Returning to Original view did NOT restore + the map — `flowNodes` is memoized and `setNodes` syncs via an effect + whose deps don't change on reader-state change, so an emptied node + array never recovers until remount (refresh). Needs a re-measure path + **plus an error boundary — there is none anywhere in the app**, a + pre-existing gap this exposed. + 3. **`reader-view.tsx` queueing correctness.** `useEffect(() => { void + flush(); })` has **no dependency array** (runs every render), and + `translate()` mutates `queued.current` **during the render phase**. + NOTE: this was initially suspected as the blank-map cause and is *not* + — `flush` early-returns when the queue is empty or a run is in flight. + Still fix both: render-phase ref mutation can drop or duplicate queued + translations (notably under StrictMode double-render). + 4. **Regression test:** map node count identical before / during / after + a reader-language switch. Current tests do not cover it. + + *POLISH (same pass):* the read-only banner reuses `className="error-banner"` + so a `role="status"` message wears the error skin — give it its own calm + informational class; the top toolbar does not wrap/overflow, so the new + Reader-language selector makes controls overlap when the panel is slid with + Control Room open; give the `AI-translated` badge a distinct style (it + currently reuses the purple `ai-suggestion-badge`, indistinguishable from + AI suggestions). + + *VERIFIED WORKING in QA:* checkpoint-2 language matching is excellent — + the coach held a full conversation in **Twi** (no grounding profile, no + dictionary), including mixed Twi/English turns, and answered Spanish in + Spanish. Reader translation itself worked (chat projected to Bengali). + Translation-half QA should be re-run after the fixes; the earlier + `Backend 401` was an unrelated stale backend process holding port 8000. + + **Considered and REJECTED (do not re-propose without new evidence):** a + third "coach-language" mode (chrome + coach speech translated, user words + untouched, editable). The mainstream case is already covered — the coach + follows the user's turn language natively (checkpoint 2), so the normal + screen is monolingual with zero translation; the niche + write-in-X-coached-in-Y case is reachable today via the conversational + override ("honor a direct user request for another response language"), + which does not persist across reloads — if users complain about + re-asking, that is the demand signal to wire the reserved + `preferredCoachLanguage` picker (build then, not now). Back-translating + *historical* coach messages is rejected outright: it needs the runtime + engine plus span-level care for embedded verbatim user evidence (mirrors + must never display translated user wording), and buys retroactive + transcript consistency that human conversation doesn't have either. + + **4b remediation review (2026-07-24) — STILL DOES NOT COMMIT until the + must-fix items below land.** The implementer addressed the four blockers; + changes remain uncommitted in the working tree. Independently verified by + Claude: `tsc` clean, **327 Vitest pass** (exit 0, no fuzz-timeout + artifact), build green (known chunk warning). **Live browser QA re-run by + Claude today** (the implementer could not — local process spawning was + denied in that session): dev server on **port 5182** (5181 was a stale + pre-existing server — the exact cross-branch hazard from 2026-07-22; check + PIDs, don't trust the port). With 3 cards, toggling reader language + zh→original→bn with the Control Room open showed **node count constant at + 3, canvas 860×607, zero console errors, no error#004, recovery boundary + never triggered** — blockers 1 and 2 are genuinely fixed. QA scope caveat: + 1280×720, 3 cards, docked draft; not stress-tested at narrow widths or + with many cards. `.map-panel` now resolves full-height via `height:auto` + + `min-height:0` flex stretch. + + > **FINAL STATUS (2026-07-24): the preceding "STILL DOES NOT COMMIT" gate + > is historical and superseded. Remediation + residuals landed, + > independently verified (`tsc` / **334 Vitest** / production build / + > Playwright), and accepted.** `feat/mindmap-multilingual-grounding` now + > contains the successful-results-only reader cache, 512 KiB FIFO eviction + > and purge-on-clear, guarded request deduplication, stable proposal hooks, + > stable selection/resize recovery, origin-specific gateway reasons, and the + > durable populated-map reader-switch regression. The Playwright assertion + > passes; its Windows parent can time out only while tearing down the + > already-passed web server. + > + > **Second residual review, also landed:** the visible rejection alert is + > defense-in-depth and is not claimed as live-browser-verified because all + > current translated-view authoring controls are disabled or return early. + > The standing banner plus `aria-describedby` are the actual user feedback. + > Rejection state clears on every reader-view change, `main.tsx` passes the + > stable callback directly, all copy says "original view", FIFO eviction is + > linear-time, and legacy source-equals-translation entries are harmlessly + > retried rather than retained. + + *Verified GOOD (do not rework):* gateway emits `read_only_view` on the + canvas path (carry-forward a closed); `system_restore` origin is narrow + both directions (cannot execute a user edit; user origin cannot restore a + snapshot) and reachable only from session-restore + undo, never AI + proposals; Control Room left byte-for-byte raw (no `reader.translate` in + `UnderTheHoodPanel`); display cache touches only its two localStorage keys; + `protectReaderText` guards code/URL/email/marker spans with byte-for-byte + restore; `"Enter to send"` deduped; provenance-row grid fixed (was 6b); + distinct `.ai-translation-badge` added (was 4a's note); and the deliberate + no-reopen-in-read-only guard at `reader-view.tsx:63` (a remembered locale + must not reopen a session in a read-only display mode) — good catch. + + *Historical must-fix findings (all resolved; retained for rationale):* + 1. **Failed translations are cached permanently** — `reader-view.tsx:100` + `catch` writes `additions[key] = source` into the persisted cache; + `enqueueReaderRequests` then skips any defined key forever, so one + transient backend failure pins a string to its untranslated form until + localStorage is hand-cleared. The prior 4b QA `Backend 401` was exactly + this trigger. Fix: do **not** cache failures — leave the key absent so + `translate()` falls back to source at render and a later prefetch can + retry. Tension to accept: retry re-bills the shared key (see the + translation-cost note below); bounded by prefetch cadence, not a loop. + 2. **Rules-of-hooks violation, two places** — `useEffect` sits *after* an + early `return null` in `MirrorCard` (`App.tsx:4800`) and + `MapActionProposalCard` (`App.tsx:4919`). Latent today (the call site + branches on `detail.kind` and the two are different component types, so + React remounts rather than reusing), and there is **no ESLint** to catch + it — but item 7's `App.tsx` decomposition will move this code, a bad + seed to carry into a refactor. Move the effect above the early return. + 3. **The blocker-4 regression test does not cover the regression.** As + landed it calls `resyncFlowNodes` with plain object literals and asserts + returned ids == input ids — near-tautological given the implementation + (`next.map(n => ({...n, selected}))`); it cannot fail if the blank-map + bug returns (no render, no locale switch, no React Flow). `Map.test.ts` + is pure-function-only by design, so real before/during/after node-count + coverage belongs in `e2e/smoke.spec.ts` (already exists). Claude + verified this path manually today, but that does not persist. + + *AGREED design change — gateway reason-code split (2026-07-24).* In + `executeCanvasAction`, branches a (`origin === "system_restore"` + + non-restore action, line 103) and b (`user` origin + `restore_snapshot`, + line 107) both report `reason: "read_only_view"`, but they are **authority** + refusals (wrong origin for the operation / wrong operation for the origin), + not read-only-display refusals — both fire even in `authoring` mode (the + `origin: "user"` restore test proves it). Only branch c (translated_view, + line 108) is a genuine read-only refusal. In a provenance-audit product the + trail must distinguish "user typed while reading a translation" from + "something routed a snapshot-restore through the user path." Change lines + 103 and 107 to a new `reason: "origin_not_permitted"`; add it to + `GatewayReason`; update the two test expectations. **Do NOT touch the + control flow** — the `system_restore` block must stay above the + translated-view check, and branch a is precisely what stops that early + return from becoming an edit bypass. Label-only change. + + *AGREED — rejection feedback (checkpoint-1 carry-forward b, decided + 2026-07-24).* What landed is `.sr-only` at `App.tsx:4440` and + `rejectionMessage` is never cleared, so a repeat rejection does not change + the DOM and is not re-announced. Decision: **alert on a blocked attempt + where the architecture allows it, and make the standing banner unmissable + for everything else.** Honest constraint: in translated view most controls + are DOM-`disabled`/`readOnly`, so they fire **no event** for `onRejected` + to hook — a per-attempt alert can only reach the non-disabled paths + (map-canvas drag/connect). So do both: (1) make `.reader-status-banner` the + always-on, unmissable "why can't I edit" signal (it already carries the + same sentence); (2) for the live-but-gated drag/connect paths, make + `onRejected` feedback **visible** (not `.sr-only`) and **re-announcing** + (clear `rejectionMessage` on the next state change / a short transient so + repeats fire). Do not remove the `disabled`/`readOnly` attributes to force + universal alerts — that is a worse affordance and a larger change. + + *NEW durable item — reader display-cache growth + hygiene.* **[DONE except + cap — verified 2026-07-24.]** FIFO byte-budget eviction + (`READER_DISPLAY_CACHE_MAX_BYTES`, `reader-view.tsx` ~8/106–123) + purge-on- + clear (`reader.clearDisplayCache()` from all three clear fns) + successful- + results-only all landed. Only residual: Nhyira chose a **~1 MB cap** (current + 512 KiB) — **DONE 2026-07-24 (`7b6d0c5`), now 1 MiB.** Spec in + `docs/polish-sweeps-pass.md` Branch B1. The original problem description + follows for rationale: + `prototype-mindmap-reader-display-cache-v1` grew unbounded: + one entry per (locale, string), no eviction, re-serialized O(n) on every + batch, quota errors swallowed (in-memory keeps growing while persistence + silently stops), and the keys embed **verbatim user text**. Worse for + authorship/privacy: **none of `clearMapOnly`/`clearDraftOnly`/`clearChatOnly` + purge it** (`App.tsx:4326`+), so content the user explicitly cleared + survives as translated copies in localStorage. Two independent fixes, both + wanted: (a) **purge on clear** — clear-map/draft/chat also drop the matching + cache entries (correctness/privacy); (b) **bounded eviction** — a + FIFO/LRU cap by **byte budget** (~1–2 MB, under the ~5 MB quota), evicting + oldest on write until under budget ("clear from behind, keep enough-ish + recent"). FIFO is trivial on object insertion order; LRU needs access-time + tracking. Decision knob left to Nhyira: cap size + FIFO-vs-LRU. Composes + with must-fix 1 — once failures aren't cached, only successes are stored, + so less churn to evict. (Draft read-only in translated view already avoids + the per-keystroke blowup.) + + *VERIFIED — translation-call routing (was an open "verify" item).* + `translateReaderText` → `postChat` → `POST /api/openai/chat/completions` + (`api.ts:64`). (1) **Study logging: clean** — that endpoint is a pure + proxy that injects the key and streams back (`backend/src/app.ts:37`); + JSONL study logging is the *separate* `/api/mindmap/events` endpoint driven + by explicit `recordEvent`, which the reader path never calls. Translation + traffic does not pollute study logs. (2) **Shared key: it spends it, + uncapped** — same proxy, same `OPENAI_API_KEY`, one billed chat-completion + per unique (locale, source) string, concurrency 4, no per-user cap. A + many-card map opened in a fresh locale is a burst of billed calls. Not a + blocker; demo/budget awareness, and the reason must-fix-1's retry behavior + is worth bounding. + + *POLISH (same pass ok):* `main.tsx:10` passes a fresh inline arrow for + `onRejected` every render, defeating `createMutationAccess`'s memo — pass + the already-stable `reader.reportReadOnlyRejection` directly. ResizeObserver + effect depends on `recoverMapDisplay`→`flowNodes`, so it disconnects/rebuilds + + fires a fresh rAF + full `setNodes` on every card edit (no loop, just + churn). `enqueueReaderRequests` only checks `cache`, not in-flight requests, + so a re-prefetch mid-flight re-translates the same string. The error + boundary **cannot catch error#004** (React Flow logs `console.error`, not a + throw, so `getDerivedStateFromError` never fires) and `componentDidCatch` + swallows silently — worth keeping (the app had none) but at minimum log, + and **record accurately that `recoverMapDisplay` + the ResizeObserver is + what fixed blockers 1 & 2, not the boundary**. `t(reader.rejectionMessage)` + translates a dynamic string (duplicates the literal across two files, + degrades to English on drift — checkpoint 1 was praised for `t()`-on- + literals-only). Selection-preservation is inconsistent: `resyncFlowNodes` + keeps `selected` but the ordinary `setNodes(flowNodes)` sync path drops it, + so the new test asserts a property the main path lacks. + +**Reflection-recovery arc (items 2–4) — AUDIT 2026-07-24: ALREADY BUILT.** An +independent code read (Claude, 2026-07-24) found the entire agreed +reflection-quality arc is implemented and unit-tested on `mindmap-main`; the +collision-map entries below were stale ("SPEC AGREED"/"AGREED TODO") and are +corrected here. The **only** genuine remaining code work is the Part-B +narration bug (item 6c); everything else is verification + one doc fix. **Not +yet validated live** — the live QA is owed and deferred to the next chat (see +6c). Tier-2 sequencing decided (Nhyira 2026-07-24): do this arc before the +`App.tsx` decomposition (item 7) — the teardown's safety net is too thin to run +first, and finishing the coach work de-risks it. + +2. **[BUILT — verify only] Graceful reflection recovery + staged progress.** + Capped ladder is in `stage1-loop.ts` (`callModel(1|2|3)`, + `MAX_REFLECTION_ATTEMPTS`; attempt-1 reflection → `reflection_validation_failed` + → attempt-2 `grounding_repair` fed the ungrounded words + rejected reflection + → attempt-3 `forced_question` with a code guard rejecting a non-question; + `exhaustedRepair` only on a malformed call; only the grounding path escalates, + every other rejection keeps single-repair). Stage prompts: `api.ts:135` + (forced question) / `api.ts:137` (informed repair). Progress events + (`onProgress`; stages `initial_attempt`/`grounding_repair`/`forced_question`) + plumbed + rendered. Tests: stage1-loop.test.ts "uses the capped two-reflection + ladder and renders the forced question" (616), "ends recovery when the + forced-question call returns another response kind" (669), plus + repair/terminal cases (381/406/425/439/454). **Doc wording already + reconciled** — `refactor-plan.md:24,385` and `DESIGN.md:71` already state the + capped-ladder rule (reflection grounding may reach the ladder; all other + paths keep one repair). Remaining: fix 6c; live QA. + +3. **[BUILT — verify only] Mirror faithfulness: zero ungrounded content words.** + `validator.ts:81` — `additionsOk = !noContent && additions === 0` + (`threshold: 0`); `ungroundedContentWords` surfaced. Prompt guidance present + (`api.ts:163`: "reuse only content words from the exact original-language + userPhrase evidence"). Tests: validator.test.ts "blocks unsupported modal or + hedge content without a special word bank" (179, the "necessarily" case), + "passes a reflection made of the user's own words, lightly rearranged" (42), + "reports distinct unsupported content words in displayed order" (197). + Remaining: NONE here. The first-pass grounded-mirror-**rate metric** is still + owed to the Stage 4 harness (item 5) — Nhyira scoped it OUT of this pass + (2026-07-24). + +4. **[BUILT — done] Chat anchor for draft-grounded reflections.** Gate at + `stage1-loop.ts:271` (`reflection_draft_without_chat_anchor`; recap variant + at :239). Test: stage1-loop.test.ts "repairs an L1 draft-only reflection + before ordinary pointer validation" (312). No remaining work. + +5. **[ACTIVE] Stage 4 eval:** run the fixed 20-scenario L0-vs-L2 suite live, + retain all transcripts, hand-score the designated 40 outcomes + (grounded-mirror rate, recovery stages, terminal failures, directiveness, + attribution, question premises, quotation confusion, latency, tokens). + Manipulation checks must also run in Chinese once (1) lands. LLM-judge + automation comes only after hand-scoring. + +6. **[DONE 2026-07-24 — `07c89f4`, corrected in follow-up] "AI suggestion · + 0%" relabel.** SHIPPED FIX: for `pct === 0` the `InfluenceBadge` now drops the + bare "· 0%" and shows just "Echoes coach". The earlier "was AI-suggested" + copy was reverted on review — `influenceTrace` rides **user-authored** + proposals too (e.g. `origin: "user_asserted"`, see stage1-loop.test.ts:1148), + so it is an **echo/influence** signal, not an authorship claim; calling it + "AI-suggested" would misattribute the user's own words and collide with the + genuine `ai_suggested` "AI suggestion" label. A real AI-suggested-then-decayed + surface (fed by `peakOverlapRatio` on actual `ai_suggested` cards) stays the + deferred Checkpoint-6 item. Historical decision context below. + *(superseded)* Decided + (Nhyira 2026-07-24): copy is **"was AI-suggested"**; scope is the + `InfluenceBadge` (`App.tsx` ~4793) — when `exactOverlapPhrases` exist but + `overlapRatio` is 0, show "was AI-suggested" instead of "Echoes coach · 0%". + Note `InfluenceTrace` does NOT carry `peakOverlapRatio` (that lives only on + the suggestion-adoption ledger path); do not thread it in this pass. Full + spec: `docs/polish-sweeps-pass.md` Branch B2. + *Deferred richer surface (may revisit):* the badge is a one-time snapshot, not + real current-vs-peak adoption decay. Surfacing genuine decay (fed by the + suggestion-adoption trace, currently ledger-only/unrendered) is a + Checkpoint-6-sized add — not done in the polish pass. + +6b. **[DONE — verified in code 2026-07-24] Map provenance panel layout.** + Fixed: `App.tsx` inline styles already carry + `.event-row.provenance-row { grid-template-columns: minmax(0, 1fr) auto; }` + + `.event-row.provenance-row .event-title { min-width: 0; }` (~2144–2146). + Landed with the 4b remediation ("provenance-row grid fixed" at the Verified- + GOOD list). No work remaining. + +6c. **[DONE 2026-07-24 — `07c89f4`; browser-QA PASSED] Turn-progress + narration: stop the eager first-phase claim.** Landed: `initial_attempt` + retired, call 1 emits no progress event, neutral ~700 ms "Working…" indicator + off `loading`, escalation stages unchanged. Browser QA passed (EN/ZH/RTL, + Playwright 3/3) against a delayed-response mock — the mock covers the + narration/timing (pure frontend); **criterion (c)** (real coach reaches a + grounded mirror OR targeted question, never a dead-end, against a **live + provider**) is **DONE 2026-07-24: verified live** — with the two-process + backend up (`:8000` / Vite `:5181`), three varied L1 turns each reached a + targeted question or a grounded mirror (first-call reflection validated in + the Control Room), zero dead-ends, no console errors. The **6a i18n smoke** + also passed live (165 strings → throwaway `sw` locale via the proxy, then + deleted). + + **L0 recap follow-up fix — `afce738` (2026-07-24, prompt-only).** Live use + surfaced that the L0 `grounded_recap` could fire on a single brief/hedged + turn and echo it back verbatim (a parrot). Not a bug — `grounded_recap` is + validated to exact user wording and at L0 is restricted to the current turn + — but poor move-selection + degenerate output. Fix (in `api.ts` recap + guidance): a recap must **synthesize in the coach's own connective prose** + (content words from cited evidence, never a verbatim echo), and at L0 fires + **only when the current turn holds several distinct points**; a single + brief/tentative thought gets a focusing question. **L0 stays single-moment + on purpose — cross-turn/draft reach is the deliberate L0→L1 distinction and + must NOT be added to L0.** OWED: live QA of this fix + a Stage 4 precision + scenario (lone hedged turn → question, not parrot; rich turn → synthesized + recap). + + **Design decisions ratified this session (do not re-litigate):** (a) + proposing a *relationship* between the user's own ideas is a *contribution* + → stays **L2** (`allowsAiSuggestedStructure`), not L1. (b) L1 ("grounded + options") is NOT changing — its felt difference from L0 is *reach without + contribution* (cross-turn + draft juxtaposition + verbatim options, recorded + as AI-connected provenance); whether users feel it is a **Stage 4 + measurable** (% L1 turns referencing ≥2 user moments / draft vs L0), not a + redesign. (c) A broader "work-with-your-words" reframe of L1 was considered + and **declined** — current in-app L1 stands. + + Original spec preserved below. + **Buildable spec: `docs/6c-turn-progress-narration-pass.md` (self-contained + change list + verification + gate).** Summary below. `App.tsx:4073` + and `App.tsx:4127` both call `setTurnProgress("initial_attempt")` before any + model call, and `TURN_PROGRESS_COPY.initial_attempt` (`App.tsx:96`) reads + "Trying to reflect your words…" — so every turn claims a reflection before + the model has chosen its move (inaccurate on question/answer/aside/suggestion + turns; repetitive). `stage1-loop.ts:445` also emits + `onProgress({stage:"initial_attempt"})` on call 1. + + **Decided behavior (Nhyira 2026-07-24):** + - *First-call phase* — **no eager narration, no reflection claim.** Show a + **neutral, delayed (~700 ms) indicator** driven off `loading` via a timer, + with neutral copy (recommend **"Working…"** as a new localized + `source.json`/`zh.json` string; a wordless dot/spinner is also acceptable). + A turn that finishes before ~700 ms shows nothing. + - *Escalation stages* render **immediately** with their existing honest copy, + unchanged — `grounding_repair` "Making sure this stays in your words…" and + `forced_question` "Asking a focused question instead…". These already fire + only on genuine escalation, so they stay event-driven and accurate. + - *Retire the `initial_attempt` claim.* Preferred: **drop** the + `initial_attempt` emission at `stage1-loop.ts:445` and drive the neutral + indicator purely from `loading` + timer (the loop should narrate only real + escalations); remove the stage from the `TurnProgressStage` union in + `assistant-response.ts` and retire the "Trying to reflect your words…" + string. Acceptable alternative: keep the stage but map it in the UI to the + neutral-delayed indicator (never render the old claim). + - Files: `App.tsx` (two eager sets ~4073/4127; `TURN_PROGRESS_COPY:96`; + render ~4497; add the ~700 ms timer), `stage1-loop.ts:445`, + `assistant-response.ts` (`TurnProgressStage`), i18n dicts (add "Working…"; + retire the old string), tests. + + **Live QA — OWED, DEFERRED to the next chat** (Nhyira is switching chats; + write it there per the numbered-steps + "what to look for / what would be a + bug" browser-QA preference). Must confirm: (a) a fast grounding turn shows + only the neutral delayed indicator, never a false "reflecting" claim; (b) a + genuinely hard/abstract turn surfaces "Making sure…" then "Asking a focused + question…" in order; (c) the coach reaches a grounded mirror OR a targeted + question, **never a dead-end** — the original item-2 motivation, still + unverified live. This live pass is the real acceptance gate for the whole + reflection-recovery arc, not just 6c. + +7. **`App.tsx` decomposition** (**4,682 lines** as of `df44321`, after the + ControlRoom extraction and the launcher integration): `session-persistence.ts`, + then the `useMindmapSession` hook, with `ControlRoom.tsx` already out. + Slice 2 is now pre-PR; slice 3 stays post-merge. + + **[SLICE 1 DONE — `ControlRoom.tsx` extracted, MERGED 2026-07-24.]** + Extracted on `feat/mindmap-controlroom-extraction` (`b37b1df`) and **merged + into `mindmap-main` as `e0115e1`** — the branch is done; do not redo it. + Moved `UnderTheHoodPanel` + its panel-only helpers out of `App.tsx` + (−439 lines); the diagnostics selector (`buildDiagnosticSnapshot`) stays in + App and feeds the panel via props; `MicIcon`/`UnderhoodIcon` stayed in App + (toolbar-only, not panel deps). Behaviour-neutral: tsc clean, 335 Vitest, + build green. + + **[SLICE 2 — NEXT, do before opening the launcher PR.]** + `session-persistence.ts`. The launcher integration sharpened the case: as of + `df44321`, `PlatformBootstrap.tsx` imports `savedMindmapSummary`, + `clearSavedMindmap`, and `DraftSourceMetadata` **from `./App`**, so the boot + layer now depends on the 4,682-line component it renders purely for storage + helpers; `App.test.ts` reaches in for `SESSION_STORAGE_KEY` too. Move + `loadPersistedSession`, `savedMindmapSummary`, `clearSavedMindmap`, + `SESSION_STORAGE_KEY`, the `PersistedSession` type, and the version-1–7 + migration into one module and re-point both importers. Behaviour-neutral; + no schema change (stay at version 7, key unchanged). + + **[SLICE 3 — after slice 2.]** The `useMindmapSession` hook. This is the + large one; it stays post-merge. 6b (Recap) then builds cleanly into the + extracted ControlRoom. + + **Sequencing (recommended 2026-07-23): do this BEFORE Checkpoint 6, not + after.** Checkpoint 6 adds two large surfaces to this same file — the + Recap panel (6b) and the compare-3-levels UI (6c) — so building them into + a ~5,000-line component makes both the implementation and its review + harder, and every checkpoint so far has grown the file further. The 4b QA + also strengthened the structural case: the blank-map failure came from + layout fragility in this file, and there is **no *root* error boundary** — + `Map.tsx:776` has one scoped to the canvas, but nothing above it, so a throw + in `App` still blanks the page (see item 9). The `ControlRoom.tsx` + extraction in particular would isolate the provenance-grid bug (6b above) + and the Control Room translation logic. + +8. **[AGREED 2026-07-23 — POST-4b] Checkpoint 6: session digest + donor + reclamation.** Reclaim three donor features + (`origin/feat/mindmap_translation`, donor-only, never merge) plus one new + deliverable. Ranked slices, each landing independently green. + **Prerequisite (recommended): land item 7's `App.tsx` decomposition first** + — 6b and 6c are both large additions to that file. See item 7. + + **6a — `generate-i18n` script. [DONE 2026-07-24 — `aaaadcb`.]** Ported + `scripts/generate-i18n.mjs` + the `i18n` npm script, adapted so + `supportedLanguages()` globs `src/i18n/*.json` (no `LANGUAGE_CODES` on + `mindmap-main`) and the donor "Out" prompt line is dropped. Dry-run verified; + **live-backend smoke DONE 2026-07-24** (165 strings → throwaway `sw` locale + via the `:8000` proxy, then deleted). Original note: + Port `scripts/generate-i18n.mjs` + the `i18n` npm script. It + translates `src/i18n/source.json` into every locale via the backend proxy + (`npm run i18n [-- --force | ]`). `mindmap-main` has the 34 + dictionaries but not the generator, so every hand-added chrome key + (checkpoints 1/4a already added some) drifts the files out of sync with no + tool to regenerate. Low-glamour, high-leverage; port early. + + **6b — session digest engine → Recap panel + exportable report.** ONE pure + deterministic function `buildSessionDigest(ledger, mapSnapshot, + sourceBank) → Digest`, rendered two ways. Substrate already exists: the + `EventLedger` on `mindmap-main` records timestamped typed events + (`contract_selected` = level switches, `assistant_response`, + `proposal_created/resolved`, `map_mutated`, `candidate_lifecycle_changed`, + `suggestion_adoption_changed`, `application_recovery`, `user_message`) with + contract snapshot, origin, outcome, response kind, repair count, adoption + percentages. Rebuild the donor's deterministic `RecapData` concept on the + *current* provenance fields (do not copy donor internals). + - **Determinism contract (pin as a test):** pure function of ledger + map + + bank, **zero AI calls**; same session in → byte-identical output. + - **Zero AI paraphrase (RATIFIED):** no generated prose anywhere, TLDR + included. TLDR is **template + deterministic slots** ("14 turns. 6 of 9 + cards are your words. Worked mostly at L1; switched to L2 at turn 7."). + User content appears only as **verbatim extractive snippets**, never + paraphrased — a report about authorship must not launder user words + through an AI. + - **Render target 1 — Recap panel (user-facing, priority).** Live, + glanceable Control Room view; both audiences in one panel (writer's + trajectory + teacher's authorship/AI-usage split). + - **Render target 2 — exportable report (RATIFIED).** Client-side + generated from in-memory ledger + state (no backend dependency). + **Markdown primary deliverable + optional JSON sidecar**; no PDF/docx. + Sections: TLDR (counts + authorship split + level headline) → usage + timeline (the `contract_selected` sequence as a turn/level/change table) + → thinking trajectory (verbatim user turns) → what was built (cards with + provenance) → optional raw appendix. **Audience: both** (one document, + both sections — not two exports). + - Guardrail: the digest is a faithful mirror of what the ledger already + knows. It must never summarize, score, or analyze — the moment it does, + it becomes the thing the app resists. Note: an exported copy embeds the + user's verbatim words (deliberate deliverable; fine in their own report). + + **6c — compare-3-levels (last; heaviest `App.tsx` surface).** Reclaim the + donor's `compareAssistanceLevels` + `comparisonSystemPrompt` + three-hued + `.compare3` UI: answer the same user turn once under each of L0/L1/L2, show + all three, user selects one to continue. **Surface behind a build-time + feature flag** (`config.ts`, e.g. `features.compareLevels`) — surfaced in + the UI for demo/dev now, flip the flag to unsurface for prod later (no code + removal; same latent-behind-flag pattern as `translated_view`). Rebuild the + plumbing on the current runtime: each level's response crosses the current + contract allowlist + validator, and "select one" routes the chosen response + through the current loop (the donor version predates the typed runtime — + concept/prompt/UI reused, plumbing rebuilt). Surface per-level + `rejectionReasons` for honesty. + + **6d — port donor e2e specs into checkpoint-5 browser QA.** The donor's + `e2e/i18n.spec.ts` + `language.spec.ts` (translated-view read-only, only + writer content reaches the engine, dictionary completeness, chrome re-skin + without touching the engine) were dropped when `mindmap-main` kept only + `smoke.spec.ts`. Adapt them against the current `translated_view` / + `mutation-policy` (not the donor's `language.ts`). Fold into checkpoint 5. + + **Explicitly skip (already decided):** `dom-translation.ts` (no DOM + MutationObserver layer — pass uses typed lookup), donor `language.ts` + write/view model (superseded by `ui-locale` + 4b reader view), + `open-threads.ts` (deliberately deleted from `mindmap-main`; do not revive). + +9. **Root error boundary.** `Map.tsx:776` guards the canvas only; nothing sits + above `App`, so any throw during render blanks the page with no recovery + affordance — the failure shape behind the 4b blank-map incident. Now that + `PlatformBootstrap.tsx` is the render root (it already owns the gate, + connecting, and blocked screens and their `platform-gate` styling), it is + the natural host: wrap the `ready` branch, reuse the existing gate markup + for the fallback, and offer reload. Small (~30 lines) and worth landing with + the launcher PR — it protects a demo far more than any further file split. + Do **not** let the fallback clear `localStorage`; the saved mindmap must + survive a render crash exactly as it survives token expiry. + +## Deferred (do not start unless asked) + +LiveKit/voice-native conversation; fast map mode (draft → proposed cards, +opt-in only); save/export/multi-user collaboration. + +**Backend reconciliation: mindmap-main → main's backend (PARKED, analyzed +2026-07-24).** The prototype talks to `mindmap-main`'s old thin, auth-less +backend; `main`'s backend is ~156 commits ahead (Better Auth device flow, +sqlite `db`, consent, erasure, usage metering, pricing, `openaiProxy.ts`). +Merge-base analysis: **mindmap-main's *only* unique backend work is 74 lines in +`app.ts` (the `/api/mindmap/events` narrow study-logging endpoint) + 56 test +lines** — everything else the prototype does is frontend. `main` already exposes +**both** routes the mindmap needs (`/api/openai/chat/completions` **and** +`/api/openai/responses`, both through `openaiProxy` with `resolveUser` + +metering). So reconciliation is *not* a big backend port; it is: +- **Port one endpoint (~0.5–1 day, low risk):** re-home `/api/mindmap/events` + onto `main`'s auth model — gate with `resolveUser`, key JSONL by Better Auth + user id, rebase its 56 tests. Its narrow schema (no draft/prompt/user text) + already matches `main`'s consent-stripping posture, which is why it is cheap. +- **The real work (~2–4 days):** `main`'s proxy **requires an authenticated + identity** (`resolveUser` null → 401; sessionless traffic gets the capped demo + key or is refused), and the mindmap frontend currently sends **zero auth** on + `postChat`/`postResponses`/events. Must wire an identity onto those three + `fetch`es, handle CORS (prototype runs on its own Vite origin), handle + 401/token-expiry (sessions outlive a 1-hr token), and **verify `main`'s + metered proxy passes the mindmap body through unchanged** (`response_format: + json_object`, `reasoning_effort`, `stream:false`) — a must-confirm. +- **Decision that swings it (OPEN, ask Nhyira):** which identity? + (a) **Better Auth session** (mindmap as an authenticated first-party page) — + needs nothing from PR #533; session + CORS only; cheaper, self-contained. + (b) **`wtk_` handoff token** (mindmap as a *launched tool* per PR #533) — + needs PR #533 merged first (different branch's territory; scope with Kenneth). +- **Realistic total: ~3–5 focused days** via the Better-Auth-session route, + running the two backends convergently rather than merging the whole 156-commit + branch (full merge = ~1–2 wks, risky, NOT step one). See the mindmap-as-a-tool + frontend trace in chat 2026-07-24 for the per-call mapping. Relates to the + parked tool-launcher/PR #533 work. + +## Product philosophy (binding — ask before deviating) + +The user authors ALL map structure; the AI questions and reflects only. +Validation gates the AI, never the user. The map changes only from explicit +user commands/confirmations. Recall is bounded influence: user-verbatim text, +confirmed placement, logged (`InfluenceTrace`); code never decides the user +"returned" to a topic. No provider tool may confirm/apply/persist/stage — +everything routes through the gateway. If a bug exposes a philosophical +choice, recommend and ask Nhyira; do not decide unilaterally. + +Working preferences: demo-ready behavior first (UIST-style demo/poster); +verify behavior yourself; plain-language honesty in chat, machine detail in +the Control Room; no stale/repetitive coach wording. + +## Environment notes (Windows) + +- Use `npx.cmd` / `npm.cmd`. +- Verify from `prototype-mindmap/`: `npx.cmd tsc --noEmit`, + `npm.cmd test -- --run`, `npm.cmd run build`, `npm.cmd run eval`. +- Vitest may report all assertions passing yet exit `1` with + `[vitest-worker]: Timeout calling "onTaskUpdate"` after the long fuzz run — + a runner/IPC artifact, not a failure. Say so explicitly when it happens. +- Commit/push hooks may fail with `/usr/bin/env: 'sh': No such file or + directory`; use `git -c core.hooksPath=NUL commit ...` **only after** + tests/build have run. +- The `eval/runs/` transcripts are data — keep them untracked. diff --git a/backend/src/__tests__/handoff.test.ts b/backend/src/__tests__/handoff.test.ts index 3d7b4841..b9c4a0ff 100644 --- a/backend/src/__tests__/handoff.test.ts +++ b/backend/src/__tests__/handoff.test.ts @@ -4,7 +4,8 @@ import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createApp } from '../app.js'; import type { Auth } from '../auth.js'; -import { closeDb } from '../db.js'; +import { closeDb, db } from '../db.js'; +import { PLATFORM_AUTH_ERROR_HEADER } from '../openaiProxy.js'; const env = { ...process.env }; @@ -19,6 +20,7 @@ beforeEach(async () => { afterEach(() => { vi.unstubAllEnvs(); + vi.unstubAllGlobals(); closeDb(); process.env = { ...env }; }); @@ -175,6 +177,34 @@ describe('tool token end-to-end', () => { const res = await post(createApp(), '/api/log', { event: 'after_revoke' }, token); expect(res.status).toBe(401); }); + + it.each(['revoked', 'expired'] as const)( + 'fails a %s tool token closed at the proxy even with demo access', + async (state) => { + vi.stubEnv('OPENAI_DEMO_API_KEY', 'demo-key'); + const token = await tokenFor(['openai:chat']); + if (state === 'revoked') { + await post(createApp(), '/api/handoff/revoke', {}, token); + } else { + db() + .prepare( + 'UPDATE tool_grant SET token_expires_at = ? WHERE access_token = ?', + ) + .run(Date.now() - 1, token); + } + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const res = await post( + createApp(), + '/api/openai/chat/completions', + { model: 'gpt-4o' }, + token, + ); + expect(res.status).toBe(401); + expect(res.headers.get(PLATFORM_AUTH_ERROR_HEADER)).toBe('platform-auth'); + expect(fetchMock).not.toHaveBeenCalled(); + }, + ); }); describe('X-Client-Id header attribution (device-flow tools)', () => { diff --git a/backend/src/__tests__/openaiProxy.test.ts b/backend/src/__tests__/openaiProxy.test.ts index e2d79664..6f93f344 100644 --- a/backend/src/__tests__/openaiProxy.test.ts +++ b/backend/src/__tests__/openaiProxy.test.ts @@ -9,6 +9,7 @@ import { attributeRequest, createSseUsageScanner, parseUsage, + PLATFORM_AUTH_ERROR_HEADER, } from '../openaiProxy.js'; import { ANONYMOUS_USER_ID, @@ -252,6 +253,33 @@ describe('POST /api/openai/chat/completions', () => { ); expect(res.status).toBe(401); + expect(res.headers.get(PLATFORM_AUTH_ERROR_HEADER)).toBe('platform-auth'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('rejects a presented invalid tool token even when demo access is configured', async () => { + vi.stubEnv('OPENAI_DEMO_API_KEY', 'demo-key'); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const res = await appWithUser(null).request( + '/api/openai/chat/completions', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer wtk_invalid-or-revoked', + }, + body: JSON.stringify({ model: 'gpt-4o' }), + }, + ); + + expect(res.status).toBe(401); + expect(res.headers.get(PLATFORM_AUTH_ERROR_HEADER)).toBe('platform-auth'); + expect(await res.json()).toEqual({ + error: { code: 'platform_auth' }, + detail: 'Tool access token is invalid or expired.', + }); expect(fetchMock).not.toHaveBeenCalled(); }); @@ -281,9 +309,48 @@ describe('POST /api/openai/chat/completions', () => { ); expect(res.status).toBe(403); + expect(res.headers.get(PLATFORM_AUTH_ERROR_HEADER)).toBe('platform-auth'); expect(fetchMock).not.toHaveBeenCalled(); }); + it('does not mark an upstream OpenAI authentication error as platform auth', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response(JSON.stringify({ error: { message: 'provider key' } }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }), + ), + ); + const res = await appWithUser(real('usr-upstream')).request( + '/api/openai/chat/completions', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'gpt-4o' }), + }, + ); + expect(res.status).toBe(401); + expect(res.headers.get(PLATFORM_AUTH_ERROR_HEADER)).toBeNull(); + }); + + it('exposes the platform-auth marker header through CORS', async () => { + const res = await appWithUser(null).request( + '/api/openai/chat/completions', + { + method: 'OPTIONS', + headers: { + Origin: 'https://mindmap.example', + 'Access-Control-Request-Method': 'POST', + }, + }, + ); + expect(res.headers.get('Access-Control-Expose-Headers')).toContain( + PLATFORM_AUTH_ERROR_HEADER, + ); + }); + it('serves a sessionless request on the demo key and meters it to `demo`', async () => { // Demo mode sends a token that isn't a session, so it lands here — the path // that would otherwise 401 once the proxy started requiring auth. @@ -558,12 +625,20 @@ describe('POST /api/openai/responses', () => { ); vi.stubGlobal('fetch', fetchMock); + const requestBody = { + model: 'gpt-4o', + input: 'hi', + tools: [{ type: 'web_search_preview' }], + temperature: 0.4, + max_output_tokens: 321, + metadata: { surface: 'mindmap' }, + }; const res = await appWithUser(real('usr-r')).request( '/api/openai/responses', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ model: 'gpt-4o', input: 'hi' }), + body: JSON.stringify(requestBody), }, ); @@ -571,6 +646,11 @@ describe('POST /api/openai/responses', () => { expect(fetchMock.mock.calls[0]?.[0]).toBe( 'https://api.openai.com/v1/responses', ); + expect( + JSON.parse( + String((fetchMock.mock.calls[0]?.[1] as RequestInit | undefined)?.body), + ), + ).toEqual(requestBody); expect(await res.json()).toEqual(body); const rows = await waitForUsage(); expect(rows[0]).toMatchObject({ diff --git a/backend/src/app.ts b/backend/src/app.ts index b83cdf70..ba89c4d6 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -11,7 +11,12 @@ import { import { deviceClientIds, gitCommit, logSecret } from './config.js'; import { eraseLoggedData } from './erasure.js'; import { appendLog, pollLogs, zipLogs } from './logging.js'; -import { attributeRequest, openaiProxy } from './openaiProxy.js'; +import { + attributeRequest, + openaiProxy, + PLATFORM_AUTH_ERROR_HEADER, + type ProxyIdentity, +} from './openaiProxy.js'; import { captureException, posthogMiddleware } from './posthog.js'; import { costUsd } from './pricing.js'; import { @@ -74,7 +79,7 @@ export function createApp({ auth }: { auth?: Auth } = {}): Hono { const app = new Hono(); // CORS stays fully permissive for now to preserve existing behaviour. - app.use('*', cors()); + app.use('*', cors({ exposeHeaders: [PLATFORM_AUTH_ERROR_HEADER] })); app.use('*', posthogMiddleware); if (auth) { @@ -101,7 +106,7 @@ export function createApp({ auth }: { auth?: Auth } = {}): Hono { openaiProxy('chat/completions', { // The proxy only reads { id, isAnonymous }; resolveUser also returns // loggingConsent, which is structurally compatible and simply ignored here. - resolveUser, + resolveIdentity: resolveProxyIdentity, authEnabled: !!auth, }), ); @@ -110,7 +115,7 @@ export function createApp({ auth }: { auth?: Auth } = {}): Hono { app.post( '/api/openai/responses', openaiProxy('responses', { - resolveUser, + resolveIdentity: resolveProxyIdentity, authEnabled: !!auth, }), ); @@ -221,6 +226,20 @@ export function createApp({ auth }: { auth?: Auth } = {}): Hono { }; } + async function resolveProxyIdentity(c: Context): Promise { + const bearer = bearerToken(c); + if (bearer?.startsWith('wtk_')) { + const tool = resolveToolToken(bearer); + return tool + ? { kind: 'authenticated', user: tool.user } + : { kind: 'rejected_tool_credential' }; + } + const user = await resolveUser(c); + return user + ? { kind: 'authenticated', user } + : { kind: 'sessionless' }; + } + // Client event logging. Requires an authenticated session: the log is keyed by // the Better Auth user id (not a client-supplied name), and content fields are // stripped to the user's consent level before they ever hit disk. diff --git a/backend/src/openaiProxy.ts b/backend/src/openaiProxy.ts index b60bb285..d2d4ec85 100644 --- a/backend/src/openaiProxy.ts +++ b/backend/src/openaiProxy.ts @@ -39,9 +39,16 @@ export type ProxyUser = Pick< 'id' | 'isAnonymous' | 'isAllowed' | 'clientId' >; +export type ProxyIdentity = + | { kind: 'authenticated'; user: ProxyUser } + | { kind: 'sessionless' } + | { kind: 'rejected_tool_credential' }; + +export const PLATFORM_AUTH_ERROR_HEADER = 'X-Writing-Tools-Error'; +export const PLATFORM_AUTH_ERROR_VALUE = 'platform-auth'; + export interface ProxyOptions { - /** Authenticated user for this request, or null if there's no session. */ - resolveUser: (c: Context) => Promise; + resolveIdentity: (c: Context) => Promise; /** * Whether auth is configured at all. When it isn't (local dev with * BETTER_AUTH_ENABLED unset), unauthenticated requests are served rather than @@ -223,15 +230,23 @@ function requestedModel(parsed: Record | null): string { export function openaiProxy(endpoint: OpenAIEndpoint, options: ProxyOptions) { return async (c: Context): Promise => { - const user = await options.resolveUser(c); + const identity = await options.resolveIdentity(c); + if (identity.kind === 'rejected_tool_credential') { + return platformAuthError(c, 401, 'Tool access token is invalid or expired.'); + } + const user = identity.kind === 'authenticated' ? identity.user : null; // Enforce the beta allowlist server-side. The client also shows a "not allowed" // screen, but that's UX — this is the gate a disallowed user can't bypass by // calling the proxy directly. isAllowed already folds in anonymous/demo users // (always allowed) and the per-user alwaysAllow grant, so this one check covers // every authenticated case; sessionless traffic is left to attributeRequest. - if (user && !user.isAllowed) return c.json({ detail: 'Forbidden' }, 403); + if (user && !user.isAllowed) { + return platformAuthError(c, 403, 'This account is not permitted.'); + } const attribution = attributeRequest(user, options.authEnabled); - if (!attribution) return c.json({ detail: 'Unauthorized' }, 401); + if (!attribution) { + return platformAuthError(c, 401, 'Authentication is required.'); + } // Which client the tokens are provenance-tagged to (a tool's client_id, or // null for the first-party add-in). Independent of who pays: a demo user's // spend goes to the capped key but the row still names their tool. @@ -369,3 +384,15 @@ export function openaiProxy(endpoint: OpenAIEndpoint, options: ProxyOptions) { }); }; } + +function platformAuthError( + c: Context, + status: 401 | 403, + detail: string, +): Response { + return c.json( + { error: { code: 'platform_auth' }, detail }, + status, + { [PLATFORM_AUTH_ERROR_HEADER]: PLATFORM_AUTH_ERROR_VALUE }, + ); +} diff --git a/frontend/src/api/__tests__/googleDocsEditorAPI.test.ts b/frontend/src/api/__tests__/googleDocsEditorAPI.test.ts index b4f51a52..91c53c6a 100644 --- a/frontend/src/api/__tests__/googleDocsEditorAPI.test.ts +++ b/frontend/src/api/__tests__/googleDocsEditorAPI.test.ts @@ -66,7 +66,10 @@ beforeEach(() => { getDocContextMock = vi.fn().mockResolvedValue(CONTEXT_A); fakeWindow = Object.assign(makeEmitter(), { - GoogleAppsScript: { getDocContext: getDocContextMock }, + GoogleAppsScript: { + getDocContext: getDocContextMock, + getDocumentName: vi.fn().mockResolvedValue('Research notes'), + }, }); fakeDocument = Object.assign(makeEmitter(), { hasFocus: () => focused, @@ -88,6 +91,13 @@ afterEach(() => { }); describe('googleDocsEditorAPI selection polling', () => { + it('includes the Google document title in an explicit context fetch', async () => { + await expect(googleDocsEditorAPI.getDocContext()).resolves.toEqual({ + ...CONTEXT_A, + documentLabel: 'Research notes', + }); + }); + it('pulls immediately and notifies handlers when the selection changes', async () => { getDocContextMock .mockResolvedValueOnce(CONTEXT_A) // initial pull diff --git a/frontend/src/api/__tests__/wordEditorAPI.test.ts b/frontend/src/api/__tests__/wordEditorAPI.test.ts new file mode 100644 index 00000000..8fdc6522 --- /dev/null +++ b/frontend/src/api/__tests__/wordEditorAPI.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { wordDocumentLabel } from '../wordEditorAPI'; + +describe('wordDocumentLabel', () => { + it('uses the decoded filename from the Office document URL', () => { + expect( + wordDocumentLabel('https://example.sharepoint.com/docs/My%20Essay.docx'), + ).toBe('My Essay.docx'); + }); + + it('falls back for unsaved documents', () => { + expect(wordDocumentLabel('')).toBe('Word document'); + expect(wordDocumentLabel(undefined)).toBe('Word document'); + }); + + it.each([ + ['C:\\Users\\writer\\OneDrive\\Essay.docx', 'Essay.docx'], + ['C:/Users/writer/OneDrive/Essay%20Draft.docx', 'Essay Draft.docx'], + ['\\\\server\\private\\team\\Shared.docx', 'Shared.docx'], + ['https://example.test/private/folder/Final%20Draft.docx', 'Final Draft.docx'], + ['https://example.test/private/folder/Bad%ZZ.docx', 'Bad%ZZ.docx'], + ])('never exposes directory components from %s', (input, expected) => { + expect(wordDocumentLabel(input)).toBe(expected); + }); +}); diff --git a/frontend/src/api/googleDocsEditorAPI.ts b/frontend/src/api/googleDocsEditorAPI.ts index 6961e4b0..ec8d8452 100644 --- a/frontend/src/api/googleDocsEditorAPI.ts +++ b/frontend/src/api/googleDocsEditorAPI.ts @@ -43,6 +43,7 @@ declare global { setDocumentProperty?: (key: string, value: string) => Promise; getDocumentProperty?: (key: string) => Promise; getDocumentId: () => Promise; + getDocumentName: () => Promise; getAllTabs: () => Promise< { id: string; title: string; text: string }[] >; @@ -269,10 +270,14 @@ export const googleDocsEditorAPI: EditorAPI = { * Gets the current document context (before cursor, selection, after cursor). */ async getDocContext(): Promise { - const context = await window.GoogleAppsScript.getDocContext(); + const [context, documentName] = await Promise.all([ + window.GoogleAppsScript.getDocContext(), + window.GoogleAppsScript.getDocumentName().catch(() => ''), + ]); // Normalize line endings (Google Docs uses \n) return { + documentLabel: documentName.trim() || 'Google Docs document', beforeCursor: context.beforeCursor || '', selectedText: context.selectedText || '', afterCursor: context.afterCursor || '', diff --git a/frontend/src/api/handoff.ts b/frontend/src/api/handoff.ts index 398a6b01..55672113 100644 --- a/frontend/src/api/handoff.ts +++ b/frontend/src/api/handoff.ts @@ -84,3 +84,75 @@ export function openInBrowser(url: string): void { } window.open(url, '_blank', 'noopener,noreferrer'); } + +export type BrowserLaunchReservation = + | { kind: 'office' } + | { kind: 'window'; popup: Window }; + +function officeBrowserOpener(): ((url: string) => void) | undefined { + const officeUi = ( + globalThis as { + Office?: { + context?: { ui?: { openBrowserWindow?: (u: string) => void } }; + }; + } + ).Office?.context?.ui; + return officeUi?.openBrowserWindow?.bind(officeUi); +} + +/** + * Reserve a browser popup before any asynchronous work or document access. + * Office's system-browser API does not use popup blocking, so it remains deferred + * until the final URL is ready. + * + * ## Why not a plain `` + * + * A link is the better answer wherever it fits, and it is worth re-checking this + * if the launch flow ever changes shape. It does not fit here, for three reasons + * that compound: + * + * 1. **The URL does not exist yet at click time.** It carries a single-use grant + * id minted by the backend, so producing it means an async round trip — plus + * reading the document first. There is no href to put on the anchor until + * that finishes. + * 2. **Reserving synchronously is the popup check.** `window.open` only inherits + * the user's activation on the click itself; called after an `await` it is + * blocked. Doing it first is what lets a blocked popup abort the launch + * *before* the document is read and transmitted — the failure surfaces while + * nothing has left the machine yet. Deferring the open inverts that: the doc + * goes out, then the window is refused, and the writer sees nothing happen. + * 3. **Pre-minting to fill an href burns the TTL.** Grants expire ~2 minutes + * after issue (`backend/src/db.ts`), and a link minted on render starts that + * clock before the writer has decided to click. + * + * The cost is a blank `about:blank` tab during the round trip, which + * `cancelBrowserLaunch` closes if the launch fails. + */ +export function reserveBrowserLaunch(): BrowserLaunchReservation | null { + if (officeBrowserOpener()) return { kind: 'office' }; + const popup = window.open('about:blank', '_blank'); + if (!popup) return null; + try { + popup.opener = null; + } catch { + // Some browser wrappers expose a read-only opener. + } + return { kind: 'window', popup }; +} + +export function completeBrowserLaunch( + reservation: BrowserLaunchReservation, + url: string, +): void { + if (reservation.kind === 'office') { + officeBrowserOpener()?.(url); + return; + } + reservation.popup.location.replace(url); +} + +export function cancelBrowserLaunch(reservation: BrowserLaunchReservation): void { + if (reservation.kind === 'window' && !reservation.popup.closed) { + reservation.popup.close(); + } +} diff --git a/frontend/src/api/wordEditorAPI.ts b/frontend/src/api/wordEditorAPI.ts index ef27941c..240b91e7 100644 --- a/frontend/src/api/wordEditorAPI.ts +++ b/frontend/src/api/wordEditorAPI.ts @@ -1,3 +1,23 @@ +export function wordDocumentLabel(url: string | undefined): string { + if (!url) return 'Word document'; + let value = url; + if (/^https?:\/\//i.test(url)) { + try { + value = new URL(url).pathname; + } catch { + // Treat malformed HTTP(S) values as opaque paths below. + } + } + const segments = value.split(/[\\/]/).filter(Boolean); + const filename = segments[segments.length - 1]; + if (!filename) return 'Word document'; + try { + return decodeURIComponent(filename); + } catch { + return filename; + } +} + /** * Whether the host supports `getReviewedText` (WordApi 1.4). We use it to read * the document as if tracked changes were accepted, so the AI never sees deleted @@ -33,6 +53,7 @@ export const wordEditorAPI: EditorAPI = { Word.run(async (context: Word.RequestContext) => { const body: Word.Body = context.document.body; const docContext: DocContext = { + documentLabel: wordDocumentLabel(Office.context.document.url), beforeCursor: '', selectedText: '', afterCursor: '', diff --git a/frontend/src/editor/index.tsx b/frontend/src/editor/index.tsx index 20cea5be..982ed57f 100644 --- a/frontend/src/editor/index.tsx +++ b/frontend/src/editor/index.tsx @@ -60,7 +60,12 @@ export function EditorScreen({ const editorAPI: EditorAPI = useMemo( () => ({ getDocContext: async (): Promise => { - return Promise.resolve(docContextRef.current); + return Promise.resolve({ + ...docContextRef.current, + documentLabel: + docContextRef.current.documentLabel ?? + 'Standalone editor draft', + }); }, addSelectionChangeHandler: (handler: () => void) => { selectionChangeHandlers.current.push(handler); diff --git a/frontend/src/pages/__tests__/registry.test.ts b/frontend/src/pages/__tests__/registry.test.ts index 03494527..4212ba8f 100644 --- a/frontend/src/pages/__tests__/registry.test.ts +++ b/frontend/src/pages/__tests__/registry.test.ts @@ -59,6 +59,21 @@ describe('page registry', () => { expect(entry.hint.length).toBeGreaterThan(0); } }); + + it('offers Tools from the Labs menu with no flag to set first', () => { + // Asserted through the selectors a writer's navbar actually goes + // through, not against the registry literal: Tools ships reachable + // — open the Labs (···) menu and it is there — while staying out of + // the three-tab strip. Adding a predicate that defaults off, or + // promoting Tools to `core`, are both rollout decisions rather than + // refactors, so they should fail here and be made deliberately. + expect(pagesByTier('lab').map((entry) => entry.name)).toContain( + PageName.Tools, + ); + expect(pagesByTier('core').map((entry) => entry.name)).not.toContain( + PageName.Tools, + ); + }); }); describe('visibility', () => { diff --git a/frontend/src/pages/registry.tsx b/frontend/src/pages/registry.tsx index 653bcb7e..5c781c01 100644 --- a/frontend/src/pages/registry.tsx +++ b/frontend/src/pages/registry.tsx @@ -88,7 +88,6 @@ export const PAGES: PageDef[] = [ title: 'Tools', hint: 'Launch writing tools', tier: 'lab', - enabled: () => isFlagEnabled('tool-launcher'), render: () => , }, { diff --git a/frontend/src/pages/tools/__tests__/tools-config.test.ts b/frontend/src/pages/tools/__tests__/tools-config.test.ts new file mode 100644 index 00000000..26cb4059 --- /dev/null +++ b/frontend/src/pages/tools/__tests__/tools-config.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + isMindmapToolEnabled, + launchFirstPartyTool, + MINDMAP_TOOL, + resolveMindmapToolUrl, + type FirstPartyTool, +} from '../index'; + +const mindmap: FirstPartyTool = { + id: 'mindmap', + name: 'Mindmap', + description: 'Test mindmap', + url: 'https://mindmap.example/', + scopes: ['openai:chat', 'doc:read'], +}; + +describe('mindmap tool registration', () => { + it('uses local and production URL defaults with an environment override', () => { + expect(resolveMindmapToolUrl(undefined, true)).toBe( + 'http://localhost:5181/', + ); + expect(resolveMindmapToolUrl(undefined, false)).toBe( + 'https://mindmap.thoughtful-ai.com/', + ); + expect(resolveMindmapToolUrl('https://preview.example/', true)).toBe( + 'https://preview.example/', + ); + }); + + it('defaults on in development and off in production, with explicit overrides', () => { + expect(isMindmapToolEnabled(undefined, true)).toBe(true); + expect(isMindmapToolEnabled(undefined, false)).toBe(false); + expect(isMindmapToolEnabled('true', false)).toBe(true); + expect(isMindmapToolEnabled('false', true)).toBe(false); + }); + + it('requests AI and document-read access without the retired logging scope', () => { + expect(MINDMAP_TOOL.scopes).toEqual(['openai:chat', 'doc:read']); + expect(MINDMAP_TOOL.scopes).not.toContain('log:write'); + }); + + it('does not access the document or backend when the popup is blocked', async () => { + const getAccessToken = vi.fn(); + const getDocContext = vi.fn(); + const createGrant = vi.fn(); + await expect( + launchFirstPartyTool(mindmap, { + getAccessToken, + getDocContext, + createGrant, + reserveLaunch: () => null, + completeLaunch: vi.fn(), + cancelLaunch: vi.fn(), + }), + ).rejects.toThrow('blocked'); + expect(getAccessToken).not.toHaveBeenCalled(); + expect(getDocContext).not.toHaveBeenCalled(); + expect(createGrant).not.toHaveBeenCalled(); + }); + + it('reserves before reading a document and completes a granted launch', async () => { + const calls: string[] = []; + const result = await launchFirstPartyTool(mindmap, { + getAccessToken: () => { + calls.push('token'); + return Promise.resolve('token'); + }, + getDocContext: () => { + calls.push('doc'); + return Promise.resolve({ + beforeCursor: 'draft', + selectedText: '', + afterCursor: '', + }); + }, + createGrant: () => { + calls.push('grant'); + return Promise.resolve({ grantId: 'grant', expiresIn: 120 }); + }, + reserveLaunch: () => { + calls.push('reserve'); + return { kind: 'office' }; + }, + completeLaunch: (_reservation, url) => calls.push(`open:${url}`), + cancelLaunch: vi.fn(), + }); + expect(calls.slice(0, 4)).toEqual(['reserve', 'token', 'doc', 'grant']); + expect(calls[4]).toContain('#wt_grant=grant'); + expect(result).toEqual({ sharedDoc: true }); + }); +}); diff --git a/frontend/src/pages/tools/index.tsx b/frontend/src/pages/tools/index.tsx index 9b3d7b9d..e8447514 100644 --- a/frontend/src/pages/tools/index.tsx +++ b/frontend/src/pages/tools/index.tsx @@ -12,8 +12,12 @@ import { useContext, useState } from 'react'; import { Button } from 'reshaped'; import { + cancelBrowserLaunch, + completeBrowserLaunch, createHandoff, openInBrowser, + reserveBrowserLaunch, + type BrowserLaunchReservation, type ToolScope, withGrantFragment, } from '@/api/handoff'; @@ -23,7 +27,7 @@ import { useAppAuth } from '@/contexts/appAuthContext'; import { useLog } from '@/hooks/useLog'; import classes from './styles.module.css'; -interface FirstPartyTool { +export interface FirstPartyTool { /** Tool client_id — must also be listed in the backend device allowlist. */ id: string; name: string; @@ -38,26 +42,87 @@ interface FirstPartyTool { * backend's BETTER_AUTH_DEVICE_CLIENT_IDS allowlist, and the URL points at wherever * the tool is hosted. A manifest-driven registry replaces this list in a later phase. */ -const FIRST_PARTY_TOOLS: FirstPartyTool[] = [ - { - // Throwaway tool for exercising the handoff flow end-to-end in dev. - // Served from sandbox/test-tool/ (see that dir's README). Remove before merge. - id: 'test-tool', - name: 'Platform API test tool', - description: - 'Dev-only. Exchanges the handoff grant and lets you fire each Platform API v0 call (LLM proxy, log, doc re-fetch, revoke).', - url: 'http://localhost:4000/', - scopes: ['openai:chat', 'log:write', 'doc:read'], - }, - { - id: 'mindmap', - name: 'Mindmap', - description: - 'Explore your draft as a client-side mindmap. Opens in your browser with a read-only snapshot of your current document.', - url: 'https://mindmap.thoughtful-ai.com/', - scopes: ['openai:chat', 'log:write', 'doc:read'], - }, -]; +export function resolveMindmapToolUrl( + explicit: string | undefined, + isDevelopment: boolean, +): string { + return ( + explicit || + (isDevelopment + ? 'http://localhost:5181/' + : 'https://mindmap.thoughtful-ai.com/') + ); +} + +export const MINDMAP_TOOL_URL = resolveMindmapToolUrl( + import.meta.env.VITE_MINDMAP_TOOL_URL, + import.meta.env.DEV, +); + +export function isMindmapToolEnabled( + explicit: string | undefined, + isDevelopment: boolean, +): boolean { + if (explicit === 'true') return true; + if (explicit === 'false') return false; + return isDevelopment; +} + +const MINDMAP_TOOL_ENABLED = isMindmapToolEnabled( + import.meta.env.VITE_ENABLE_MINDMAP_TOOL, + import.meta.env.DEV, +); + +export const MINDMAP_TOOL: FirstPartyTool = { + id: 'mindmap', + name: 'Mindmap', + description: + 'Explore your draft as a client-side mindmap. Opens in your browser with a read-only snapshot of your current document.', + url: MINDMAP_TOOL_URL, + scopes: ['openai:chat', 'doc:read'], +}; + +export const FIRST_PARTY_TOOLS: FirstPartyTool[] = MINDMAP_TOOL_ENABLED + ? [MINDMAP_TOOL] + : []; + +interface LaunchToolDependencies { + getAccessToken(): Promise; + getDocContext(): Promise; + createGrant: typeof createHandoff; + reserveLaunch(): BrowserLaunchReservation | null; + completeLaunch(reservation: BrowserLaunchReservation, url: string): void; + cancelLaunch(reservation: BrowserLaunchReservation): void; +} + +export async function launchFirstPartyTool( + tool: FirstPartyTool, + dependencies: LaunchToolDependencies, +): Promise<{ sharedDoc: boolean }> { + const reservation = dependencies.reserveLaunch(); + if (!reservation) { + throw new Error('Your browser blocked the new window. Allow popups and try again.'); + } + try { + const token = await dependencies.getAccessToken(); + const doc = tool.scopes.includes('doc:read') + ? await dependencies.getDocContext() + : undefined; + const { grantId } = await dependencies.createGrant(token, { + toolClientId: tool.id, + scopes: tool.scopes, + doc, + }); + dependencies.completeLaunch( + reservation, + withGrantFragment(tool.url, grantId), + ); + return { sharedDoc: doc !== undefined }; + } catch (error) { + dependencies.cancelLaunch(reservation); + throw error; + } +} const SCOPE_LABELS: Record = { 'openai:chat': 'AI access', @@ -88,21 +153,17 @@ export default function Tools() { setError(null); setBusyToolId(tool.id); try { - const token = await getAccessToken(); - // Only read the document when the tool asked for it — the snapshot leaves - // our trust boundary, so we don't gather it speculatively. - const doc = tool.scopes.includes('doc:read') - ? await editorAPI.getDocContext() - : undefined; - const { grantId } = await createHandoff(token, { - toolClientId: tool.id, - scopes: tool.scopes, - doc, + const result = await launchFirstPartyTool(tool, { + getAccessToken, + getDocContext: () => editorAPI.getDocContext(), + createGrant: createHandoff, + reserveLaunch: reserveBrowserLaunch, + completeLaunch: completeBrowserLaunch, + cancelLaunch: cancelBrowserLaunch, }); - openInBrowser(withGrantFragment(tool.url, grantId)); void toolsLog.toolLaunched(log, { tool: tool.id, - sharedDoc: doc !== undefined, + sharedDoc: result.sharedDoc, scopes: tool.scopes, }); } catch (e) { diff --git a/frontend/src/types.d.ts b/frontend/src/types.d.ts index 012fd296..40b2ddeb 100644 --- a/frontend/src/types.d.ts +++ b/frontend/src/types.d.ts @@ -127,6 +127,8 @@ interface ContextSection { interface DocContext { contextData?: ContextSection[]; + /** Human-readable source label carried with launcher document snapshots. */ + documentLabel?: string; beforeCursor: string; selectedText: string; afterCursor: string; diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index 11f02fe2..2012e8cc 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -1 +1,6 @@ /// + +interface ImportMetaEnv { + readonly VITE_MINDMAP_TOOL_URL?: string; + readonly VITE_ENABLE_MINDMAP_TOOL?: string; +} diff --git a/frontend/tests/demo-page-visual.spec.ts-snapshots/demo-page-chromium-linux.png b/frontend/tests/demo-page-visual.spec.ts-snapshots/demo-page-chromium-linux.png index cebc9242..aca98b54 100644 Binary files a/frontend/tests/demo-page-visual.spec.ts-snapshots/demo-page-chromium-linux.png and b/frontend/tests/demo-page-visual.spec.ts-snapshots/demo-page-chromium-linux.png differ diff --git a/prototype-mindmap/.claude/launch.json b/prototype-mindmap/.claude/launch.json new file mode 100644 index 00000000..48b804db --- /dev/null +++ b/prototype-mindmap/.claude/launch.json @@ -0,0 +1,12 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "mindmap-dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 5181, + "autoPort": true + } + ] +} diff --git a/prototype-mindmap/.gitignore b/prototype-mindmap/.gitignore new file mode 100644 index 00000000..fc09212b --- /dev/null +++ b/prototype-mindmap/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +*.log +tsconfig.tsbuildinfo +eval/runs/ diff --git a/prototype-mindmap/FABLE-NOTES.md b/prototype-mindmap/FABLE-NOTES.md new file mode 100644 index 00000000..29ee1dbc --- /dev/null +++ b/prototype-mindmap/FABLE-NOTES.md @@ -0,0 +1,286 @@ +# Fable hardening pass — running notes (2026-07-04) + +Baseline at HEAD `20c20b6`: tsc clean, 358/358 vitest green, tree clean (3 untracked docs untouched). +Current: tsc clean, 472/472 green (100 fuzz runs + 14 new regression tests added). + +## Summary (one line per finding) + +- [x] F1 harness BUILT: `src/fuzz.loop.test.ts` — 100 seeded runs × 60+ turns, 6 invariants/turn; found F2, F3, F6 +- [x] F2 FIXED (severe): validator let the AI stitch two grounded user sentences with an invented connective ("A. leads to B.") into a shipped connection mirror — claim-level one-breath binding added (validator.ts) +- [x] F3 FIXED: model-authored clarify phrases (turn.clarifySpan / failing weakestSpan / validationDebug span) were quoted back as the USER's words in chat + Under-the-Hood — attribution sanitizer added (controller.ts, understanding.ts) +- [x] F4 FIXED: clarifyTarget wedge — never cleared on question-mode turns, permanently suppressing mirror-pressure/salience bridges + stale UTH "waiting for" (controller.ts default branch) +- [x] F5 FIXED: captureLoop repeat-memory survived its elicitation (override block + finish() else), could trip the capture_loop clarify on the FIRST repeat of a later unrelated elicitation +- [x] F6 FIXED: settle-backstop drift (brief item 5) — settle re-ask referencing a stale "not sure" with low concept-overlap now steered via referencesStaleUncertainty +- [x] F7 FIXED (visual verify pending): nested-card edges — invisible `anchorProxy` React Flow nodes + (parented to the root ancestor, DOM-measured to the embedded card's spot) keep edges attached to the + REAL child card id; `proxyAnchorSpecs` is pure + tested (Map.test.ts, 6 tests) +- [x] F8 SWEEP DONE: verdicts — clarifyTarget WEDGE (fixed, F4); captureLoop STALE (fixed, F5); + organizeFocus AIRTIGHT (declineCount survives exactly when the coach re-asks the same pair — correct); + coverageFocus AIRTIGHT (cleared on substantive/commands/pivot); activeElicitation/pendingCardWording + AIRTIGHT (fuzz escape probes green); pendingChildPlacement had a stale-parent hole (fixed, F11); + activeSelectionContext = stale-influence judgment call (recorded below); App.tsx persistence is + symmetric (clone/save/restore cover the same fields) +- [x] F9 FIXED (enforcement): (a) LLM-emitted nest_card between two EXISTING cards had NO current-turn + gate — model could quietly author hierarchy; now both cards must be named this turn + instruction-shaped + nest wording. (b) connect_cards between existing cards executed on any connect-word — "these two ideas + connect deeply" (declarative) could author an edge; now requires instruction-shaped wording or a #ref + pair. (c) exact-phrase checks were raw substrings — "art" inside "start" counted as current-turn + wording; now word-boundary. All with regression tests. +- [x] F11 FIXED: pending confirmations executed against cards deleted mid-flow — map layer silently + no-ops (ensureCard returns undefined) while chat says "Done." Now any pending whose referenced ids are + gone is dropped with an honest notice (`pending_command_stale_reference`); same for child placement. +- [x] F10 dead code: removed unreachable second `find` in map-commands.ts ensureCard (predicate strictly + subsumed by the first); removed vestigial `awaitingCorrection` on relationship_confirmation (never set + true — and a legacy-persisted `true` would have turned a "yes" into the literal label via the + catch-all); fixed stale controller header comment ("LLM never sees ThoughtUnits" — it does, read-only). +- [x] docs/airtightness-report.md updated to match the tightened gates (verification line, relationship + binding row, new attribution + stale-pending rows, nest/connect/create row updates) + +## Fix details (file anchors as of this pass) + +### F2 — cross-utterance relationship stitching (validator.ts) +Attack: claims [span A (verbatim utt1), span B (verbatim utt2)], claim.text = "utt1 leads to utt2", +target connection. Each span passes; each sentence happens to contain some RELATION_TERM, so the +old per-span binding passed. Shipped as a real mirror (fuzz seeds 111/122/127). +Fix: `claimRelationStatedInOneUtterance` — some single cited utterance must carry EVERY relational +term the claim text uses AND ground >= spanGroundingMin of the claim's content tokens. +Second iteration: term binding added because a pure ratio leaked short-tail stitches ("...leads to nope"). +Tests: validator.test.ts "stitched together with an invented connective", "short-tail stitch", plus +a legit multi-utterance-citation pass case. + +### F3 — user-attribution leak (controller.ts + understanding.ts) +Three surfaces: (1) `when you said ""` in the mirror-fail clarify text — the +weakest span FAILED grounding, so its phrase can be model-invented; (2) `state.clarifyTarget` from +unvalidated `turn.clarifySpan` -> UTH waitingFor quotes it as "your own wording on ..."; (3) +understanding.ts `validationEvidence` surfaced the failing span's phrase as user evidence. +Fix: `isUserAttributablePhrase`/`sanitizeClarifySpan` (normalized-substring-of-bank check) at both +controller pin sites + grounded-in-cited-utterances check in validationEvidence. +Tests: loop.test.ts "hardening: model-authored clarify phrases..." (4 cases), understanding.test.ts +"never surfaces a failing span's phrase...". + +### F4 — clarifyTarget lifecycle (controller.ts) +Set on validation failure + clarify turns; was only cleared on stuck/mirror/DE_ESCALATE/goal-5/focus-help. +A single failed mirror left the pin for the rest of the session unless one of those fired: bridges +(mirror_pressure/draft_salience) gated by repairClarifyActive were suppressed forever, UTH kept a stale +"waiting for", prompts kept the stale anchor. Fix: question-mode default branch clears it (the model has +moved off the clarify thread); consecutive clarify turns still keep it. +Tests: loop.test.ts "hardening: clarify pin lifecycle" (clear on question, keep across clarifies). + +### F5 — captureLoop stale repeat-memory (controller.ts) +Cleared on mirror + next ingest turn, but NOT in finish()'s elicitation-ending else branch nor the +overrideMode reset — a stale lastAnswerNorm could increment across a later unrelated elicitation and +fire the capture_loop clarify one repeat early. Now the capture flow (elicitation + pendingCardWording + +captureLoop) dies together. Tests: "hardening: capture-loop memory dies with its elicitation". + +### F6 — settle drift (controller.ts finish, Goal-5 block) +`referencesStaleUncertainty` (you said/earlier/before ... not sure/unsure/stuck/no idea) OR'd with the +0.6 concept-overlap gate, same steer (map-aware transition vs DE_ESCALATE). Tests: "hardening: +stale-uncertainty settle drift" (steer + leave-fresh-settle-alone). + +## Harness (F1) +`src/fuzz.loop.test.ts`. 3 suites: default config (seeds 1-40), readiness/pacing opened (101-140), +map-lean slider (201-220); 60 turns/run + escape probes. Adversarial mock LLM: invented mirrors, +cross-utterance stitches, model-prose gists ("modelprose"), invented clarify spans, fabricated/stale +map commands ("zyxxq" marker), fake evidence ids, carry-forward gaming. Drives a REAL ThoughtUnitStore +(applyAcceptedMapCommands, shared bank), random sovereign deletes/edits mid-run. +Invariants: I1 never throws; I2 valid mode/non-empty text; I3 mirror revalidates + preamble + user +vocabulary; I4 commands mint only user-substring text, map only ever contains user vocabulary; I5 UTH +snapshot carries no command shapes and no marker prose; I6 wedge probe — any transient field set 10 +consecutive turns must clear under cancel/cancel/move-on/substantive escape. +Run: `npm test -- --run fuzz.loop` (from prototype-mindmap/). + +## UI pass — layout/routing cluster (2026-07-04, second session) + +User directed a UI handoff (7 items); items 3/6/7 landed elsewhere during a quota gap. Per the +answered scoping questions I took **layout/routing first (#1,#2,#4,#5)**, **own map-layout.ts**, and +**verified visually in the preview**. tsc + vite build clean, 499 vitest green (map-layout 7→14 tests). + +- **#1 Auto-clean respects existing layout — `map-layout.ts` rewrite.** Root cause: Dagre ranks the + hierarchy AND reorders siblings by its own crossing-minimization, scrambling the user's left/right + arrangement. Fix: keep Dagre only for vertical ranks, then `reorderRanksByUserX` clusters nodes by + Dagre y and re-lays each rank left-to-right in the user's existing x-order (stable by x, then y, then + id), spaced by width — no overlap, idempotent. Added `inferVerticalEdges`: neutral connections with a + clear user vertical gap get a soft top→bottom rank edge so a hand-built vertical tree isn't flattened. + **Preview-verified**: source #500 at y=80 centered over three children at y=288 in preserved order + audit(80) < user(354) < shared(628); re-run idempotent (unit test). +- **#2 Connector routing — `computeConnectionHandles` (new export) + autoClean wiring + `store.setConnectionHandles`.** + After layout, picks facing-side handles from card geometry (card above → bottom→top; left → right→left; + dominant axis wins), resolving nested endpoints via rootOf. **Preview-verified** handles persisted: + 500→501 bottom/top, 500→502 right/left, 500→503 left/right (diagonals route around the middle card, no + cutting through interiors). +- **#4 Direction popover — concrete + collision-safe (`ConnectionEdge` rewrite + CSS).** Abstract + source/target replaced with real card refs: `No arrow` / `#500 → #501` / `#501 → #500`, plus card-text + snippets and the label. Popover floats above the badge (`z-index:1200`, verified above cards) with a + bright amber CSS tether (`::after`) back to the on-edge badge. Edge-label layer raised so it's never + hidden behind a card. +- **#5 Badge staggering — `badgeOffsets` memo + `endpointCenter`.** Edges whose midpoints bucket together + get perpendicular offsets so nearby badges separate. **Preview-verified**: 3-edge fan already separated; + a forced parallel 500↔501 pair lands its two badges apart, not stacked. +- **#1 follow-up fix (user-reported): directed edges respect the user's orientation.** The user had #57 + level with and to the RIGHT of #12 (a lateral relationship), but the edge was `source_to_target`, so + `buildRootLayoutModel` unconditionally made it a Dagre rank edge → #57 was forced into the rank below + #12. Fix: an edge becomes a VERTICAL rank edge only when the user actually placed the target with a + meaningful vertical gap (`> 0.6 * max card height`); an edge laid out level stays lateral (same rank), + so #57 stays beside #12 in user x-order. Direction metadata now only orients (which card on top) and is + a fallback when positions are absent (new cards / the position-free unit tests). Lateral nodes with no + vertical edge of their own would float to rank 0, so `snapLateralNodesToNeighborRank` seats each onto + its ranked neighbor's rank after Dagre (tried Dagre `minlen:0` first — it throws, silently hit the + fallback grid and collapsed all ranks; snap is the robust path). Preview-verified on the user's 8-card + layout: #57 same rank as #12, to its right, edge routes right→left. Regression test added. +- **#2 follow-up fix (user-reported): tree routing, not raw dominant-axis.** After auto-clean, the + #12→#57 edge exited #12's *side* and stacked on the same side as #12's incoming edge, because #57 was + farther left than below and the old `|dy| >= |dx|` rule chose horizontal. Fixed `computeConnectionHandles` + to be rank-aware: any two cards with a real vertical gap (>= half the shorter card's height) route + bottom-to-top, so a card's incoming edge enters its top and its outgoing edge leaves its bottom; side + handles only for same-height siblings. Preview-verified on the user's 8-card layout (all edges now + bottom/top; #12 incoming top, outgoing bottom). Regression test added (diagonal-down child → vertical). +- **Known minor**: a handle-less parallel edge (only reachable by direct snapshot injection, not by + user-drawn connections which get staggered handles from `connectionHandlesFor`) can drop its badge near + the source card. Cosmetic, test-only artifact; left as-is. +- **Env note**: `vite.config.ts` now honors a `PORT` env override (preview tooling) but still defaults to + 5181 with `open` for normal `npm run dev` — restored after the preview session. `.claude/launch.json` + has `autoPort:true`. +- **Remaining from the handoff (not started this session):** #2's badge-vs-card-rectangle avoidance + beyond staggering; deeper popover free-space collision search (current high-z + float-above solves the + "hidden under card" complaint). #3/#6/#7 were done during the quota gap — not re-touched. + +## Review pass over the gap-landed work (2026-07-05, third session) + +Scope: review + fix-as-you-go of everything landed between `20c20b6` and `74b71b8` (open-threads, +edit_card, off-ramps, dismiss ideas, row-preserving auto-clean, generalized command precedence, +stale-pending guard, sanitizeClarifySpan, typed mirror override). Baseline 537/537 → final 540/540, +tsc + vite build clean. + +Fixed this pass (each with a regression test where behavioral): +- R1 open-threads activation hijack: thread matching is deliberately loose (one shared topic word = + 0.5 overlap on a 2-token thread), and it fired on EVERY compact turn — an ordinary answer sharing a + word with an old parked phrase could retarget `activeSelectionContext` + mark the thread active. + Now only return/pivot-shaped turns (`looksLikeThreadReturn`) or literal restatements activate, and + direct-command turns never do. (controller.ts `activateMatchingParkedThread`) +- R2 question-shaped edit hole: LLM-emitted `edit_card` was not dropped on question-shaped turns + (only create_card was), and `isUncertainExplicitPlacement` was missing the modals `could`/`shall` + that `isQuestionShapedCommandTurn` has — so "Could #10 say human oversight" (voice, no "?") could + execute a model-emitted edit. Both aligned; card WRITES (create+edit) now dropped on question turns. +- R3 raw internal id leak (transcript bug): "That seems close to tu_10 ... edited/reworded?" showed + the internal unit id and offered an action that then had no path. Now cites the #ref and names the + fulfilling command ("You can say: reword #N to "). +- R4 capability off-ramp misses (transcript bug): "can you reword a card" / "are you capable of + rewording a card?" fell through to the coach loop (only "are you able to ... edit cards" matched). + Detector broadened to capability-verb + card phrasings; answer text now names the reword command. +- R5 mojibake regexes: two `[’']` character classes (UTF-8 corruption of `[’']`) in + detectTypedModeOverride / detectOffRampResponse. +- R6 dead block: the connection_label-specific command-precedence check was unreachable after the + generalized precedence guard landed; removed. +- R7 fuzz harness taught about edit_card: MUTATION_KINDS (UTH read-only invariant), authorship check + on `edit_card.text`, an adversarial LLM edit attack (invented replacement wording must be blocked), + and a legit "reword #N to " user-input category. + +Verified airtight in the new work (no action): edit_card acceptance gates mirror create_card +(resolve + named-this-turn + exact current-turn replacement via word-boundary `textContainsExactPhrase` ++ cited-id check); commandConsumedUtteranceIds covers edit_card; sanitizeClarifySpan applied at both +clarifyTarget set-points; stale-pending guard drops confirmations whose cards were deleted; UTH/trace/ +App ack/api prompt all cover edit_card; dismiss wiring (App ✕ → dismissedCandidateIds, persisted, +cloned, cleared with state) emits zero map writes. + +## Weird-input lane (2026-07-05, fourth session) — the two-channel "aware, not fenced" design + +User + reviewer green-lit the LLM meta-lane, but the ORIGINAL binary (meta OR coach) would have +fenced the AI from noticing exhaustion on a productive turn. Fixed by splitting into TWO channels so +awareness is never gated out — only STRUCTURE is fenced. Decisions: soften + drop map-ward pushes +(no proactive pause); LLM + deterministic floor. 549/549 green, tsc + vite build clean. + +- **Channel 1 — `metaIntent` (pure aside, fenced).** New `LLMTurn.metaIntent` + (emotional|confused|social|off_topic|unparseable). Honored only when the turn is clearly not real + work: `!command && !userAnsweredLastQuestion && !inCardCapture && !looksLikeSubstantiveAnswer` (the + last is the reviewer's mixed-turn guard — a "frustrated but here's my real point" turn is coached, + never discarded). When honored: return BEFORE candidate application and command routing (structure + can't leak), mark the utterance `nonHarvestable` (new flag, NOT `commandOnly` — reviewer's point), + preserve clarifyTarget/activeElicitation/lastCoachQuestion so a brief aside doesn't wipe the thread. + `suppressionReason: "meta_aside"`. +- **Channel 2 — `affect` (tone/pacing modifier, NEVER fences).** New `LLMTurn.affect` + (exhausted|frustrated|overwhelmed|energized) settable on ANY turn including productive ones, plus a + deterministic floor (`DRAINED_PHRASES` + `LLMContext.userSeemsDrained`) that can only ASSERT drained, + never force harshness. When drained, `mirrorPressureHigh` and the draft-`salienceBridge` are switched + OFF — i.e. genuine "this person is tired" recognition removes the two brittle map-ward pushes instead + of code forcing them. This is the crux of honoring the user's "don't fence awareness" value. +- **Capabilities manifest** (`config.capabilities.canDo/cantDo`) — product truth injected into the + prompt so meta/capability answers stay honest instead of model vibes; `LLMContext.capabilities` + optional (api falls back to config). Deterministic capability/authoring off-ramps kept as the reliable + fast path; the soft meta-repair off-ramp now also defers to coaching on substantive turns. +- **Post-review hardening**: deterministic capability answers now render from the manifest instead of + hardcoded copy; unsupported feature asks such as card styling/color/export route to the hard + capability off-ramp before the LLM, so no smuggled candidates/commands can be harvested; and a pure + `meta_aside` no longer clears `pendingCardWording`/`captureLoop`, so a quick "ugh" during card-capture + does not wipe the exact wording the user already supplied. +- **Second bug sweep**: `nonHarvestable` now propagates through the async live-bank merge and is excluded + everywhere source material is rendered or re-derived (`api` Source Bank prompt, focus-help bank anchors, + App readiness snapshot, and UTH idea labels). This closes the read-side leak where a fenced aside could + still appear as ordinary source context even though controller harvest/mirror gates excluded it. +- **Enforcement**: `mirrorEligibleBank` excludes `nonHarvestable`; `store.markNonHarvestable` added; + api parser whitelists the two enums; question-shaped turns already drop card writes. Fuzz harness: + adversarial mock now sets metaIntent+affect while smuggling create_card + candidateUpserts → I7 + invariant asserts a `meta_aside` turn carries zero map commands; mirror re-validation now excludes + nonHarvestable too. Tests: pure-aside fenced, mixed-turn NOT honored, drained-floor drops the + mirror-pressure push. +- **Deferred / minor**: (a) a meta aside during a pending confirmation gets the "still pending" + reprompt (pending handlers run pre-LLM), not a warm aside — safe, nothing lost. (b) The substantive + backstop is a coarse >=3-content-token line; it errs low-cost both ways and the LLM's metaIntent is + always required, so no code-only fencing. (c) UTH keeps its last snapshot on a meta aside (no + rebuild), consistent with the deterministic off-ramp — a plain in-panel event is still a follow-up. + +## Review pass over the weird-input lane + reviewer fixes (2026-07-05, fifth session) + +Reviewed the landed reviewer-fix code (unsupported-capability detection, manifest-driven answer, +meta-branch no longer clearing capture state — all confirmed present) plus the full off-ramp / meta / +affect surface. 550/550 green, tsc + vite build clean. + +- **R-fix (real bug): capability off-ramp hijacked writing subject matter.** `isUnsupportedCapabilityAsk` + treated bare `style|color|theme|layout|appearance|font|background|bold|italic` as app-feature asks, so + a *writing* turn — "can you tell me if my **style** is clear", "**color** theory matters in my essay", + "make my writing **style** clearer", "the central **theme** of my essay" — got hijacked into the + capability off-ramp and its content discarded (`commandOnly`). Tightened in two rounds: (1) an + appearance word must co-occur with an unambiguous MUTATION verb (make/change/set/turn/... — `style` + and `color` no longer count as the verb); (2) narrowed `appearanceWord` to unambiguously-visual terms + only (`colou?rs?|blue|red|green|purple|font|fonts|layout`), dropping style/theme/appearance/background/ + bold/italic. All pinned cases still fire ("make cards blue", "change the layout", "export this map"); + new regression test covers 5 writing-content phrasings that must reach the coach and stay harvestable. +- **Verified sound (no change):** meta lane fence returns before candidate/command routing; + `nonHarvestable` wired symmetrically (understanding.ts label sourcing, App.tsx bank merge OR-both-flags, + App.tsx understanding-bank filter); affect suppression gates only the two map-ward pushes + (mirror-pressure + draft-salience) and leaves the explicit user-asked list bridge alone; deterministic + off-ramp precedence over the LLM meta-lane is clean (off-ramp returns pre-LLM); trace catalog has + meta_aside so the header chip renders. +- **Minor, left as-is:** "can you write my thesis" (a content-authoring ask) isn't caught by the + deterministic authoring off-ramp because `thesis` isn't in its target-noun list — but the LLM layer + (manifest in prompt) declines it and no content is authored (enforcement holds), so it's an honesty-UX + gap, not an enforcement hole. "change the layout"/"export this" remain intentionally map-interpreted + per the pinned tests; a rare "change the layout of my essay" could still be mis-caught. + +## Judgment calls deferred to user (do not act) +1. ~~reference_confirmation swallows a NEW explicit command~~ RESOLVED in the gap work: generalized + command precedence now preempts any pending kind (with a cancel guard). +1b. Off-ramp ordering: a meta-repair phrase ("that's wrong") while a confirmation is pending CLEARS the + pending command and reorients, instead of routing to the near-match rejection/correction flow. Safe + (nothing executes) but lossy — the user must restate the command. Deliberate in the gap work (it has + its own test), so left as shipped; flagging the trade-off. +1c. Dismissed-idea resurrection is LLM-gameable: un-suppression only requires the upsert to cite ANY + this-turn utterance, so a pushy model can revive a dismissed candidate off an unrelated turn. + Consequence-bounded (readiness/validator/confirm still gate; panel is read-only). A stricter + "re-articulate" check would need vocabulary overlap between the fresh turn and the dismissed idea. +1d. The agreed "fenced LLM meta-lane" for emotional/general-weird input shipped as DETERMINISTIC + templates instead (annoyed/frustrated/confused/"that's wrong" → fixed repair copy). More conservative + than the decision, but general-weird input still falls to the normal coach loop with no special + prompt note. Fine for demo; revisit if off-distribution turns still feel mechanical. +2. activeSelectionContext persists until the next mirror or large turn (advisory-only; feeds acceleration + evidence + LLM ctx). Clearing it on move-on/topic-pivot would be tidier but changes coaching behavior. +3. clearChatOnly (App.tsx ~3309) replaces the whole LoopState with a fresh empty bank while the MAP (and + `confirmed[]`) persists — surviving cards' provenance utterance ids point into the discarded bank. + Go-forward writes still share the one live bank, so no invariant break, but provenance display for old + cards dangles. This is the previously deferred item E; needs a product decision (re-seed bank from map + texts on clear-chat, or accept dangling provenance). +4. pendingChildPlacement is ARMED by parsing the AI's OWN question wording (childPlacementRequest); the + user's next free-text answer is minted directly as a nested card with no confirm step. Card text is + user words and the placement was named in the question, so it's arguably consented — but it is the one + flow where AI question-wording chooses the parent. Deliberately shipped in `3d64f10`; leaving as-is. +5. "#448 should link to #451" (declarative-shaped but #ref-pair) still executes as a command — existing + test pins this as deliberate; my new declarative gate exempts #ref pairs to preserve it. diff --git a/prototype-mindmap/README.md b/prototype-mindmap/README.md new file mode 100644 index 00000000..2bfdee12 --- /dev/null +++ b/prototype-mindmap/README.md @@ -0,0 +1,110 @@ +# Reflective Mind-Map Prototype + +A writing-support prototype where the AI helps a user externalize their own +thinking into a node graph. Assistance contracts distinguish grounded reflection +from visibly AI-suggested contribution; every chat-derived structural change is +inert until explicit confirmation and retains its provenance. + +Sibling to `prototype-word-bank` (the document-insertion coach); it reuses that +prototype's deterministic-grounding philosophy but externalizes into a mind map +instead of a draft. Uses the repo's `backend/` OpenAI proxy for AI calls. + +## Writing Tools launcher + +Production builds require a launch from the Writing Tools tool launcher by +default. The launcher passes a short-lived `wt_grant` in the URL fragment; the +mindmap exchanges it for a scoped bearer token and removes the grant from the +URL. Development and test builds remain usable without a launcher token. + +```text +VITE_BACKEND_URL=http://localhost:8000/api +VITE_REQUIRE_LAUNCH=false +``` + +`VITE_REQUIRE_LAUNCH` enables the gate for a development or test build. When it +is unset, the gate is enabled for production builds and disabled for development +and tests. **Production builds always require a launch:** `VITE_REQUIRE_LAUNCH=false` +is ignored when `PROD` is set, so a misconfigured deploy environment cannot ship an +ungated bundle. A grant present in the URL is processed in every mode. + +`VITE_BACKEND_URL` is **required** for production builds and throws at startup if +missing. Development and test builds fall back to `http://localhost:8000/api`; a +production bundle carrying that fallback would point every user's browser at their +own machine. + +The Writing Tools registry uses `VITE_MINDMAP_TOOL_URL`. Its development default +is `http://localhost:5181/`; its production default is +`https://mindmap.thoughtful-ai.com/`. The existing Playwright smoke suite +continues to use Vite's development server at port 4173, so production-gate +verification remains a separate build check. + +## Design principle: typed proposals with deterministic consequences + +- **Enforcement (code, not configurable):** a mirror must pass validation before + it is shown; the AI cannot commit structure (only the user confirms); + connections must come from user-articulated language; every committed unit + carries provenance back to the user's words. +- **Factual prompt context:** Source Bank evidence ids, map/draft state, explicit + UI selection, Think/Map preference, and support controls. + +## Provider transport + +The established transport remains the default: + +```text +VITE_MINDMAP_PROVIDER_TRANSPORT=chat_json +``` + +The isolated provider-tool path is enabled locally with: + +```text +VITE_MINDMAP_PROVIDER_TRANSPORT=responses_tools +VITE_MINDMAP_MODEL=gpt-5.6-terra +VITE_MINDMAP_REASONING_EFFORT=low +``` + +The Responses transport exposes only `propose_reflection_v1` and +`propose_map_action_v1`. They create reviewable typed proposals; neither tool +confirms or applies a map mutation. + +## Enforcement core + +The pure validation and gateway modules remain independently unit tested even +though the prototype now includes a UI and provider adapters. + +| Module | Role | +| --- | --- | +| `config.ts` | Pointer-validation thresholds, explicit UI pacing, and capability facts. | +| `types.ts` | Domain model: source utterances, candidate thoughts, mirror claims, confirmed reflections, thought units. | +| `normalize.ts` | Normalizer (matches `prototype-word-bank/ownership.ts`) + stopwords + light stemmer. | +| `validator.ts` | **The 3-check mirror validator.** Content overlap, source-span grounding, unsupported-word budget. | +| `stage1-loop.ts` | Typed model orchestration, one repair attempt, and proposal creation. | +| `action-gateway.ts` | Sole deterministic boundary for map-changing actions. | + +The three validator checks, coarsest to finest: + +1. **Content overlap** — are the reflection's content words the user's words? +2. **Source-span grounding** — does every claim trace to a user utterance that + actually supports it? (Catches new *relationships* assembled from real words.) +3. **Unsupported words** — are stray new content words under budget? (Catches a + single meaning-shifting insertion like "central" that the average let through.) + +When any check fails the mirror is blocked and the AI must fall back to a +clarifying question, targeted at the weakest span. + +## Roadmap + +- **M0** — enforcement core (here). +- **M1** — minimal chat loop wired to the backend OpenAI proxy: Question → + Mirror (gated) → Clarify. +- **M2** — `@xyflow/react` mind-map surface; confirmed chunks become thought + units with provenance; AI proposals are "pending" until confirmed. +- **M3** — thought-unit role changes (content ⇄ sub-node), connections, direct + user editing with symmetric primitives. + +## Test + +```sh +npm install +npm test +``` diff --git a/prototype-mindmap/docs/6c-turn-progress-narration-pass.md b/prototype-mindmap/docs/6c-turn-progress-narration-pass.md new file mode 100644 index 00000000..8baaf3ac --- /dev/null +++ b/prototype-mindmap/docs/6c-turn-progress-narration-pass.md @@ -0,0 +1,160 @@ +# Checkpoint 6c Implementation Spec — Turn-progress narration + +Status: **SPEC READY — decided 2026-07-24.** Self-contained implementation +handoff for the one remaining Tier-2 code change. Audience: implementer (Codex) +on `mindmap-main`. The live browser-QA that gates this is written and run in a +**separate** chat — do not run it here. + +Authority: this spec restates AGENT-HANDOFF.md item **6c** (lines 505–544) in +buildable detail. If anything here conflicts with product philosophy in +AGENT-HANDOFF.md, stop and ask Nhyira — do not decide unilaterally. + +## The problem + +Every turn eagerly claims a reflection **before the model has chosen its move**. + +- `App.tsx:4073` and `App.tsx:4127` both call `setTurnProgress("initial_attempt")` + before any model call (the two send paths). +- `TURN_PROGRESS_COPY.initial_attempt` (`App.tsx:96`) reads + `"Trying to reflect your words..."`. +- So on question / answer / aside / suggestion turns the UI asserts "reflecting" + that never happens — inaccurate and repetitive. +- `stage1-loop.ts:445` also emits `onProgress({ stage: "initial_attempt" })` on + call 1 (via `callModel(1, undefined, "initial_attempt")` → line 436). + +The escalation stages are already honest and already fire only on genuine +escalation: +- `grounding_repair` → `"Making sure this stays in your words..."` (`callModel(2, …, "grounding_repair")`, line 480) +- `forced_question` → `"Asking a focused question instead..."` (`callModel(3, …, "forced_question")`, line 510) + +## Decided behavior (Nhyira 2026-07-24) + +1. **First-call phase — no eager narration, no reflection claim.** Show a + **neutral, ~700 ms-delayed** indicator driven off `loading` via a timer. + Neutral copy: add a new localized string **`"Working..."`**. (A wordless + dot/spinner is also acceptable, but the localized-string route is preferred + for consistency with the existing indicator.) A turn that finishes before + ~700 ms shows **nothing**. +2. **Escalation stages render immediately, unchanged.** `grounding_repair` and + `forced_question` keep their existing copy and stay event-driven — they + already fire only on real escalation. +3. **Retire the `initial_attempt` claim.** + - **Preferred:** drop the `initial_attempt` emission at `stage1-loop.ts:445` + (change `callModel(1, undefined, "initial_attempt")` → `callModel(1)` so no + progress event fires on call 1); the neutral indicator is driven purely by + `loading` + timer in `App.tsx`. Remove `initial_attempt` from the + `TurnProgressStage` union (`assistant-response.ts:84`) and retire the + `"Trying to reflect your words..."` string. + - **Acceptable alternative:** keep the stage in the union but never render the + old claim — map `initial_attempt` in the UI to the neutral-delayed + indicator. (Only take this route if removing the union member causes + disproportionate test churn; the preferred route is cleaner.) + +## Exact change list + +### `prototype-mindmap/src/stage1-loop.ts` +- **Line 445:** `callModel(1, undefined, "initial_attempt")` → `callModel(1)`. + This is the only functional loop change — call 1 must emit no progress event. + Leave lines 480 (`grounding_repair`) and 510 (`forced_question`) untouched. +- No change to the `callModel` signature or the guard at line 436 + (`if (progressStage) …`) — with no third arg, `progressStage` is `undefined` + and nothing emits. Good. + +### `prototype-mindmap/src/assistant-response.ts` +- **Line 84 (preferred route):** `TurnProgressStage = + "grounding_repair" | "forced_question"` (drop `"initial_attempt"`). +- `TurnProgressEvent` (line 87) needs no change beyond the narrowed union. + +### `prototype-mindmap/src/App.tsx` +- **`TURN_PROGRESS_COPY` (line 95–99):** remove the `initial_attempt` entry. + With the narrowed `TurnProgressStage` union the `Record<…>` type enforces this + automatically — you must remove line 96 or it won't compile. +- **Two eager sets — lines 4073 and 4127:** **delete** both + `setTurnProgress("initial_attempt");` lines. `turnProgress` now only ever + becomes non-null from a real escalation event via the existing + `onProgress` handlers (lines 4093 / 4149), which already do + `setTurnProgress(event.stage)`. +- **New neutral delayed indicator.** Add a boolean state (e.g. + `const [showWorking, setShowWorking] = useState(false)`) driven by a + `useEffect` on `loading`: + - when `loading` turns true, start a ~700 ms `setTimeout` that sets + `showWorking` true; + - when `loading` turns false (or on cleanup), clear the timer and set + `showWorking` false. + Keep the existing `setTurnProgress(null)` resets at lines 4113 / 4168. +- **Render (lines 4497–4504).** Currently `{loading && turnProgress && (…)}`. + Change so: + - if `turnProgress` is set (a real escalation), render + `t(TURN_PROGRESS_COPY[turnProgress])` exactly as today (immediate); + - else if `loading && showWorking`, render the neutral indicator + `t("Working...")` (same `msg assistant` / italic bubble styling); + - else render nothing. + Escalation copy must take precedence over the neutral indicator (if an + escalation fires after 700 ms, show the honest escalation copy, not "Working"). + +### i18n dictionaries +- **`src/i18n/source.json`:** add the key `"Working..."` (match the existing + ellipsis convention — the current strings use ASCII `...`, so use + `"Working..."`, not `"Working…"`). +- **`src/i18n/zh.json`:** add the Chinese value (e.g. `"处理中..."` — confirm with + the existing translation style in that file). `zh.json` must cover every + `source.json` key. +- Remove the `"Trying to reflect your words..."` key from `source.json` and + `zh.json` (and any other locale that carries it) since it is retired. Other + locales degrade to English by design; only `zh.json` must stay complete. +- Do **not** hand-edit the other ~32 locale files for the new key — they degrade + to English. (If the `generate-i18n` script from 6a lands first, regenerate; + it has not landed yet, so English fallback is expected and fine.) + +### Tests +- **`stage1-loop.test.ts`:** the two ladder tests referenced in the handoff + ("uses the capped two-reflection ladder and renders the forced question" @616; + "ends recovery when the forced-question call returns another response kind" + @669) assert on emitted progress stages. Update any assertion that expects an + `initial_attempt` event on call 1 — call 1 now emits **nothing**. The + `grounding_repair` / `forced_question` emissions are unchanged and must still + be asserted. +- Add/adjust a test that no progress event is emitted for a plain first-call + success turn. +- If a test imports the `TurnProgressStage` union or `TURN_PROGRESS_COPY` + expecting `initial_attempt`, update it. +- The ~700 ms timer is UI-timing behavior; unit-test it only if it's cheap with + fake timers. Its real coverage is the deferred live QA, not a unit test. + +## Verification (run from `prototype-mindmap/`, Windows) + +```bash +npx.cmd tsc --noEmit +npm.cmd test -- --run +npm.cmd run build +``` + +- Vitest may print all-pass yet exit `1` with + `[vitest-worker]: Timeout calling "onTaskUpdate"` after the fuzz run — a + runner/IPC artifact, not a failure. Say so explicitly if it happens. +- Commit only after `tsc` + tests + build are green. If the pre-commit hook + fails with `/usr/bin/env: 'sh'`, use + `git -c core.hooksPath=NUL commit ...` **only after** the checks pass. + +## Out of scope for this pass (do NOT touch) + +- The reflection-recovery ladder logic itself (items 2–4) — already built and + unit-tested; this pass only changes how call 1 is *narrated*. +- The Stage 4 eval / grounded-mirror-rate metric (item 5). +- `App.tsx` decomposition (item 7) and any Checkpoint 6 surfaces (6a/6b/6c-compare). +- The `6b` provenance-grid layout bug — separate item. + +## The gate (owed, in a separate chat — NOT here) + +The live browser-QA walkthrough is the **real acceptance gate for the whole +reflection-recovery arc**, not just this narration change. It is written and run +in the next chat, numbered-steps + "what to look for / what would be a bug" +format. It must confirm: +- (a) a fast grounding turn shows only the neutral delayed indicator, never a + false "reflecting" claim; +- (b) a genuinely hard/abstract turn surfaces "Making sure this stays in your + words..." then "Asking a focused question instead..." **in order**; +- (c) the coach reaches a grounded mirror OR a targeted question, **never a + dead-end** (the original item-2 motivation, still unverified live). + +Do not mark 6c or the arc "done" on unit tests alone — the live pass is the gate. diff --git a/prototype-mindmap/docs/DESIGN.md b/prototype-mindmap/docs/DESIGN.md new file mode 100644 index 00000000..5027feb6 --- /dev/null +++ b/prototype-mindmap/docs/DESIGN.md @@ -0,0 +1,303 @@ +# Reflective Mind-Map Design + +This is the canonical design document for `prototype-mindmap`. The companion +`airtightness-report.md` is the enforcement appendix. Older implementation +briefs were removed after they became stale. + +## Aim + +This is a writing-thinking tool, not a writing-production tool. The user +externalizes their thinking into a concept map. The AI helps by questioning, +reflecting and recapping the user's words, noticing when clarification is +needed, and offering reviewable structure. L0 and L1 remain strictly +user-word-faithful. L2 may originate visibly attributed suggestions, but no +model contribution becomes map structure without explicit user confirmation. + +The central bet is that constrained dialogue plus a user-grounded external map +helps a person construct and recognize their own thinking more deeply than +freeform chat or AI-generated prose. + +## Invariants + +1. **The user controls every committed idea, label, hierarchy, role, and + connection.** Direct user structure is user-authored; confirmed L2 material + retains visible AI provenance. +2. **L0 and L1 never author ungrounded structure.** L2 may suggest new content or + structure only with explicit attribution and the same confirmation gateway. +3. **Validation gates the AI, never the user.** The map is the user's sovereign + workspace. +4. **Selection creates influence.** Model-chosen connections among the user's + words are recorded as `ai_connected`; AI-authored wording is recorded as + `ai_suggested`. Neither is disguised as purely user-authored. +5. **The slider moves eagerness, never the authorship gate.** +6. **Enforcement lives in code; calibration lives in config.** + +Useful corollary: the AI may interpret freely, but a consequential act that +creates structure is either source-grounded and confirmed, explicitly +AI-attributed and confirmed at L2, or a direct user action. + +## Required Behavior + +### Capture + +A large voice/text turn is split into sentence-level units. Each unit is recorded +verbatim in the Source Bank. Nothing the AI says is ever treated as the user's +words. The typed turn orchestrator supplies factual context about a long, +exploratory turn so the model can choose an appropriate focusing move; it does +not classify the user's intent or rewrite the model's language. + +### Questioning + +The coach makes one coherent move per turn. That move may be a question, +reflection, grounded recap, aside, options, suggestion, or map proposal when the +active assistance contract permits it. Questions must not smuggle an unstated +answer, relationship, or direction into their premise. Direct quotation is used +only when it makes the question clearer; exact but confusing fragments are not a +quality success. + +### Mirroring + +A mirror restates structure in the user's own words. Its visible text is derived +from validated claims, so an uncited model-authored wrapper cannot leak into the +proposal. At L0 it draws from one recorded user moment; at L1/L2 it may bring +together eligible wording across turns or chat plus the current draft snapshot, +and that model-chosen synthesis is recorded as `ai_connected`. A mirror is split +into confirmable chunks; only confirmed chunks become cards. + +Reflection-grounding failures use a named, capped recovery ladder: initial +response, one informed repair supplied with the rejected reflection and exact +unsupported words, and—only if the repair is another grounding-failed +reflection—one forced-question call. The third call accepts only a valid typed +question. All other rejection paths retain one repair call. There is never a +fourth model call; exhausted paths render application-owned recovery UI. + +### Grounded Recaps + +A grounded recap is conversational consolidation, not a map proposal. Its +visible text is derived from validated, source-backed claims and it cannot +nominate candidate structure. L0 recaps only the current user turn. L1/L2 may +synthesize eligible user wording across turns or juxtapose chat with current +draft wording; cross-source or cross-turn selection is recorded as +`ai_connected`. Novel L2 interpretation belongs in an attributed suggestion, +not in a recap. + +### Carry-Forward + +When the user explicitly commits an idea ("the main idea I want to carry forward +is X"), a single clear, grounded statement can mirror immediately. It still must +validate and still requires confirmation. This fast-track is honored at any +slider position. + +### Chat-Derived Map Changes + +The model decides whether a chat turn warrants a map proposal; code does not +infer an imperative from wording. Every chat-derived structural change is shown +as a proposal and requires an explicit click before it can mutate the map. +The action gateway verifies user wording, references, duplicates, and graph +integrity. Missing labels and ambiguous references are completed in the proposal +card, never by parsing a later chat turn. A current-turn instruction that +explicitly names both cards in a nesting action may support that exact nesting: +the model cites the complete instruction, code verifies the span, recency, and +both references, and the user still confirms. Code does not infer this intent +with keywords or a semantic command parser. + +The composer has an explicit "Add as card" affordance for intentional capture. +Direct canvas manipulation remains immediate because it is already an explicit +user action, but it still crosses the same integrity boundary. + +### No Harvesting + +For long/exploratory turns, the coach must not extract a fixed number of cards. +It mirrors only what the user explicitly selected or grounded clearly enough; +otherwise it asks one focusing question that hands selection back to the user. +The longer and richer the input, the more careful the coach must be about +selecting structure for the user. The model receives factual turn-shape advice +and asks a focusing question when it cannot ground a clear proposal. Code never +uses turn shape to author, suppress, or rewrite conversational content. + +### Sparse-Map Pacing + +When the visible map is still sparse, the coach should keep helping the user +capture ideas rather than prematurely asking them to organize relationships. +Visible candidate richness alone is not enough to justify organize-mode +questioning. Until the map has enough structure to compare or connect, code +supplies a factual sparse-map pacing advisory asking the model to prefer capture +or clarification. This is deliberately prompt-advised rather than a controller +rewrite: pacing is a conversational judgment, not an authorship or safety gate. + +### Draft Context and Focus + +The complete draft is background context; a real user selection is stronger, +explicit focus evidence. The model uses the request, recent dialogue, and draft +to judge whether a passage-specific move is useful. A model-chosen anchor is a +passive overlay, never a browser-native selection and never `selectedFocus`. +`View passage`, or reopening the draft after it was docked/minimized, reveals and +scrolls to the anchor without changing its authorship status. + +Draft evidence is stored as immutable snapshots. Only the current snapshot is +model-visible. L0 cannot cite draft wording in reflections or recaps; L1/L2 may +juxtapose it only with eligible chat wording, and the result is `ai_connected`. + +### Controlled Working Memory + +Candidate ideas are deterministic lifecycle records (`active`, `parked`, +`ignored`, `promoted`) backed by exact Source Bank evidence and user-turn ages. +The model decides whether recall is conversationally useful; code does not match +topics or impose a minimum age. Recall is limited to validated questions and +asides using exact user phrases. Ignore and promotion prevent model +resurrection; Restore is an explicit Control Room action that returns an idea to +parked. Recall UI and terminal recovery UI never enter provider history or the +Source Bank. + +### Provenance and Suggestion Adoption + +User-authored material has no prominent badge. Model-selected synthesis of the +user's words is shown as `AI-connected · your words`. L2-originated structure is +shown as an AI suggestion. If a card's distinct stemmed content words overlap a +visible prior suggestion by at least 50% on creation or edit, it becomes +persistently `ai_suggested`; later edits update its current percentage and peak +without laundering the original influence. Suggestions that occur after the +card text was established cannot retroactively claim adoption. + +### Sovereign Map + +The user can freely create, edit, drag, nest, connect, delete, and undo. No map +action is blocked by reflection validation. User-introduced wording writes +back to the shared Source Bank so later AI turns stay grounded in the user's +canvas work. + +### Slider + +The Think-to-Map slider changes eagerness and pacing for non-declared ideas. It +is passed to the model as explicit factual steering. Honoring a user's tentative +framing is now the model's judgment, not a code word-bank: code no longer +classifies uncertainty from wording. The slider never changes pointer grounding, +confirmation, or whether chat-derived proposals require explicit confirmation. + +### Diagnostics + +The Control Room shows the trustworthy typed-response, validation, repair, +gateway, proposal, and application events for the current session. It does not +invent explanations from a legacy controller mode. + +## Implementation Map + +| Behavior | Where | Mechanism | +| --- | --- | --- | +| Capture | `store.ts`, `normalize.ts` | `SourceBank.addSegmented`, sentence/newline segmentation | +| Questioning | `api.ts`, `stage1-loop.ts`, `llm-contract.ts` | Typed response prompt plus factual, advisory turn/map context | +| Validation | `validator.ts` | Strict visible-word grounding + span/relationship grounding for reflections and recaps | +| Confirmation | `App.tsx`, `proposal-store.ts` | Reflections and map actions remain inert until an explicit proposal decision | +| Chat proposals | `stage1-loop.ts`, `action-gateway.ts` | Model proposes; code verifies pointers/references; user confirms | +| Direct canvas actions | `Map.tsx`, `action-gateway.ts` | Immediate explicit-user actions retain graph/store checks | +| Assistance contracts | `assistance-contract.ts`, `stage1-loop.ts` | L0/L1/L2 contribution permissions are snapshotted per turn; they never authorize a write | +| Provenance | `proposal-store.ts`, `map-store.ts`, `suggestion-adoption.ts`, `Map.tsx` | User-authored, AI-connected, and AI-suggested material remain distinguishable; adopted suggestions retain current and peak overlap | +| Audit ledger | `event-ledger.ts` | Full local events in IndexedDB; no server telemetry | +| Map | `map-store.ts`, `Map.tsx` | One primitive: `ThoughtUnit` card; nesting is `parentId`; connections have label cards | +| Draft grounding and anchoring | `App.tsx`, `api.ts`, `draft-anchor.ts` | Immutable current draft snapshots plus a passive model anchor overlay; model anchors never create `selectedFocus` | +| Voice dictation | `App.tsx`, `useSpeechToText.ts` | Browser speech recognition fills the composer for manual review before send | +| Slider | `config.ts` | `withQuestionIntentBias` changes pacing thresholds only | +| Diagnostics | `understanding.ts`, `trace.ts`, `Map.tsx` | Structured response, validation, repair, gateway, proposal, and application events | +| Provider proposal tools | `provider-tools.ts`, `api.ts` | Feature-flagged Responses tools normalize into the same reflection/map-proposal path and never mutate the map | + +The integration that must never break: the map and the chat loop share the same +`SourceBank` instance (`stateRef.current.bank`). Map writes and undo restores go +through that instance. + +## Decisions and Rejected Alternatives + +- **LLM interpretation, code fences.** The model interprets conversational + meaning and decides whether to ask, reflect, or propose. Code verifies only + facts it owns: evidence pointers, references, graph integrity, provenance, + contract permissions, and explicit confirmation. + +- **Factual advice, not lexical interpretation.** Code may tell the model facts + such as the current map is sparse, the user selected a card, or a draft span is + in focus. It does not feed keyword/regex interpretations such as "because was + detected" back into the prompt, and it never rewrites a capable model's + conversational response merely to enforce pacing. + +- **No controller trim for mirror chunk count.** A hard cap would silently drop + user-grounded claims and move selection into code. If many ideas are ready, + ask a focusing question; do not trim. + +- **Declaration recognition is not slider-gated.** Explicit user intent is + honored at any position. The slider only tunes pacing for non-declared ideas. + +- **Carry-forward is idea-only.** It accelerates density only. It never satisfies + relationship clarity, hierarchy spontaneity, or connection grounding. + +- **Proposal-first chat structure.** Chat is never a direct mutation channel, + even when its wording appears imperative. The proposal click supplies the + intent confirmation that evidence checks cannot infer. + +- **Provider tools transport intent, not authority.** The Responses transport + exposes only reflection and map-action proposal tools. Calls normalize to the + existing typed union and still cross contract, evidence, gateway, + confirmation, and revalidation boundaries. There is no provider tool for + confirmation, application, direct canvas mutation, or persistence. + +- **Local transcript remains authoritative.** The Responses adapter uses + `store: false`, disables parallel tool calls, and replays provider items only + inside the bounded recovery attempt currently in flight. Reflection grounding + alone may use the capped third forced-question call. `chat_json` remains the + default until `responses_tools` matches or improves validity and passes a live + propose-only authority smoke test. + +- **No ghost structure.** An AI proposal is reviewed in chat only. It never + stages a tentative card, edge, nesting, layout shift, or relationship visual + on the canvas before the user confirms it. + +- **Narrow L0 claim.** L0 ensures that map structure is user-stated, + deterministically grounded, and explicitly confirmed; it does not claim that + a question could never influence a user's subsequent wording. Exact prior-turn + echo is logged as influence evidence, not misrepresented as authorship proof. + +- **Inline proposal completion.** Missing labels and ambiguous references are + resolved in a proposal card. The next user message is never treated as a + hidden yes/no, correction, or command continuation. + +- **Nested cards render as embedded DOM, not xyflow subflows.** This keeps the + card as the one primitive and makes nesting visually literal. + +- **Default model profile.** The prototype starts with GPT-5.6 Terra at low + reasoning. Comparative profiles remain an evaluation decision after the live + transcript smoke test; no model is judged on a harness that omits the latest + user reply from dialogue position. + +- **Eval rubric: mirror within about one productive turn.** One useful follow-up + on a compound idea is a pass; forcing immediate mirroring everywhere would + make the tool an extractor. + +## Current Status + +Built and tested: + +- capture/segmentation +- typed question/reflection/grounded-recap/aside/map-proposal/options/suggestion responses +- pointer validation and attribution derivation +- visible reflection/recap text derived from validated claims +- per-chunk mirror confirmation +- reflection-specific informed repair and forced-question recovery, with + application-owned terminal UI +- proposal-first chat structure and direct canvas integrity actions +- explicit current-turn card-reference nesting intent without semantic routing +- concept map cards, nesting, connections, delete, undo +- immutable draft snapshots and passive draft anchoring +- Think-to-Map slider +- structured diagnostics +- controlled working-memory candidate lifecycle and source-backed recall +- three-tier provenance plus persistent suggestion-adoption percentages +- browser voice dictation into the chat composer with manual review before send +- persisted contracts, provenance, local ledger, and v6 session migration +- command-only exclusion from mirror eligibility + +Current verification checkpoint (2026-07-21): TypeScript and eval type-checks, +270 Vitest tests, and the production build are green. Browser smoke verified the +application shell and assistance-level switching. Live-model recap, explicit +nesting, and tuned reportable-eval verification remain the next evidence pass. + +Known tradeoff: questions can still carry framing that code cannot reliably +classify. The system protects autonomous map authorship, provenance, and visual +staging in code; it records exact echo evidence and measures conversational +directiveness through scenario evaluation rather than brittle language routing. diff --git a/prototype-mindmap/docs/README.md b/prototype-mindmap/docs/README.md new file mode 100644 index 00000000..badba736 --- /dev/null +++ b/prototype-mindmap/docs/README.md @@ -0,0 +1,18 @@ +# Mindmap prototype docs — read order + +New here? Read in this order: + +1. [DESIGN.md](DESIGN.md) — canonical design invariants. The authorship + philosophy lives here; everything else defers to it. +2. [airtightness-report.md](airtightness-report.md) — how the invariants are + enforced in code (the enforcement appendix). +3. [refactor-plan.md](refactor-plan.md) — staged de-brittling rationale and + resolved decisions (Stages 1–4). +4. [../../AGENT-HANDOFF.md](../../AGENT-HANDOFF.md) — current state, planned + work, and the collision map for anyone building on this branch. + +Pass documents (scoped work plans, most recent first): + +- [multilingual-grounding-pass.md](multilingual-grounding-pass.md) — in flight. +- [stage-4-eval-harness-pass.md](stage-4-eval-harness-pass.md) — active track. +- [stage-1_5-fix-pass.md](stage-1_5-fix-pass.md) — historical. diff --git a/prototype-mindmap/docs/airtightness-report.md b/prototype-mindmap/docs/airtightness-report.md new file mode 100644 index 00000000..ae4be2e2 --- /dev/null +++ b/prototype-mindmap/docs/airtightness-report.md @@ -0,0 +1,176 @@ +# Airtightness Report + +Enforcement appendix for `prototype-mindmap`, describing the **Stage 1 typed +proposal runtime** (post-cutover). `DESIGN.md` is the canonical product/design +source and `refactor-plan.md` is the staged plan; this report tracks which +philosophical constraints are enforced in code, which are prompt-level, and +where the residual soft spots are. + +> Architecture note: the legacy `controller.ts` regex intent-router, the +> `signals.ts`/`readiness.ts` word-bank readiness gate, and the direct-command +> detection forest described in older versions of this report **no longer +> exist**. `controller.ts` is a thin re-export of the typed loop; `map-commands.ts` +> is a compatibility seam over the action gateway. Enforcement now lives in +> `validator.ts`, `action-gateway.ts`, `proposal-store.ts`, `assistance-contract.ts`, +> `map-store.ts`, and `store.ts`. + +## Central Principle + +The user controls every committed idea, label, hierarchy, role, and connection. +L0/L1 structure remains user-word-faithful; L2 may contribute visibly attributed +AI suggestions. The model interprets conversational meaning freely; code verifies only facts it owns — +evidence pointers, references, graph integrity, provenance, contract +permissions, and explicit confirmation. Validation gates the AI's contributions; +direct user map actions are never blocked by validation. + +## The one enforcement pattern + +> Code does not *detect* language. The model *points at* its evidence, and code +> *verifies the pointer is verbatim in the user's text*. + +Every consequential act is either fenced by grounding-plus-confirmation, +explicitly AI-attributed plus confirmation at L2, or is a direct user canvas +action. No keyword/regex bank interprets user intent in the enforcement path. + +## Code-Enforced Constraints + +| Constraint | File(s) | Mechanism | +| --- | --- | --- | +| Reflections and recaps use the user's words | `validator.ts`, `stage1-loop.ts` | Every distinct substantive content word in each claim must be supported by cited eligible evidence; ordinary function-word glue is ignored. Visible text is reconstructed from validated claims rather than trusted from a model-authored wrapper. | +| Relationships/hierarchy are stated by the user in one breath | `validator.ts` | Each cited span must ground `>= 0.75` of its phrase within a *single* utterance. For hierarchy/connection claims, the model declares the literal `relationSpan` connective; code verifies it is a verbatim substring of both the claim text and one cited utterance, and that most claim content words ground in that same utterance. No relation-word bank participates. | +| A reflection is only ever a proposal | `stage1-loop.ts`, `proposal-store.ts`, `App.tsx` | A validated reflection becomes a `shown` proposal with per-claim confirm/decline; only confirmed chunks reach the gateway and become cards. | +| Reflections never carry novel L2 prose | `stage1-loop.ts` | Reflections remain source-backed at every level. L0 is limited to one recorded user moment; L1/L2 cross-turn or draft+chat synthesis is accepted as `ai_connected`. Novel L2 language must use an attributed suggestion or map proposal. | +| Grounded recaps are non-consequential | `stage1-loop.ts` | A recap validates like a reflection, creates no proposal, and may not include candidate upserts. L0 is current-turn-only; L1/L2 synthesis is traced as `ai_connected`. | +| One consequence boundary for every map write | `action-gateway.ts` | `inspectAction` (AI proposals) and `executeCanvasAction` (direct user) are the only paths that mutate the map. Chat, panel buttons, and canvas all route through the gateway. | +| AI card/edit text is verbatim user wording | `action-gateway.ts` | `create_card`/`edit_card` from an AI proposal require the text to be a whole-phrase substring of a cited utterance unless the contract permits AI-suggested structure (then the card is stamped `ai_suggested`, never `user_asserted`). | +| Connections need verified pairing + relationship evidence | `action-gateway.ts` | Below L2, a connection requires a `VerifiedPairingProof` (selection pair, co-mention in one turn utterance, or selection-plus-named-card) *and* relationship evidence grounded in one utterance. The model nominates; code verifies. | +| Explicit nesting intent is honored narrowly | `action-gateway.ts`, `stage1-loop.ts` | A current-turn nesting instruction may support only the named child/parent pair when its exact cited span contains both card references. It still produces a proposal and requires confirmation; no keyword or semantic command classifier is used. | +| References are never guessed into execution | `action-gateway.ts` | Exact normalized match resolves; anything else returns `needs_reference_choice`/`needs_input` for the user to pick. Ambiguity never auto-executes. | +| Missing labels/refs are completed inline, not mined from chat | `action-gateway.ts`, `proposal-store.ts` | Incomplete proposals carry a `completion` and stay `unresolved` (never mislabeled as an AI inference); the next chat message is not treated as a hidden answer. | +| Graph integrity | `map-store.ts`, `action-gateway.ts` | Cycle guard on nesting; connection endpoints normalize to root cards; self-loops dropped; duplicate connections rejected. | +| Proposals cannot execute against a changed map | `proposal-store.ts` | Each proposal stamps `mapRevision`; a moved revision or a vanished referenced card invalidates it. Resolution is by explicit UI transition (`canTransitionProposal`), never by parsing the next message. | +| Contract allowlist is a code gate | `assistance-contract.ts`, `stage1-loop.ts` | `contractRejectsResponse` rejects any response `kind` not allowed at the active level and enforces `optionsMustBeVerbatim`. The contract never authorizes a write. | +| Options at L1 are the user's own words | `stage1-loop.ts`, `action-gateway.ts` | `optionsMustBeVerbatim` requires every option and its spans to be verbatim user spans. | +| Provenance is preserved | `map-store.ts`, `action-gateway.ts`, `proposal-store.ts`, `suggestion-adoption.ts` | `user_asserted`, `ai_connected`, `ai_suggested`, `unresolved`, and `legacy_confirmed` survive persistence. A card that reaches the inclusive 50% suggestion-adoption threshold remains `ai_suggested`; later edits update current/best overlap while retaining the original adoption source and peak. | +| Provider tools transport intent, not authority | `provider-tools.ts`, `api.ts` | The feature-flagged Responses transport exposes only `propose_reflection_v1` and `propose_map_action_v1`; both normalize into the typed union and cross the same gateway/validator/confirmation boundaries. No tool confirms, applies, or persists. | +| Source Bank is ground truth | `store.ts`, `App.tsx` | Chat, declarations, map edits, and immutable draft snapshots are append-only utterances; only the current draft snapshot is model-visible. `commandOnly`/`nonHarvestable` exclude wording from later harvest without deleting provenance. | +| Direct user actions are sovereign and undoable | `action-gateway.ts`, `map-store.ts`, `App.tsx` | `executeCanvasAction` skips AI-only checks (the user is never validation-gated) but still enforces graph integrity; edits/nesting/connections snapshot for undo. | +| Card sizes cannot brick the canvas | `map-store.ts` | `clampCardSize` bounds every `setSize` and every loaded snapshot. | +| Calibration is separate from enforcement | `config.ts` | Only deterministic thresholds and product facts; no language interpretation. | +| Diagnostics describe real events | `stage1-loop.ts`, `understanding.ts`, `trace.ts`, `event-ledger.ts` | Response/validation/gateway/proposal/repair events are emitted from actual outcomes; the local ledger keeps full fidelity, outbound study events carry only allowlisted metadata. | + +## Important Code Details + +### Mirror validation (`validator.ts`) + +Two grounding checks run per claim: **strict content-word grounding** (every +distinct substantive word must occur in cited evidence) and **span grounding** +(each cited span grounds within one utterance; relational claims also require the declared +connective to be verbatim in one utterance). Code no longer classifies epistemic +meaning — the old `tentativeEvidencePattern` word-bank was removed; honoring a +user's tentative framing is the model's judgment. A failed claim yields a +`weakestSpan` for the repair/clarify path. + +### The action gateway (`action-gateway.ts`) + +`inspectAction` returns a structured result (`ready` / `needs_input` / +`needs_reference_choice` / `needs_relationship_label` / `rejected`), not a +boolean. `ready` carries an `ExecutableAction` and a computed `origin`. +`applyGatewayActions` executes only an already-inspected executable action after +the user's click. `executeCanvasAction` is the parallel boundary for immediate, +explicitly user-authored canvas intents. + +### Proposal lifecycle (`proposal-store.ts`) + +One `Proposal` type replaces the old pending-command cluster. State transitions +are constrained; `shown`/`edited` are the only actionable states; confirmation is +a UI transition. `mapRevision` stamping makes stale proposals fail closed. + +### Assistance contracts (`assistance-contract.ts`) + +Three immutable, versioned levels: L0 non-directive (`question`, `reflection`, +`grounded_recap`, `aside`, `map_proposal`; one-moment/current-turn synthesis and +asserted structure only), L1 grounded options (+ verbatim `options` and +cross-turn/draft+chat synthesis traced as `ai_connected`), and L2 suggestive (+ +`suggestion` and attributed `ai_suggested` structure). Fixed at every level: +provenance, validation, map-write authorization, confirmation, and graph checks. + +### Provider transport (`provider-tools.ts`, `api.ts`) + +`chat_json` is the default; `responses_tools` is feature-flagged. Both parse into +the same typed `AssistantResponseEnvelope`. The Responses adapter uses +`store: false`, disables parallel tool calls, and replays provider items only in +the current bounded recovery attempt. Reflection grounding alone may escalate +to a capped forced-question call. Live-turn dialogue history is built call-time +(`historyForCurrentTurn`) so the model never sees a transcript that ends on its +own unanswered question. + +## Prompt-Level Constraints + +These shape behavior but are **not** the final enforcement boundary: + +- the coach philosophy and question rules in `api.ts` (`systemPrompt`) +- sparse-map pacing, reflection rhythm, and turn-shape advisories (all rendered + as facts, e.g. "cards=N; sparse=true", never as keyword interpretations) +- the Think/Map eagerness value +- the capability manifest (`config.ts`) + +Prompt failures are expected to be caught by the validator, the gateway, the +contract allowlist, or the confirmation click wherever an act becomes +consequential. + +## Residual Soft Areas + +- **Question framing and quotation clarity.** A question can smuggle a suggestion + or use exact fragments in a syntactically confusing way; no reliable code gate + catches either. Exact prior-assistant overlap is logged as an `InfluenceTrace` + (evidence, not an authorship classifier). Reportable evals separately score + hidden premises and confusing quotation. +- **Near-match resolution** is simple substring/token containment, used only to + ask the user which card — never to execute structure without confirmation. +- **Candidate grouping / turn-shape** are model-interpreted advisories; bad + advice is bounded by validation, the gateway, and confirmation. +- **Cross-turn memory / recall is model-owned, code-supported.** The + `open-threads` subsystem was deleted (2026-07-20) and replaced by working-memory + recall built on the `CandidateStore`: the model nominates candidates, code + persists them with a `status` (active/parked/ignored/promoted) and a factual + `ageInTurns` + staleness signal (`currentUserTurn` − `lastTouchedTurn`), and the model decides + when to recall. Held and ignored ideas are surfaced in the Control Room using the + user's own evidence wording. Code never matches user text to held items or + decides that the user "returned" to a topic — recall stays a model move, and any + recalled idea still crosses the gateway before placement. Ignored and promoted + records absorb model advisory updates; Restore is an explicit user action that + returns an ignored record to parked. +- **Draft anchoring is model-chosen and passive.** The model may name an exact + `anchor` substring; `draft-anchor.ts` measures it as a read-only overlay that + never creates a native selection or mutates the draft. There is no code length + cap. `findDraftAnchorRange` inserts block/`
` separators and matches on + normalized whitespace, so multi-sentence and cross-paragraph anchors resolve + (non-contiguous word phrases still fail). Anchor *scope* (short phrase vs. + passage) remains the model's prompt-level judgment. Opening or undocking the + draft does not convert a model anchor into user-selected focus. +- **Stemming/normalization** is intentionally simple. + +## File Responsibility Summary + +| File | Responsibility | +| --- | --- | +| `validator.ts` | Strict content-word + span/relation grounding for reflections and grounded recaps. | +| `action-gateway.ts` | The sole map-write consequence boundary (AI proposals and direct canvas actions), reference/graph/verbatim/pairing checks, origin derivation. | +| `proposal-store.ts` | Proposal type and state machine; revision-stamped invalidation. | +| `assistance-contract.ts` | L0/L1/L2 contracts and snapshots; contribution allowlist. | +| `stage1-loop.ts` | Thin orchestrator: contract check → validate/gateway → proposal; one ordinary repair or a capped reflection-recovery ladder. | +| `store.ts` | Source Bank (ground truth) and Candidate Store. | +| `map-store.ts` | Thought units, nesting, connections, endpoint normalization, sizes, snapshots. | +| `map-commands.ts` | Deprecated compatibility seam re-exporting the gateway. | +| `provider-tools.ts` | Responses-transport schemas and normalization into the typed union. | +| `config.ts` | Deterministic thresholds and product facts (no interpretation). | +| `normalize.ts` | Whole-phrase/word-boundary matching and stemming. | +| `turn-shape.ts` | Deterministic size-only turn classification (advisory). | +| `draft-anchor.ts` | Read-only measurement of a model-named draft anchor span (overlay rects + scroll); never mutates the draft or creates a selection. | +| `event-ledger.ts` | Local full-fidelity events; allowlisted outbound metadata. | +| `api.ts` | Prompt, provider transports, defensive parsing into the typed union. | +| `App.tsx` | Session state, persistence/migration, proposal UI, Control Room, undo, voice, draft. | +| `Map.tsx` | Visual concept map and diagnostics surface. | +| `types.ts`, `llm-contract.ts`, `assistant-response.ts` | Domain and response-contract types. | diff --git a/prototype-mindmap/docs/multilingual-grounding-pass.md b/prototype-mindmap/docs/multilingual-grounding-pass.md new file mode 100644 index 00000000..5c1483ee --- /dev/null +++ b/prototype-mindmap/docs/multilingual-grounding-pass.md @@ -0,0 +1,450 @@ +# Multilingual Grounding Pass — spec for Codex + +Status: AGREED 2026-07-23. Amendments from Claude review are folded in and marked +**[AMENDED]**. Read `DESIGN.md` and `airtightness-report.md` first; nothing here +relaxes the authorship invariants. + +## Central design decision + +Multilingualism changes how the coach **understands and presents** original +evidence; it does **not** create a second authoritative version of that evidence. +Natural mirrors remain the default, exact wording becomes a repair strategy, and +provenance — not translation — continues to determine what the system may claim +or place on the map. + +Three separate concepts, never conflated: + +- **UI locale** — buttons, labels, instructions, fixed interface copy. +- **Original authored content** — user messages, drafts, cards, evidence, + relationship wording. Stored exactly as authored; authoritative. +- **AI translation** — an explicitly requested, visibly attributed model output. + Never silently replaces authoritative content. + +Do **not** create an authoritative translated SourceBank or insert translated +duplicates into the SourceBank. + +## 0. Donor branch: `origin/feat/mindmap_translation` — cherry-pick, never merge + +**[AMENDED — verified rationale]** The translation branch forked **before** the +Stage 1 typed-proposal cutover. It still carries `stage1-loop.ts`-era code, an +old `validator.ts`, controller-era modules, and ~24k insertions vs. main. A +merge would resurrect deliberately deleted subsystems. Treat it strictly as a +donor: copy files/assets in, never `git merge` it. + +**[AMENDED — the donor is better than first assessed.]** Inspect before +rebuilding; these donor modules already encode the right philosophy: + +Reuse (near) as-is: +- `src/i18n/*.json` — **34 static UI dictionaries including `zh.json`**, keyed + by English source string. Chinese UI works day one. +- `src/ui-strings.ts` — static dictionary lookup with case-folded keys; + partial dictionary degrades in coverage, never correctness. +- `src/language.ts` — the write/view language split. Its own doc comment states + a view-language translation is "never written back, never harvested into the + map, and never handed to the assistant as the writer's wording." + `isReadOnlyView()` is a genuine **seed for the centralized mutation guard** + (checkpoint 1), not something to discard. +- `src/translate.ts` / `src/translation-memory.ts` / + `src/translation-context.ts` — display-only translation overlay with + client-side caching and bounded concurrency; never mutates stored content. +- Locale controls, persistence keys (where compatible), reader-display styling, + UI-presentation-only tests, `useSpeechToText` if wanted (separate decision). + +Reimplement or substantially revise (anything touching the pre-cutover runtime): +- Any derived translation bank treated as authored content. +- Any logic that translates SourceBank entries. +- Coach context construction (rebuild on the current typed loop). +- Grounding and normalization (rebuild per §5 on the current `validator.ts`). +- Persistence that treats translated text as authored. +- Scattered mutation/read-only checks → centralize (checkpoint 1). +- Anything referencing the donor's old `validator.ts`, loop, or controller. + +## 1. Branch and checkpoint strategy + +Create a new branch from the current authoritative branch: + +``` +mindmap-main HEAD +└── feat/mindmap-multilingual-grounding +``` + +Before importing anything: confirm TypeScript, tests, and build are green; add +regression tests for existing provenance and assistance-level boundaries; +record the starting commit in the handoff. Do not merge the remote language +branch. + +Checkpoints, in order: + +1. UI localization + centralized mutation gating. +2. Original-language coach context and persistence. +3. **English/Chinese/mixed-language grounding** (see §5 amendment). +4. Explicit translation behavior. +5. Context budgeting, evals, and browser verification. + +**[AMENDED — checkpoint discipline]** Each checkpoint must land independently +green (`tsc`, full Vitest, build) before the next begins — a regression must be +attributable to display localization vs. grounding vs. translation behavior +without untangling a combined diff. Checkpoints 1 and 5 are each PR-sized +tracks on their own; do not fold them into neighbors. + +## 2. UI localization without touching authored content + +Port from the donor: locale selection, translated application chrome, +translation dictionaries, persisted UI locale, localized fixed recovery and +progress UI, localized accessibility labels. + +Never translate or overwrite: SourceBank utterances, user chat messages, +drafts, card contents, candidate evidence, relationship evidence, +model-history entries. A display-language change re-skins the interface and +leaves all authored material **byte-for-byte unchanged**. + +While doing this, centralize read-only and mutation permissions: every map or +proposal mutation passes through one authoritative guard (seeded from the +donor's `isReadOnlyView` pattern), covering direct card creation/editing, "Add +as card", proposal confirmation/editing, nesting and connections, candidate +restoration/dismissal, and assistance-level continuation actions. + +**[STATUS 2026-07-23]** Checkpoints 1–3 are accepted on +`feat/mindmap-multilingual-grounding` (rebased onto `mindmap-main`; +checkpoint 1 `7448891`, checkpoint 2 `47e2999`, checkpoint 3 reviewed and +awaiting its commit). Reviews, audit findings, and carry-forward items live +in `AGENT-HANDOFF.md` under this pass's collision-map entry. Checkpoint 4 +(§8 explicit translation) is next; it makes `translated_view` reachable and +therefore also owns the checkpoint-1 carry-forwards: gateway-level read-only +enforcement + user-visible rejection feedback, and the +`source.json`/`zh.json` "Enter to send" dedupe. Standing invariants: +grounding never consults `latestUserLanguagePattern` (advisory-invariant +test pins it), and the English mirror-rate watch-item in §6 applies before +any validator loosening is even discussed. + +## 3. Original multilingual coach context + +The coach receives original user content, not derived translations. Add: + +```ts +interface LanguageContext { + uiLocale: string; + preferredCoachLanguage?: string; + latestUserLanguagePattern?: "single" | "mixed" | "unknown"; +} +``` + +These are conversational hints, **not evidence**. Prompt guidance +(approximately): preserve authored passages in their original language; respond +using the language pattern of the latest user turn unless asked otherwise; a +mixed-language turn may receive a naturally mixed-language response; do not +silently translate quoted evidence. Incorrect language detection must never +invalidate or alter user content. + +## 4. Reflections cite precise evidence phrases + +Preserve natural model-authored reflections; code never assembles them. Extend +reflection evidence so the model identifies the phrases inside cited +utterances: + +```ts +interface ReflectionEvidencePhrase { + utteranceId: string; + userPhrase: string; +} + +interface GroundedReflection { + text: string; + evidence: ReflectionEvidencePhrase[]; +} +``` + +Code: (1) confirm the utterance exists and is eligible; (2) confirm +`userPhrase` occurs in that exact original utterance; (3) locate internal +character positions itself; (4) validate the reflection against selected +evidence phrases; (5) validate relationships separately; (6) commit advisory +mutations only after the complete response passes. **Never** ask the model to +calculate character offsets (doubly important for CJK: surrogate pairs and +grapheme clusters make model-computed offsets unreliable). + +## 5. Layered multilingual normalization + +Separate language-neutral processing from language-specific equivalence. Do +not introduce semantic similarity or embedding-based validation — the +validator checks authorship support, not topical relatedness. + +### Language-neutral foundation (all languages) + +- Unicode canonical normalization; +- safe case comparison where applicable; +- whitespace normalization; +- Unicode-aware word segmentation; +- punctuation and quotation handling; +- exact phrase location. + +**[AMENDED — CJK requirements for the neutral layer.]** As originally specced +this layer produces false grounding failures in Chinese. Required: + +1. **Punctuation width/quote folding.** NFC does *not* unify full-width + `,。!?:;` with half-width equivalents, nor `「」『』` with `“”`/`""`. + The model quoting an utterance with different punctuation width must not + fail exact-match. Fold via NFKC or an explicit width/quote-mapping table in + the neutral normalizer. This is the single most likely Chinese-breaking + omission — cover it with tests (see §10). +2. **Segmentation without whitespace.** "Unicode-aware word segmentation" + means `Intl.Segmenter` with word granularity (handles zh). The Node test + environment must have **full ICU** (verify; add a canary test asserting + `new Intl.Segmenter("zh", {granularity:"word"})` segments a known sentence + correctly), or repair-ladder "unsupported words" feedback is garbage for + Chinese. +3. **Simplified ↔ Traditional is NOT unified.** If the model echoes evidence + converting 简↔繁, it fails grounding and falls to the repair ladder. That is + the *decided* behavior (safe fail), not an accident — document it in the + validator and cover it with a test. + +### Supported language profiles + +**[AMENDED — initial profiles are English and Chinese, not English and +Spanish.]** The target audience is Chinese; the donor branch already ships +`zh.json`. Spanish may be added later as the inflectional-equivalence +exemplar; it is not in this pass's scope. + +```ts +interface GroundingLanguageProfile { + segment(text: string): string[]; + normalizeToken(token: string): string; + isFunctionWord(token: string): boolean; +} +``` + +The **zh profile** is cheaper than an inflectional one: +- `segment`: `Intl.Segmenter("zh", { granularity: "word" })`. +- `normalizeToken`: identity plus the neutral layer's width/quote folding (no + stemming — Chinese has no inflection). +- `isFunctionWord`: a small **closed** particle/glue list (e.g. 的 了 是 就 吗 + 呢 也 和 在 着 过 呀 吧 啊). Keep it closed-class; no open-ended word bank + (same principle as the no-hedging-word-bank rule in the mirror-faithfulness + spec). + +Without a zh profile, mixed zh-en turns would treat every particle as +substantive → stricter validation → more forced repairs → a stiffer coach in +exactly the demo language. The profile exists to permit *grammatical glue +only*; substantive terms still require exact evidence match. + +### Mixed-language validation + +- Preserve quoted and substantive phrases in their original language; +- apply a known profile only where appropriate; +- unknown substantive terms must match evidence exactly; +- permit only narrowly defined conversational glue; +- reject translated substantive claims unless the response is explicitly + classified as translation. + +Language detection can assist profile selection; it cannot determine +authorship or rewrite evidence. + +## 6. Recovery stays model-owned + +No deterministic exact-source response composer. When a natural reflection +fails grounding, use the existing capped ladder (see the graceful-recovery +spec in the handoff): informed repair receives the unsupported words and the +rejected reflection; the model may produce a corrected natural reflection, a +more extractive reflection preserving exact phrases, a question, or another +contract-allowed response; repeated failure reaches the existing +forced-question call. Prompt (not controller code) advises: if paraphrasing +cannot be grounded faithfully, preserve the user's exact original-language +wording; if that would be unnatural or misleading, ask one context-specific +question instead. Code decides validity; the model decides the conversational +move, except the already-agreed forced-question bound. + +**[AMENDED — expected Chinese behavior shift, by design.]** Chinese +reflections paraphrase aggressively by nature (synonym swaps, aspect +particles, measure words), so exact-phrase grounding will fail more often in +Chinese than English, pushing the coach toward extractive mirrors and +questions. This is contract-correct but changes the demo *feel* — the Chinese +coach will sound more quote-heavy. Measure it (§10 evals) before a demo +audience does; if the first-pass grounded-mirror rate in Chinese is +materially worse than English, that is a prompt-tuning/model-capability +signal, not a reason to loosen validation. + +Because Checkpoint 3 also limits lexical support to the exact evidence phrases +the model nominates, compare the English first-pass grounded-mirror rate against +the pre-Checkpoint-3 baseline. If it drops materially, tune the prompt to cite +enough precise original-language evidence; do not loosen the validator. + +## 7. Cross-language relationships through provenance + +Given `u1` (English: A), `u2` (Chinese: B), `u3` (Chinese: explicit +relationship between A and B), a response may cite: + +```json +{ + "sourceEvidence": { "utteranceId": "u1", "userPhrase": "A" }, + "targetEvidence": { "utteranceId": "u2", "userPhrase": "B" }, + "relationshipEvidence": { "utteranceId": "u3", "userPhrase": "relationship wording" } +} +``` + +Source and target remain in their original languages; no translated copies. +Assistance levels unchanged: L0 requires the relationship supplied by the user +under the existing L0 temporal/evidence contract; L1 may select/juxtapose +earlier user material as `ai_connected`; L2 may propose a novel relationship +as visibly `ai_suggested`. Merely understanding two languages does not +authorize a relationship. If a cross-language reference is too implicit for +code to verify, L0 asks for clarification — it does not silently translate or +infer. + +## 8. Translation as a separate response capability + +An explicit translation request is not a mirror: + +```ts +interface TranslationResponse { + kind: "translation"; + sourceEvidence: ReflectionEvidencePhrase[]; + targetLanguage: string; + translatedText: string; + provenance: "ai_translated"; +} +``` + +Product-oriented L0 may answer an explicit translation request provided it is +visibly labeled AI-translated; is not a grounded reflection; does not +automatically enter the SourceBank; does not become a card or relationship +without a separate user adoption action; does not replace the original in +model history. If the user later states or adopts the translated wording +through an explicit user action, that adoption creates new authoritative user +evidence — the translation itself remains AI-authored. Spontaneous translation +is generally avoided at all levels. + +(The donor's `translate.ts`/`translation-memory.ts` display overlay is +compatible with this — it is a *view*, not a response. The `TranslationResponse` +kind governs the coach channel; the overlay governs the read-only reader view.) + +### §8 product decisions — RATIFIED 2026-07-23 + +Checkpoint 4 ships as **two independently green slices**: + +**4a — coach translation response (first).** Exactly the `TranslationResponse` +contract above. Decided: + +- The request is **model-classified and schema-validated**. A direct + natural-language ask in chat ("translate that for me" / "把这个翻译成英文") + yields the labeled translation card. There is **no chat-side Translate + button** — the model decides the conversational move, code decides + validity, same as every other response kind. Misclassification is low-harm + by construction: the response is conversational only, visibly labeled, and + enters nothing. Add a Stage 4 precision scenario: a turn that *mentions* + translation without requesting one must not yield a translation response. +- Adoption needs no new UI in this slice: the user typing/saying the + translated wording themselves makes it authored. A dedicated "adopt this + wording" affordance is deferred polish. + +**4b — reader view (second).** Everything translated **including user +words**, therefore read-only (`translated_view` becomes reachable). The +visible language picker lives here — switching the whole screen is a mode +change and deserves a control; a per-utterance translation is a +conversational request and does not. Port the donor overlay. This slice owns +the two checkpoint-1 carry-forwards: gateway-level read-only enforcement + +user-visible rejection feedback, and the `source.json`/`zh.json` +"Enter to send" dedupe. + +**Control Room in the translated view — RATIFIED 2026-07-23.** The Control +Room is a *mixed* surface; do not blanket-translate it. Split by layer: + +- **Translate (it is chrome):** app-authored narration and labels — section + titles ("What mattered this turn"), causal-event titles ("Idea is ready to + reflect"), safety-check copy, buttons. Same category as the rest of the + interface; a reader gets it in their language so the panel is not + conspicuously untranslated. +- **Leave byte-for-byte (it is the audit layer, not presentation):** machine + codes (`lexical_grounding`, `reflection_validation_failed`, …) are + identifiers, not prose, and the honest-to-user / jargon-in-Control-Room + split already puts raw technical truth here; **evidence snippets** are the + writer's verbatim words shown *as proof* that an exact string grounded a + claim — translating one falsifies the audit (it would assert the validator + matched text it never saw) and violates the §8 rule that quoted user + wording is never translated; payloads are the literal record likewise. + +Rationale: reading surfaces (cards, chat, map, proposals) translate as +*presentation* for comprehension, kept honest by the read-only lock. The +Control Room's job is *fidelity*, not comprehension — the reader understands +the map through the translated cards, not through the Control Room, which is +the proof layer behind them. Proof you have translated is no longer proof. + +**Deferred nicety (not v1):** where a reader needs the meaning of a raw +snippet, show a translation as a *gloss beside* the verbatim (tooltip / +subtext), never replacing it — the same "label it, don't launder it" pattern +as the `ai_translated` card. + +**The read-only dividing line (philosophy, binding):** the lock follows +*whose words are displayed non-authoritatively*. Translating AI-authored +chrome or speech never requires a lock; the moment **user words** are +displayed in translation, the screen no longer shows the authoritative text +and every write path must refuse. + +**Considered and REJECTED — do not re-propose without new user evidence:** + +- A third "coach-language" mode (chrome + coach speech in a chosen language, + user words untouched, editable). Unnecessary: the coach already follows + the user's turn language natively (§3), so the normal screen is + monolingual with zero translation; the niche write-in-X-coached-in-Y case + is reachable via the conversational override, which the prompt already + honors. That override does not persist across reloads — recurring user + complaints about re-asking are the demand signal to wire the reserved + `preferredCoachLanguage` picker; build it then, not now. Note that even + then, quoted user evidence inside mirrors/recaps/verbatim options always + displays in the user's original language — a translated mirror is exactly + the masquerade this section forbids, and the §5 validator rejects it + mechanically. +- **Back-translating historical coach messages** (rejected outright, not + deferred): requires the runtime engine plus span-level care for embedded + verbatim user evidence, and buys retroactive transcript consistency that + human conversation does not have either. Switching languages mid-session + leaves earlier turns in their original language, like any real + conversation. + +## 9. Bound model context without weakening validation + +Do not send an indefinitely growing SourceBank every turn. Build the working +prompt from: the latest conversation turns; the complete latest user turn; +current user selection or draft focus; explicitly referenced cards; evidence +for active candidate memories; evidence for pending proposals; deliberately +recalled older evidence. + +Store the complete SourceBank locally and validate every returned evidence +reference against it. For a very long utterance: retain the complete original +locally; include it while directly relevant and within budget; have the model +cite smaller exact phrases; never silently treat a truncated prompt fragment +as the complete utterance. Add diagnostics showing which SourceBank entries +were included, omitted, or truncated. No semantic retrieval in this slice. + +## 10. Required tests + +Deterministic coverage, at minimum: + +- UI locale changes without authored-content mutation. +- Reload preserving original multilingual SourceBank entries byte-for-byte. +- English natural reflection validation. +- **[AMENDED]** Chinese natural reflection validation. +- **[AMENDED]** Mixed Chinese/English evidence in one utterance. +- Cross-language evidence across multiple utterances. +- Long utterances requiring precise evidence phrases. +- Evidence phrase absent from the claimed utterance. +- **[AMENDED]** Chinese→English (and reverse) paraphrase rejected as a mirror. +- **[AMENDED]** Full-/half-width punctuation and CJK-quote round-trip: model + quotes evidence with different punctuation width → still grounds. +- **[AMENDED]** Simplified/Traditional mismatch fails grounding (the decided + safe-fail behavior) and falls to the repair ladder. +- **[AMENDED]** `Intl.Segmenter` zh canary (full-ICU present in test env). +- Explicit translation accepted only as `ai_translated`. +- Translation excluded from SourceBank and map provenance. +- L0 cross-language unstated relationship rejection. +- L1 cross-language juxtaposition classified as `ai_connected`. +- L2 novel relationship classified as `ai_suggested`. +- Multilingual reflection repair → exact-wording reflection. +- Multilingual reflection repair → question. +- Existing maximum model-call guarantees hold. +- Mixed-language content surviving persistence and reload unchanged. +- Central read-only gating across every mutation path. +- Context budgeting retaining required evidence and reporting omissions. + +**[AMENDED — evals]** The Stage 4 harness manipulation checks must run in +Chinese as well as English (at minimum: first-pass grounded-mirror rate, +recovery-stage distribution, terminal failures, per language). Spanish +scenarios are out of scope for this pass. diff --git a/prototype-mindmap/docs/polish-sweeps-pass.md b/prototype-mindmap/docs/polish-sweeps-pass.md new file mode 100644 index 00000000..4bdd035f --- /dev/null +++ b/prototype-mindmap/docs/polish-sweeps-pass.md @@ -0,0 +1,173 @@ +# Polish sweeps — Codex parallel-work spec + +Status: **LANDED 2026-07-24 (implemented directly on `mindmap-main`, not via +Codex branches).** All three items committed and green (tsc / 335 Vitest / +build). B1 = `7b6d0c5`, B2 (with 6c finalize) = `07c89f4`, 6a = `aaaadcb`. +The one deferred check: 6a's **live-backend translation smoke** (needs `:8000` +up) — the dry-run passed, the fetch/write logic is donor-identical. The rest of +this doc is retained as the implementation record. (6b and the cache +eviction/purge were already done before this pass; see "Already done" below.) + +**SEQUENCING (do this first).** The 6c narration change is **uncommitted** in the +working tree and edits `src/i18n/source.json`, `src/i18n/zh.json`, and +`App.tsx`. Branch B2 below also edits all three (adds `"was AI-suggested"` to the +same two dict regions; edits `App.tsx`). **Commit 6c before Codex branches**, so +the polish branches fork from a tree that already contains 6c — otherwise B2 and +6c collide in the i18n arrays. Branch A also reads `source.json`; harmless, but +same reason to sequence after 6c lands. + +Verify from `prototype-mindmap/` (Windows): `npx.cmd tsc --noEmit`, +`npm.cmd test -- --run`, `npm.cmd run build`. Commit only after green. If the +pre-commit hook dies on `/usr/bin/env: 'sh'`, use +`git -c core.hooksPath=NUL commit ...` **after** the checks pass. Vitest may +print all-pass yet exit 1 with the `onTaskUpdate` timeout after the fuzz run — +that is the known IPC artifact, not a failure; say so if it happens. + +## Already done — do NOT re-implement (verified in code 2026-07-24) +- **6b provenance-grid layout** is fixed. `App.tsx` already carries + `.event-row.provenance-row { grid-template-columns: minmax(0, 1fr) auto; }` + and `.event-row.provenance-row .event-title { min-width: 0; }` (~lines + 2144–2146; styles are an inline ` +
+ {/* Chat panel */} +
+
+
+ +
+ + +
+ + {!ledgerAvailable &&
{t("Local audit storage is unavailable in this browser.")}
} + {reader.isTranslatedView && ( +
+ {t("Viewing a translation. Switch back to the original view to edit.")} + + {reader.rejection && ( +
+ {t("Switch back to the original view to edit.")} +
+ )} +
+ )} + +
+ {msgs.map((m) => ( +
+ + {m.role === "user" ? t("you") : m.role === "assistant" ? t("coach") : t("recovery")} + {m.role === "assistant" && } + {m.questionStance && ( + {t(m.questionStance)} + )} + +
+ {m.role === "application" && m.terminal === "repair_failed" ? t(m.text) : reader.translate(m.text)} +
+ {m.role === "application" && m.terminal === "repair_failed" && ( +
+ +
+ )} + {m.role === "user" && m.deliveryStatus === "failed" && ( +
+ +
+ )} + {m.role === "assistant" && m.questionAnchor && ( + + )} + {m.proposalId && proposals.has(m.proposalId) && (proposals.get(m.proposalId)!.detail.kind === "reflection" ? ( + void decideClaim(m.proposalId!, claimId, decision)} + onEdit={(claimId, text) => editMirrorClaim(m.proposalId!, claimId, text)} + /> + ) : ( + card.role !== "connection_label")} + onEdit={updateActionProposal} + onDecide={decideActionProposal} + /> + ))} +
+ ))} + {loading && (turnProgress || showWorking) && ( +
+ {t("coach")} +
+ {t(turnProgress ? TURN_PROGRESS_COPY[turnProgress] : "Working...")} +
+
+ )} +
+
+ +
+ {aiAccessDenied &&
{t("This account is not permitted to use AI features. Your draft and map remain available.")}
} + {error &&
{error}
} + {stickyDraftFocus && ( +
+ {t("Focusing on selected text:")} “{reader.translate(focusSummary(stickyDraftFocus.text))}” + +
+ )} +
+