diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml
index 121d7cc..125c020 100644
--- a/.github/workflows/deploy-pages.yml
+++ b/.github/workflows/deploy-pages.yml
@@ -3,6 +3,16 @@ name: Deploy Mindmap to GitHub Pages
on:
pull_request:
workflow_dispatch:
+ inputs:
+ app:
+ # The switch between the two apps. Rolling back = running this again with the
+ # other choice. Both use the same sign-in client and URL, so nothing else changes.
+ description: "Which app to deploy to mindmap.thoughtful-ai.com"
+ type: choice
+ options:
+ - current
+ - next
+ default: current
permissions:
contents: read
@@ -22,9 +32,17 @@ jobs:
with:
node-version: 24
cache: npm
+ cache-dependency-path: |
+ package-lock.json
+ next/package-lock.json
- run: npm ci
- - run: npm run build:pages
- - run: npm run verify:pages
+ # Pull requests build and verify both apps; a deployment builds only the chosen one.
+ - name: Build current app
+ if: ${{ github.event_name != 'workflow_dispatch' || inputs.app != 'next' }}
+ run: npm run build:pages && npm run verify:pages
+ - name: Build next app
+ if: ${{ github.event_name != 'workflow_dispatch' || inputs.app == 'next' }}
+ run: npm ci --prefix next && npm run build --prefix next && node scripts/verify-pages-build.mjs next
- name: Configure Pages
if: ${{ github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' }}
uses: actions/configure-pages@v5
@@ -32,7 +50,7 @@ jobs:
if: ${{ github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' }}
uses: actions/upload-pages-artifact@v4
with:
- path: dist
+ path: ${{ inputs.app == 'next' && 'next/dist' || 'dist' }}
deploy:
name: Deploy Pages artifact
diff --git a/docs/tools-first-pass.md b/docs/tools-first-pass.md
new file mode 100644
index 0000000..0230d2b
--- /dev/null
+++ b/docs/tools-first-pass.md
@@ -0,0 +1,289 @@
+# Tools-first pass: a fresh Mindmap where the rules live in the tool calls
+
+Status: spec for an experiment on branch `explore/tools-first`. Nothing here changes the
+live app. The new app is built from scratch in `next/`; the current app in `src/` stays
+as it is so the two can be compared.
+
+## 1. Why
+
+The current coach is boxed in step by step: a typed response format, a validator for
+visible text, a recovery ladder, turn-shape and sparse-map advisories, working memory.
+The question this pass tests:
+
+> If the model reasons freely, and only its **actions** are checked, at the moment it
+> attempts them, inside the tool that performs them, do we keep the design's guarantees
+> with far less machinery, and get a better coach?
+
+At its simplest the app is **a text chat that can change a map through tools, with or
+without restrictions depending on the level**.
+
+The model works the way any agent with tools works: it reasons, attempts an action, and
+if the action is refused it sees why and decides what to do next (try differently, ask
+the writer, or explain what it tried). Nothing walks it through the rules step by step;
+the rules are stated, and enforced only when an action executes.
+
+## 2. Decisions
+
+| Topic | Decision |
+|---|---|
+| Where checks live | In each tool's handler, when the tool executes. Schemas carry shape only (types, required fields). No separate gateway or validator stage. |
+| Interpretation | Handlers compare text ("is this inside that?", "does this come before that?"). They never decide what text means. No regexes or word lists for meaning. |
+| Who writes card text | The model. The handler checks it against the writer's words. |
+| What the model sees | The full conversation, plus the current map, draft, level and budget. No separate "source bank" view, no segment IDs. |
+| What the handler checks against | A record of the writer's own messages exactly as sent, kept by the app. Never the model's own messages. |
+| Partial quotes | Allowed. The writer's confirmation is the safety net. No negation check. |
+| Map edits by the model | Every one is a tool call, and every result is a **proposal**. Nothing reaches the map without the writer's click. Several proposals per turn are fine. |
+| Visibility | Every tool call shows in the chat with its result. Refusals show a reason the writer can expand. |
+| Refusals | Say truthfully what failed. They don't tell the model what to do instead. |
+| Attempt budget | At most 3 refused attempts per turn. The count is in every refusal. After the third, tools are removed and the model replies in text only. |
+| `suggest_idea` below L2 | Offered, always refused, so the model learns the boundary and draws the idea out of the writer instead. The instructions and the tool description also say up front that it is refused (Nhyira, 10-28): the prompt tells the model it won't work, so it doesn't waste an attempt, and the handler's refusal stays as the real check in case it tries anyway or ignores the prompt. So the coach declining in words at L0 without trying the tool is expected. |
+| Memory between calls | None. Every call is stateless and sends everything (section 7), ordered so the provider's prompt cache covers the repeated part. No provider-side conversation storage. |
+| Suggestion adoption | Behind an on/off setting. Measured by shared runs of three or more words, not today's common-word list (see §9). The list is the fallback if runs prove problematic. |
+| Out of v1 | i18n, a restating tool, working memory, the fuzz harness (after v1), L3 (after v1), the Think/Map slider, steering buttons, voice, Control Room diagnostics, sign-in and deploy. |
+
+## 3. Levels
+
+| | L0 | L1 | L2 | L3 (planned, after v1) |
+|---|---|---|---|---|
+| Model can read | chat + draft | chat + draft | chat + draft | chat + draft |
+| Card text may come from | writer's chat messages | chat + draft | chat + draft | anything |
+| Links and nesting | only if the writer stated them (section 5.2) | same as L0 | proposed freely | applied freely |
+| Coach's own ideas (`suggest_idea`) | refused | refused | proposed, labelled | allowed |
+| Checks in handlers | on | on | on (graph integrity only for links and nesting) | off |
+
+At L0 the model **sees** the draft but cannot quote it on the map.
+
+L3 exists for a study comparing writers' sense of ownership with and without
+restrictions. It is the same tools with handler checks switched off. Build v1 so each
+handler takes `{ checks: boolean }`; nothing else is needed now. Whether L3 shows
+provenance labels is a study-design question for later.
+
+## 4. App state (owned by code, not the model)
+
+- **Writer record:** every message the writer sends, exactly as typed, with its turn
+ number; plus the current draft. This is what handlers check against. The model's own
+ messages are never in it.
+- **Map:** cards `{ id, text, parentId, provenance, sources }` and links
+ `{ id, from, to, label, provenance }`. The writer can add, edit, drag, delete, nest and
+ undo directly; those are immediate (they are the writer's own actions). Once the
+ writer changes a quoted card's words, it drops its sources and its `From …` label
+ (the text is now their own edit); an AI suggestion keeps its label, and adoption
+ tracking covers it. Undo also restores proposal states, so undoing an Accept makes
+ that proposal pending again and the model is told. A `+ Card` left empty is removed.
+- **Proposals:** pending `{ id, kind: card | link | nest | suggestion, ... }`, each
+ accepted or rejected by the writer. A link or nest that refers to a pending card is
+ dropped if that card is rejected.
+- **Level** (fixed for the length of a turn) and **settings** (`suggestionAdoption`).
+- **Provenance is set by code from what the handler found**, never passed by the model.
+
+Saved in the browser, like today.
+
+## 5. Tools
+
+All tools return a result to the model:
+
+```json
+{ "ok": true, "proposal": "p5", "shown_as": "card: \"X is bad\"" }
+{ "ok": false, "reason": "\"X is good\" is not in your messages", "attempts_left": 2 }
+```
+
+### Matching rule (used by every check)
+
+"Verbatim" means a plain substring match after mechanical normalization: Unicode NFC,
+lower-case, whitespace runs collapsed to one space, and straight/curly quotes treated
+alike. No other transformation.
+
+When a piece matches in more than one message, record the most recent match, and all
+matches. Each accepted card keeps these `sources` (piece, most recent turn, every turn,
+whether the draft also has it).
+
+### 5.1 `propose_card`
+
+```json
+{ "pieces": ["string", "..."] }
+```
+
+- **Card text** is the pieces in the model's order, joined with a single space. Code adds
+ no words.
+- **Checks (L0–L2):** every piece is non-empty and matches verbatim in the eligible text:
+ the writer's chat messages at L0; chat plus the current draft at L1 and L2.
+- **Provenance:** one source → the writer's own (no badge). More than one source → label
+ `From 2 messages` (`From N messages`). Proposed for any piece from the draft: `From your
+ draft`; confirm the wording when building.
+- Refusal example: `"X is good" is not in your messages`.
+- **Typos (Nhyira, 10-28):** pieces are quoted exactly as typed, typos included; the
+ model doesn't correct spelling, and the writer can fix it on the card after accepting
+ (which, as any edit, drops the `From …` label). Pieces start and end on whole words.
+ When the words are there but only inside longer words (a typo like "lik ethe" glues
+ "the" to a letter), the refusal says so and shows the stretch they sit in, e.g.
+ `"the creative work intersection" is in your messages only inside longer words ("ethe
+ creative work intersection")`, so the model can retry with whole words. Tolerating
+ near-misses in the check was rejected: one letter is how "not" becomes "now".
+
+### 5.2 `propose_link`
+
+```json
+{ "from_card": "c1", "to_card": "p5", "label": "because", "evidence": ["string", "..."] }
+```
+
+`from_card` and `to_card` may be existing cards or card proposals from this turn.
+
+- **Always (L0–L2):** both cards exist (or are pending), they differ, and no identical
+ link exists.
+- **L0 and L1, the evidence rule:**
+ 1. `evidence` has one or two pieces, each verbatim in the writer's eligible messages.
+ 2. Two pieces must come from **two adjacent writer messages** (only the coach's turn
+ between them), in order.
+ 3. Both cards' text appears inside the evidence.
+ 4. The label appears inside the evidence, **after** the `from` card's text and
+ **before** the `to` card's text.
+
+ Example: messages "I like X but I'm not sure", coach "why?", "because X is bad".
+ Evidence `["I like X", "because X is bad"]`, label `because`, cards "I like X" and
+ "X is bad" all pass. It's adjacency, not interpretation.
+- **L2:** no evidence needed; the label is free; provenance `AI suggested`.
+
+### 5.3 `propose_nest`
+
+```json
+{ "child": "c2", "parent": "c1", "evidence": ["string", "..."] }
+```
+
+- **Always:** both exist, differ, and nesting would not create a cycle.
+- **L0 and L1:** the same evidence rule as links, steps 1–3 (no label).
+- **L2:** no evidence needed. An accepted L2 nesting keeps an `AI suggested` mark on the
+ child (like an L2 link) until the writer re-nests the card themselves.
+
+### 5.4 `suggest_idea`
+
+```json
+{ "text": "string", "near_card": "c1" }
+```
+
+- **L0 and L1:** always refused: `suggest_idea isn't available at L0`.
+- **L2:** creates a suggestion proposal; provenance `AI suggestion`.
+
+### Known gaps (documented, covered by the writer's confirmation)
+
+- A partial piece can drop a "not" and reverse the meaning.
+- Both cards appearing in the evidence doesn't prove the evidence relates them ("I like X
+ and I hate Y").
+- For nesting, which card is the parent isn't checked.
+- If the writer used "it" instead of repeating a card's words, a link can't be proven, so
+ the coach has to ask.
+- A card the writer has edited no longer matches their earlier words, so it can't be
+ linked by evidence until they state the link again.
+- Plain chat text is unchecked: the coach could paraphrase the writer in chat. Watch for
+ it in v1; a restating tool is the v2 option if it happens often.
+
+## 6. The tool loop and budget
+
+1. The writer sends a message. Code records it.
+2. Call the model with the full context (section 7) and the tools for this level.
+3. For each tool call: run the handler, create a proposal or a refusal, and append the
+ call and its result to the turn.
+4. Refusals count against the budget. Each refusal includes `attempts_left`. The budget is
+ checked per call, so once it is spent, any further calls in the same batch are refused
+ without running.
+5. Call the model again with the results, until it replies with text and no tool calls.
+6. When `attempts_left` reaches 0, the next call is made with `tool_choice: "none"`, so
+ the reply is text only.
+7. Safety cap: no more than 8 model calls in one turn, whatever happens.
+
+The prompt permits honesty about refused actions. Example of the behaviour this is meant
+to allow (not scripted): *"I tried to put something you said on the map, but I couldn't
+tie it to your words. What do you want that point to be?"*
+
+## 7. What the model sees on every call
+
+Stateless: everything is sent every time, in this order (static first, so the provider's
+prompt cache covers it):
+
+1. **Instructions:** role (a thinking coach; the writer owns the map), this level's rules
+ in plain words, the budget, permission to be honest about refusals, and a few
+ conversation notes (e.g. "if the writer declined something, don't raise it again").
+2. **Tool definitions:** name, a description that states the rule its handler enforces,
+ and the fields.
+3. **The conversation so far:** writer messages, coach replies, and earlier tool calls
+ with their results, refusals included. For very long chats, the oldest messages are
+ left out of what the model sees (only the last 40 writer turns are sent, cut where a
+ writer message starts so a tool call is never separated from its result); the writer
+ record keeps everything, so old quotes still check, and the state block says how many
+ messages are hidden.
+4. **Current state, rebuilt each call:** the map (cards and links with IDs and short
+ provenance), pending proposals, outcomes of the writer's decisions since the last
+ call ("accepted p3, now card c7", "rejected p4"), everything the writer has declined
+ so far (kept for the whole session, so a declined idea isn't raised again), the
+ draft, and `level · attempts left`. It is sent as a developer message, since it comes
+ from the app and not the writer.
+5. **The writer's new message.**
+
+API: OpenAI Responses through the Writing Tools proxy (`/api/openai/responses`), the
+same model setting as today (`VITE_MINDMAP_MODEL`). Local development uses the backend's
+sessionless path, so v1 needs no sign-in.
+
+## 8. What the writer sees
+
+- **Chat**, with each tool call as one line: tool name, a one-line summary, and the
+ result. Proposals have **Accept** and **Reject**; refusals have a collapsed **why**:
+
+ ```
+ ✕ suggest_idea · refused ▸ why
+ ✓ propose_card · "X is bad" [Accept] [Reject]
+ ✓ propose_link · I like X → because → X is bad [Accept] [Reject]
+ That sounds like the reason behind your doubt…
+ ```
+
+- **Map:** accepted proposals appear; pending ones are not on the map.
+- **Level picker:** a change applies from the next turn.
+- **Draft panel:** a box to paste the draft into.
+- **Copy map** and **copy draft** out.
+
+Keep it simple: no extra panels or diagnostics in v1.
+
+## 9. v1 scope and build order
+
+1. **Core loop with plain UI:** chat, writer record, the four tools and handlers, the
+ budget, proposals with accept and reject, the map with direct editing and undo, level
+ picker, draft panel, browser saving, copy out.
+2. **Suggestion adoption** behind its setting: a card the writer creates or edits is
+ adopted from an L2 suggestion when at least half its words sit in runs of three or
+ more words, in order, shared with that suggestion (shorter cards: the whole card
+ must appear). Only a card whose text just changed can newly adopt; later edits
+ update current and peak overlap. Same behaviour as `src/map/suggestion-adoption.ts`
+ except the overlap measure, which uses no word list.
+3. **Tests for every handler rule** in sections 5.1–5.4, including each refusal reason,
+ adjacency, label position, cycles, the budget and the 8-call cap.
+4. **Try it in real conversations** at each level, including the "I like X… because X
+ is bad" case and a long rambling message.
+
+After v1: port the adversarial fuzz harness to these tools; match the current app's
+look by screenshot comparison; L3; sign-in (copy `src/platform/` as is) and deploy;
+compare with the current app (code size, behaviour, how the coach feels) and decide.
+
+## Deploying: one switch, same rollback
+
+The Pages workflow ("Deploy Mindmap to GitHub Pages", run by hand from `main`) has an
+**app** choice: `current` (the default, the app in `src/`) or `next` (this app). Both
+deploy to mindmap.thoughtful-ai.com and use the same Writing Tools OAuth client,
+redirect URI and session storage key, so switching needs no backend or Helm change and
+users stay signed in. Their saved work lives under different browser storage keys per
+app, so switching back restores each app's own sessions. **Rollback = run the workflow
+again with `current`.** Sign-in in `next/` is `src/platform/platform-session.ts` copied as
+is plus a lean `next/src/platform/Bootstrap.tsx`. Once `next` is settled, swap the
+folders (Ken's suggestion on #6), delete the old app and drop the choice.
+
+## 10. Layout
+
+```
+next/
+ package.json, vite.config.ts, tsconfig.json own app, own scripts
+ src/
+ state/ writer record, map, proposals, settings, persistence
+ tools/ one file per tool: schema, description, handler, tests
+ loop/ context builder (section 7), tool loop and budget (section 6)
+ ui/ chat, tool lines, map, level picker, draft panel
+```
+
+Merge `origin/main` into this branch whenever useful; `next/` never conflicts with
+`src/`.
diff --git a/next/.env.production b/next/.env.production
new file mode 100644
index 0000000..0a3b754
--- /dev/null
+++ b/next/.env.production
@@ -0,0 +1,2 @@
+VITE_BACKEND_URL=https://app.thoughtful-ai.com/api
+VITE_OAUTH_CLIENT_ID=writing-tools-mindmap
diff --git a/next/TESTING.md b/next/TESTING.md
new file mode 100644
index 0000000..f29facd
--- /dev/null
+++ b/next/TESTING.md
@@ -0,0 +1,41 @@
+# Trying the tools-first Mindmap locally
+
+For colleagues who already have the Writing Tools repo with its backend set up
+(dependencies installed, `OPENAI_API_KEY` in `backend/.env`). Calls are paid by that key.
+
+## 1. Start your Writing Tools backend with sign-in off
+
+From inside `writing-tools/backend` (it loads `.env` from the folder it starts in):
+
+```powershell
+$env:BETTER_AUTH_ENABLED='false'; npx tsx src/index.ts
+```
+
+On macOS/Linux: `BETTER_AUTH_ENABLED=false npx tsx src/index.ts`
+
+It must be on port 8000, and your checkout must be from 2026-07-17 or later (that is
+when `/api/openai/responses` was added; `git pull` if unsure). If something else is
+already on port 8000, such as an old backend, stop it first, or the app will talk to it.
+
+## 2. Run the app
+
+```bash
+git clone https://github.com/AIToolsLab/mindmap
+cd mindmap
+git checkout explore/tools-first
+cd next
+npm ci
+npm run dev
+```
+
+Open http://localhost:5183.
+
+## If something's off
+
+- **"Couldn't reach the coach"** in the chat: the backend isn't running on port 8000, or
+ sign-in is on.
+- **A model error:** your key may not have access to the default model. Choose another
+ with `VITE_MINDMAP_MODEL` (e.g. `$env:VITE_MINDMAP_MODEL='gpt-5'; npm run dev`).
+- Your chat, map and draft are saved only in your browser. "New session" clears them.
+
+What this app is and what it tests: `../docs/tools-first-pass.md`.
diff --git a/next/index.html b/next/index.html
new file mode 100644
index 0000000..27eb0a3
--- /dev/null
+++ b/next/index.html
@@ -0,0 +1,12 @@
+
+
+
Sign in to use Writing Tools AI. Your chat, map and draft stay in this browser.
+ {boot.detail &&
{boot.detail}
}
+
+
+ );
+ }
+ return (
+ signOut() : undefined}
+ />
+ );
+}
diff --git a/next/src/platform/platform-session.test.ts b/next/src/platform/platform-session.test.ts
new file mode 100644
index 0000000..92e8de2
--- /dev/null
+++ b/next/src/platform/platform-session.test.ts
@@ -0,0 +1,230 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+ OAUTH_SCOPE,
+ OAUTH_SESSION_STORAGE_KEY,
+ OAUTH_TRANSACTION_MAX_AGE_MS,
+ OAUTH_TRANSACTION_STORAGE_KEY,
+ canonicalOAuthResource,
+ clearOAuthSession,
+ consumeOAuthTransaction,
+ createAuthorizationRequest,
+ exchangeAuthorizationCode,
+ isCompactJwt,
+ loginRequired,
+ oauthCallbackFromSearch,
+ oauthRedirectUri,
+ readOAuthSession,
+ resolveBackendUrl,
+ resolveOAuthClientId,
+ scrubOAuthCallbackFromUrl,
+ writeOAuthSession,
+ type OAuthTransaction,
+} from "./platform-session";
+
+function memoryStorage(): Storage {
+ const values = new Map();
+ return {
+ get length() { return values.size; },
+ clear: () => values.clear(),
+ getItem: (key) => values.get(key) ?? null,
+ key: (index) => [...values.keys()][index] ?? null,
+ removeItem: (key) => { values.delete(key); },
+ setItem: (key, value) => { values.set(key, value); },
+ };
+}
+
+// btoa instead of Node's Buffer: next/ type-checks against browser types only.
+function base64Url(value: unknown): string {
+ return btoa(JSON.stringify(value)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
+}
+
+function jwt(exp = Math.floor(Date.now() / 1000) + 43_200): string {
+ return `${base64Url({ alg: "EdDSA", typ: "JWT" })}.${base64Url({ exp })}.signature`;
+}
+
+function response(body: unknown, status = 200): Response {
+ return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
+}
+
+const transaction: OAuthTransaction = {
+ version: 1,
+ state: "state",
+ verifier: "v".repeat(64),
+ redirectUri: "https://mindmap.example/",
+ resource: "https://app.example",
+ createdAt: Date.now(),
+};
+
+describe("standalone OAuth session", () => {
+ it("fails closed in production while keeping development defaults", () => {
+ expect(resolveBackendUrl({ PROD: false })).toBe("http://localhost:8000/api");
+ expect(resolveOAuthClientId({ PROD: false })).toBe("writing-tools-mindmap");
+ expect(() => resolveBackendUrl({ PROD: true })).toThrow("VITE_BACKEND_URL");
+ expect(() => resolveOAuthClientId({ PROD: true })).toThrow("VITE_OAUTH_CLIENT_ID");
+ });
+
+ it("refuses a non-HTTPS backend in production but allows http://localhost in dev", () => {
+ for (const url of ["http://insecure.example/api", "not a url", "ftp://app.example/api"]) {
+ expect(() => resolveBackendUrl({ PROD: true, VITE_BACKEND_URL: url }), url).toThrow("https://");
+ }
+ expect(resolveBackendUrl({ PROD: true, VITE_BACKEND_URL: "https://app.example/api/" }))
+ .toBe("https://app.example/api");
+ expect(resolveBackendUrl({ PROD: false, VITE_BACKEND_URL: "http://localhost:8000/api" }))
+ .toBe("http://localhost:8000/api");
+ });
+
+ it("rejects abandoned or future-dated login transactions", () => {
+ const storage = memoryStorage();
+ const now = 1_000_000_000;
+ const store = (createdAt: number) =>
+ storage.setItem(OAUTH_TRANSACTION_STORAGE_KEY, JSON.stringify({ ...transaction, createdAt }));
+ store(now - 60_000);
+ expect(consumeOAuthTransaction(storage, now)).not.toBeNull();
+ store(now - OAUTH_TRANSACTION_MAX_AGE_MS - 1);
+ expect(consumeOAuthTransaction(storage, now)).toBeNull();
+ expect(storage.getItem(OAUTH_TRANSACTION_STORAGE_KEY)).toBeNull();
+ store(now + 5 * 60_000);
+ expect(consumeOAuthTransaction(storage, now)).toBeNull();
+ });
+
+ it("treats any authorization parameter as callback material", () => {
+ expect(oauthCallbackFromSearch("?view=map")).toBeNull();
+ expect(oauthCallbackFromSearch("?state=s")).toEqual({ state: "s" });
+ expect(oauthCallbackFromSearch("?error_description=x")).toEqual({ errorDescription: "x" });
+ });
+
+ it("keeps the earliest expiry when the JWT and expires_in disagree", async () => {
+ const now = 1_000_000_000_000;
+ const lateJwt = jwt(Math.floor(now / 1000) + 86_400);
+ const session = await exchangeAuthorizationCode("code", transaction, {
+ fetcher: vi.fn(async () => response({
+ access_token: lateJwt,
+ token_type: "Bearer",
+ scope: OAUTH_SCOPE,
+ expires_in: 3600,
+ })) as unknown as typeof fetch,
+ backendUrl: "https://app.example/api",
+ clientId: "client",
+ now: () => now,
+ });
+ expect(session.expiresAt).toBe(now + 3_600_000);
+
+ const storage = memoryStorage();
+ writeOAuthSession(storage, { version: 1, accessToken: lateJwt, expiresAt: now + 3_600_000 });
+ expect(readOAuthSession(storage, now + 3_600_001)).toBeNull();
+ });
+
+ it("requires login unconditionally in production and only by opt-in in development", () => {
+ expect(loginRequired({ PROD: true, VITE_REQUIRE_LOGIN: "false" })).toBe(true);
+ expect(loginRequired({ PROD: false })).toBe(false);
+ expect(loginRequired({ PROD: false, VITE_REQUIRE_LOGIN: "true" })).toBe(true);
+ });
+
+ it("derives the byte-exact origin resource and callback", () => {
+ expect(canonicalOAuthResource("https://APP.Example/api/")).toBe("https://app.example");
+ expect(oauthRedirectUri({ origin: "https://mindmap.example", pathname: "/" })).toBe("https://mindmap.example/");
+ });
+
+ it("creates S256 authorization state and persists the transaction before redirect", async () => {
+ const storage = memoryStorage();
+ const request = await createAuthorizationRequest({
+ storage,
+ location: { origin: "https://mindmap.example", pathname: "/" },
+ backendUrl: "https://app.example/api",
+ clientId: "writing-tools-mindmap",
+ now: () => 10,
+ });
+ const url = new URL(request.url);
+ expect(url.pathname).toBe("/api/auth/oauth2/authorize");
+ expect(url.searchParams.get("client_id")).toBe("writing-tools-mindmap");
+ expect(url.searchParams.get("redirect_uri")).toBe("https://mindmap.example/");
+ expect(url.searchParams.get("scope")).toBe(OAUTH_SCOPE);
+ expect(url.searchParams.get("resource")).toBe("https://app.example");
+ expect(url.searchParams.get("code_challenge_method")).toBe("S256");
+ expect(url.searchParams.get("state")).toBe(request.transaction.state);
+ expect(JSON.parse(storage.getItem(OAUTH_TRANSACTION_STORAGE_KEY)!)).toEqual(request.transaction);
+ });
+
+ it("captures and scrubs callback material while preserving unrelated query and hash values", () => {
+ expect(oauthCallbackFromSearch("?view=map&code=c&state=s")).toEqual({ code: "c", state: "s" });
+ const replaceState = vi.fn();
+ scrubOAuthCallbackFromUrl(
+ { pathname: "/", search: "?view=map&code=c&state=s&error_description=no", hash: "#draft" },
+ { state: { keep: true }, replaceState },
+ );
+ expect(replaceState).toHaveBeenCalledWith({ keep: true }, "", "/?view=map#draft");
+ });
+
+ it("consumes authorization state exactly once", () => {
+ const storage = memoryStorage();
+ storage.setItem(OAUTH_TRANSACTION_STORAGE_KEY, JSON.stringify(transaction));
+ expect(consumeOAuthTransaction(storage)).toEqual(transaction);
+ expect(consumeOAuthTransaction(storage)).toBeNull();
+ });
+
+ it("accepts only unexpired compact JWT sessions and clears OAuth state without local work", () => {
+ const storage = memoryStorage();
+ const session = { version: 1 as const, accessToken: jwt(200), expiresAt: 200_000 };
+ writeOAuthSession(storage, session);
+ expect(readOAuthSession(storage, 100_000)).toEqual(session);
+ expect(readOAuthSession(storage, 201_000)).toBeNull();
+ expect(isCompactJwt("not-a-token")).toBe(false);
+ storage.setItem(OAUTH_TRANSACTION_STORAGE_KEY, JSON.stringify(transaction));
+ clearOAuthSession(storage);
+ expect(storage.getItem(OAUTH_SESSION_STORAGE_KEY)).toBeNull();
+ expect(storage.getItem(OAUTH_TRANSACTION_STORAGE_KEY)).toBeNull();
+ });
+
+ it("exchanges with omitted credentials and repeats the exact resource", async () => {
+ const fetcher = vi.fn().mockResolvedValue(response({
+ access_token: jwt(44_000),
+ token_type: "Bearer",
+ scope: OAUTH_SCOPE,
+ expires_in: 43_200,
+ }));
+ const session = await exchangeAuthorizationCode("code", transaction, {
+ fetcher,
+ backendUrl: "https://app.example/api",
+ clientId: "writing-tools-mindmap",
+ now: () => 1_000,
+ });
+ expect(session.accessToken).toContain(".");
+ const [, init] = fetcher.mock.calls[0]!;
+ expect(init).toMatchObject({ method: "POST", credentials: "omit" });
+ const form = init.body as URLSearchParams;
+ expect(form.get("resource")).toBe("https://app.example");
+ expect(form.get("redirect_uri")).toBe("https://mindmap.example/");
+ expect(form.get("code_verifier")).toBe(transaction.verifier);
+ });
+
+ it.each([
+ { token_type: "bearer", scope: OAUTH_SCOPE, expires_in: 43_200, access_token: jwt() },
+ { token_type: "Bearer", scope: "doc:read", expires_in: 43_200, access_token: jwt() },
+ { token_type: "Bearer", scope: OAUTH_SCOPE, expires_in: 86_401, access_token: jwt() },
+ { token_type: "Bearer", scope: OAUTH_SCOPE, expires_in: 0, access_token: jwt() },
+ { token_type: "Bearer", scope: OAUTH_SCOPE, expires_in: 43_200, access_token: "opaque" },
+ ])("rejects a token response outside the Track A contract", async (body) => {
+ await expect(exchangeAuthorizationCode("code", transaction, {
+ fetcher: vi.fn().mockResolvedValue(response(body)),
+ })).rejects.toMatchObject({ code: "invalid" });
+ });
+
+ it("accepts a shorter server-selected access-token lifetime", async () => {
+ const accessToken = jwt(3_700);
+ await expect(exchangeAuthorizationCode("code", transaction, {
+ fetcher: vi.fn().mockResolvedValue(response({
+ token_type: "Bearer", scope: OAUTH_SCOPE, expires_in: 3_600, access_token: accessToken,
+ })),
+ now: () => 100_000,
+ })).resolves.toMatchObject({ accessToken });
+ });
+
+ it("rejects a correctly shaped token that is already expired", async () => {
+ await expect(exchangeAuthorizationCode("code", transaction, {
+ fetcher: vi.fn().mockResolvedValue(response({
+ token_type: "Bearer", scope: OAUTH_SCOPE, expires_in: 43_200, access_token: jwt(1),
+ })),
+ now: () => 2_000,
+ })).rejects.toMatchObject({ code: "invalid" });
+ });
+});
diff --git a/next/src/platform/platform-session.ts b/next/src/platform/platform-session.ts
new file mode 100644
index 0000000..bee60e1
--- /dev/null
+++ b/next/src/platform/platform-session.ts
@@ -0,0 +1,349 @@
+///
+
+export const OAUTH_SESSION_STORAGE_KEY = "prototype-mindmap-oauth-session-v1";
+export const OAUTH_TRANSACTION_STORAGE_KEY = "prototype-mindmap-oauth-transaction-v1";
+export const OAUTH_SESSION_VERSION = 1;
+export const OAUTH_CLIENT_ID_DEFAULT = "writing-tools-mindmap";
+export const OAUTH_SCOPE = "openai:chat";
+
+const viteEnv = (import.meta as ImportMeta & { env?: Record }).env;
+
+type PlatformEnv = {
+ PROD?: boolean;
+ VITE_BACKEND_URL?: string | boolean;
+ VITE_OAUTH_CLIENT_ID?: string | boolean;
+ VITE_REQUIRE_LOGIN?: string | boolean;
+};
+
+/**
+ * A production bundle that quietly falls back to localhost points every user's browser
+ * at their own machine — a confusing per-request failure rather than an obvious one.
+ * Fail at module load so a misconfigured deploy is caught by the first smoke check.
+ */
+export function resolveBackendUrl(env: PlatformEnv = viteEnv ?? {}): string {
+ const configured = typeof env.VITE_BACKEND_URL === "string" ? env.VITE_BACKEND_URL.trim() : "";
+ if (configured) {
+ // The bearer token and every AI request travel to this URL; production must not
+ // ship a bundle that sends them in cleartext. (Dev keeps http://localhost.)
+ if (env.PROD === true && !isHttpsUrl(configured)) {
+ throw new Error("VITE_BACKEND_URL must be an https:// URL for production builds of the mindmap.");
+ }
+ return configured.replace(/\/+$/, "");
+ }
+ if (env.PROD === true) {
+ throw new Error("VITE_BACKEND_URL must be set for production builds of the mindmap.");
+ }
+ return "http://localhost:8000/api";
+}
+
+function isHttpsUrl(value: string): boolean {
+ try {
+ return new URL(value).protocol === "https:";
+ } catch {
+ return false;
+ }
+}
+
+export function resolveOAuthClientId(env: PlatformEnv = viteEnv ?? {}): string {
+ const configured = typeof env.VITE_OAUTH_CLIENT_ID === "string"
+ ? env.VITE_OAUTH_CLIENT_ID.trim()
+ : "";
+ if (configured) return configured;
+ if (env.PROD === true) {
+ throw new Error("VITE_OAUTH_CLIENT_ID must be set for production builds of the mindmap.");
+ }
+ return OAUTH_CLIENT_ID_DEFAULT;
+}
+
+export function canonicalOAuthResource(backendUrl: string): string {
+ return new URL(backendUrl).origin;
+}
+
+export const PLATFORM_BACKEND_URL = resolveBackendUrl();
+export const OAUTH_CLIENT_ID = resolveOAuthClientId();
+export const OAUTH_RESOURCE = canonicalOAuthResource(PLATFORM_BACKEND_URL);
+
+export function loginRequired(env: PlatformEnv = viteEnv ?? {}): boolean {
+ // Production always requires login, and `VITE_REQUIRE_LOGIN=false` cannot turn
+ // that off. The override exists only for dev servers and Playwright; honouring it
+ // in a PROD build would let one stray env var ship an ungated bundle, with nothing
+ // at runtime to signal that the gate was disabled.
+ if (env.PROD === true) return true;
+ return env.VITE_REQUIRE_LOGIN === "true";
+}
+
+export interface OAuthSession {
+ version: 1;
+ accessToken: string;
+ expiresAt: number;
+}
+
+export interface OAuthTransaction {
+ version: 1;
+ state: string;
+ verifier: string;
+ redirectUri: string;
+ resource: string;
+ createdAt: number;
+}
+
+export interface OAuthCallback {
+ code?: string;
+ state?: string;
+ error?: string;
+ errorDescription?: string;
+}
+
+export type OAuthFlowErrorCode = "invalid" | "denied" | "network";
+
+export class OAuthFlowError extends Error {
+ constructor(readonly code: OAuthFlowErrorCode, message: string) {
+ super(message);
+ this.name = "OAuthFlowError";
+ }
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+export function isCompactJwt(value: unknown): value is string {
+ if (typeof value !== "string") return false;
+ const segments = value.split(".");
+ return segments.length === 3 && segments.every((segment) => /^[A-Za-z0-9_-]+$/.test(segment));
+}
+
+function decodeJwtExpiry(accessToken: string): number | null {
+ try {
+ const payload = accessToken.split(".")[1];
+ if (!payload) return null;
+ const normalized = payload.replace(/-/g, "+").replace(/_/g, "/");
+ const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
+ const parsed = JSON.parse(atob(padded)) as unknown;
+ if (!isRecord(parsed) || typeof parsed.exp !== "number" || !Number.isFinite(parsed.exp)) return null;
+ return parsed.exp * 1000;
+ } catch {
+ return null;
+ }
+}
+
+/** The token is honoured only until the earliest expiry any source reports. */
+function earliest(...expiries: Array): number | null {
+ const known = expiries.filter((value): value is number => value !== null);
+ return known.length ? Math.min(...known) : null;
+}
+
+export function readOAuthSession(
+ storage: Pick,
+ now: number = Date.now(),
+): OAuthSession | null {
+ try {
+ const parsed = JSON.parse(storage.getItem(OAUTH_SESSION_STORAGE_KEY) ?? "null") as unknown;
+ if (!isRecord(parsed) || parsed.version !== OAUTH_SESSION_VERSION || !isCompactJwt(parsed.accessToken)) {
+ return null;
+ }
+ const storedExpiry = typeof parsed.expiresAt === "number" && Number.isFinite(parsed.expiresAt)
+ ? parsed.expiresAt
+ : null;
+ const expiry = earliest(decodeJwtExpiry(parsed.accessToken), storedExpiry);
+ if (expiry === null || expiry <= now) return null;
+ return { version: 1, accessToken: parsed.accessToken, expiresAt: expiry };
+ } catch {
+ return null;
+ }
+}
+
+export function writeOAuthSession(storage: Pick, session: OAuthSession): void {
+ storage.setItem(OAUTH_SESSION_STORAGE_KEY, JSON.stringify(session));
+}
+
+export function clearOAuthSession(storage: Pick): void {
+ storage.removeItem(OAUTH_SESSION_STORAGE_KEY);
+ storage.removeItem(OAUTH_TRANSACTION_STORAGE_KEY);
+}
+
+/**
+ * A login round trip takes seconds. A transaction older than this was abandoned, and
+ * its state/verifier should not be able to complete a login long after the fact.
+ */
+export const OAUTH_TRANSACTION_MAX_AGE_MS = 10 * 60 * 1000;
+
+export function readOAuthTransaction(
+ storage: Pick,
+ now: number = Date.now(),
+): OAuthTransaction | null {
+ try {
+ const parsed = JSON.parse(storage.getItem(OAUTH_TRANSACTION_STORAGE_KEY) ?? "null") as unknown;
+ if (
+ !isRecord(parsed) ||
+ parsed.version !== 1 ||
+ typeof parsed.state !== "string" || !parsed.state ||
+ typeof parsed.verifier !== "string" || parsed.verifier.length < 43 ||
+ typeof parsed.redirectUri !== "string" ||
+ typeof parsed.resource !== "string" ||
+ typeof parsed.createdAt !== "number" || !Number.isFinite(parsed.createdAt) ||
+ now - parsed.createdAt > OAUTH_TRANSACTION_MAX_AGE_MS ||
+ parsed.createdAt > now + 60_000
+ ) return null;
+ return parsed as unknown as OAuthTransaction;
+ } catch {
+ return null;
+ }
+}
+
+export function writeOAuthTransaction(
+ storage: Pick,
+ transaction: OAuthTransaction,
+): void {
+ storage.setItem(OAUTH_TRANSACTION_STORAGE_KEY, JSON.stringify(transaction));
+}
+
+export function consumeOAuthTransaction(
+ storage: Pick,
+ now: number = Date.now(),
+): OAuthTransaction | null {
+ const transaction = readOAuthTransaction(storage, now);
+ storage.removeItem(OAUTH_TRANSACTION_STORAGE_KEY);
+ return transaction;
+}
+
+function randomBase64Url(bytes: number, cryptoImpl: Crypto): string {
+ const values = new Uint8Array(bytes);
+ cryptoImpl.getRandomValues(values);
+ let binary = "";
+ values.forEach((value) => { binary += String.fromCharCode(value); });
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
+}
+
+function bytesToBase64Url(values: Uint8Array): string {
+ let binary = "";
+ values.forEach((value) => { binary += String.fromCharCode(value); });
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
+}
+
+export function oauthRedirectUri(location: Pick): string {
+ return `${location.origin}${location.pathname}`;
+}
+
+export async function createAuthorizationRequest(options: {
+ storage: Pick;
+ location: Pick;
+ cryptoImpl?: Crypto;
+ backendUrl?: string;
+ clientId?: string;
+ now?: () => number;
+}): Promise<{ url: string; transaction: OAuthTransaction }> {
+ const cryptoImpl = options.cryptoImpl ?? crypto;
+ const backendUrl = options.backendUrl ?? PLATFORM_BACKEND_URL;
+ const resource = canonicalOAuthResource(backendUrl);
+ const verifier = randomBase64Url(48, cryptoImpl);
+ const state = randomBase64Url(32, cryptoImpl);
+ const digest = await cryptoImpl.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
+ const redirectUri = oauthRedirectUri(options.location);
+ const transaction: OAuthTransaction = {
+ version: 1,
+ state,
+ verifier,
+ redirectUri,
+ resource,
+ createdAt: (options.now ?? Date.now)(),
+ };
+ writeOAuthTransaction(options.storage, transaction);
+ const url = new URL(`${backendUrl}/auth/oauth2/authorize`);
+ url.search = new URLSearchParams({
+ client_id: options.clientId ?? OAUTH_CLIENT_ID,
+ redirect_uri: redirectUri,
+ response_type: "code",
+ scope: OAUTH_SCOPE,
+ code_challenge: bytesToBase64Url(new Uint8Array(digest)),
+ code_challenge_method: "S256",
+ state,
+ resource,
+ }).toString();
+ return { url: url.toString(), transaction };
+}
+
+export function oauthCallbackFromSearch(search: string): OAuthCallback | null {
+ const params = new URLSearchParams(search);
+ // Any authorization parameter counts, so even a partial or forged callback (e.g.
+ // `?state=` alone) is scrubbed from the URL and rejected rather than left in place.
+ if (!["code", "state", "error", "error_description"].some((key) => params.has(key))) return null;
+ return {
+ ...(params.get("code") ? { code: params.get("code")! } : {}),
+ ...(params.get("state") ? { state: params.get("state")! } : {}),
+ ...(params.get("error") ? { error: params.get("error")! } : {}),
+ ...(params.get("error_description") ? { errorDescription: params.get("error_description")! } : {}),
+ };
+}
+
+export function scrubOAuthCallbackFromUrl(
+ location: Pick,
+ history: Pick,
+): void {
+ const params = new URLSearchParams(location.search);
+ for (const key of ["code", "state", "error", "error_description"]) params.delete(key);
+ const search = params.toString();
+ history.replaceState(history.state, "", `${location.pathname}${search ? `?${search}` : ""}${location.hash}`);
+}
+
+export async function exchangeAuthorizationCode(
+ code: string,
+ transaction: OAuthTransaction,
+ options: {
+ fetcher?: typeof fetch;
+ backendUrl?: string;
+ clientId?: string;
+ now?: () => number;
+ } = {},
+): Promise {
+ let response: Response;
+ try {
+ response = await (options.fetcher ?? fetch)(
+ `${options.backendUrl ?? PLATFORM_BACKEND_URL}/auth/oauth2/token`,
+ {
+ method: "POST",
+ credentials: "omit",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({
+ grant_type: "authorization_code",
+ client_id: options.clientId ?? OAUTH_CLIENT_ID,
+ redirect_uri: transaction.redirectUri,
+ code,
+ code_verifier: transaction.verifier,
+ resource: transaction.resource,
+ }),
+ },
+ );
+ } catch (error) {
+ throw new OAuthFlowError("network", error instanceof Error ? error.message : "Could not reach Writing Tools.");
+ }
+ const body = await response.json().catch(() => ({})) as Record;
+ if (!response.ok) {
+ throw new OAuthFlowError(
+ body.error === "access_denied" ? "denied" : "invalid",
+ typeof body.error_description === "string" ? body.error_description : "Writing Tools rejected the login.",
+ );
+ }
+ const expiresIn = body.expires_in;
+ const scopes = typeof body.scope === "string" ? body.scope.trim().split(/\s+/).filter(Boolean) : [];
+ if (
+ body.token_type !== "Bearer" ||
+ scopes.length !== 1 || scopes[0] !== OAUTH_SCOPE ||
+ typeof expiresIn !== "number" || !Number.isFinite(expiresIn) ||
+ expiresIn <= 0 || expiresIn > 86_400 ||
+ !isCompactJwt(body.access_token)
+ ) {
+ throw new OAuthFlowError("invalid", "Writing Tools returned an invalid OAuth session.");
+ }
+ const now = (options.now ?? Date.now)();
+ const tokenExpiry = decodeJwtExpiry(body.access_token);
+ if (tokenExpiry !== null && tokenExpiry <= now) {
+ throw new OAuthFlowError("invalid", "Writing Tools returned an expired OAuth session.");
+ }
+ return {
+ version: 1,
+ accessToken: body.access_token,
+ // Honour whichever lifetime is shorter: the JWT's own exp or the advertised expires_in.
+ expiresAt: earliest(tokenExpiry, now + expiresIn * 1000)!,
+ };
+}
diff --git a/next/src/state/adoption.ts b/next/src/state/adoption.ts
new file mode 100644
index 0000000..05dd819
--- /dev/null
+++ b/next/src/state/adoption.ts
@@ -0,0 +1,68 @@
+import { norm } from "./match";
+import type { Card, Session } from "./types";
+
+/**
+ * Suggestion adoption, behind `settings.suggestionAdoption` (spec §2).
+ *
+ * A card the writer creates or edits counts as adopted from an L2 suggestion when at
+ * least half of its words sit inside runs of three or more words, in the same order,
+ * that also appear in that suggestion. Cards shorter than three words count only if
+ * the whole card appears in the suggestion. No word list: common words rarely form a
+ * shared three-word run on their own. If runs prove too loose or too strict, a
+ * common-word list is the fallback the spec notes.
+ *
+ * As before: only a card whose text just changed can newly adopt, and later edits
+ * update the current and peak overlap without clearing the original influence.
+ */
+export const ADOPTION_THRESHOLD = 0.5;
+export const MIN_RUN = 3;
+
+function words(text: string): string[] {
+ return norm(text)
+ .split(" ")
+ .map((w) => w.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, ""))
+ .filter(Boolean);
+}
+
+/** Fraction of the card's words covered by runs it shares with the suggestion. */
+export function runOverlap(cardText: string, suggestionText: string): number {
+ const card = words(cardText);
+ const suggestion = words(suggestionText);
+ if (!card.length || !suggestion.length) return 0;
+ const run = Math.min(MIN_RUN, card.length);
+ const runs = new Set();
+ for (let i = 0; i + run <= suggestion.length; i++) runs.add(suggestion.slice(i, i + run).join(" "));
+ const covered = new Array(card.length).fill(false);
+ for (let i = 0; i + run <= card.length; i++) {
+ if (runs.has(card.slice(i, i + run).join(" "))) for (let j = i; j < i + run; j++) covered[j] = true;
+ }
+ return covered.filter(Boolean).length / card.length;
+}
+
+function best(cardText: string, session: Session): { id?: string; overlap: number } {
+ let top: { id?: string; overlap: number } = { overlap: 0 };
+ for (const p of session.proposals) {
+ // Every suggestion the writer has seen in the chat counts, whatever they decided.
+ if (p.kind !== "suggestion" || p.status === "dropped") continue;
+ const overlap = runOverlap(cardText, p.text);
+ if (overlap > top.overlap) top = { id: p.id, overlap };
+ }
+ return top;
+}
+
+function reconcileCard(card: Card, session: Session, textChanged: boolean): Card {
+ const match = best(card.text, session);
+ const prev = card.adoption;
+ if (!prev && (!textChanged || !match.id || match.overlap < ADOPTION_THRESHOLD)) return card;
+ const adoption = prev
+ ? { ...prev, currentOverlap: match.overlap, peakOverlap: Math.max(prev.peakOverlap, match.overlap) }
+ : { adoptedFrom: match.id!, currentOverlap: match.overlap, peakOverlap: match.overlap };
+ return { ...card, provenance: { kind: "ai_suggested" }, adoption };
+}
+
+/** Run after any writer map change. `changed` holds the cards whose text just changed. */
+export function reconcileAdoption(session: Session, changed: ReadonlySet): Session {
+ if (!session.settings.suggestionAdoption) return session;
+ const cards = session.map.cards.map((c) => reconcileCard(c, session, changed.has(c.id)));
+ return { ...session, map: { ...session.map, cards } };
+}
diff --git a/next/src/state/export.ts b/next/src/state/export.ts
new file mode 100644
index 0000000..6a03308
--- /dev/null
+++ b/next/src/state/export.ts
@@ -0,0 +1,27 @@
+import { provenanceLabel, structureLabel } from "./labels";
+import type { Card, MapState } from "./types";
+
+/** The map as Markdown for "Copy map": nesting as an indented list, then the links. */
+export function mapToMarkdown(map: MapState): string {
+ const byParent = new Map();
+ for (const c of map.cards) {
+ const key = c.parentId && map.cards.some((p) => p.id === c.parentId) ? c.parentId : null;
+ byParent.set(key, [...(byParent.get(key) ?? []), c]);
+ }
+ const lines: string[] = [];
+ const walk = (parent: string | null, depth: number, seen: Set) => {
+ for (const c of byParent.get(parent) ?? []) {
+ if (seen.has(c.id)) continue;
+ seen.add(c.id);
+ const marks = [provenanceLabel(c.provenance), parent ? structureLabel(c.parentProvenance) && "nesting AI suggested" : null].filter(Boolean);
+ lines.push(`${" ".repeat(depth)}- ${c.text || "(empty card)"}${marks.length ? ` _(${marks.join("; ")})_` : ""}`);
+ walk(c.id, depth + 1, seen);
+ }
+ };
+ walk(null, 0, new Set());
+
+ const text = (id: string) => map.cards.find((c) => c.id === id)?.text ?? id;
+ const links = map.links.map((l) => `- ${text(l.from)} → ${l.label || "(no label)"} → ${text(l.to)}${structureLabel(l.provenance) ? " _(AI suggested)_" : ""}`);
+
+ return ["# Map", "", ...(lines.length ? lines : ["(no cards)"]), ...(links.length ? ["", "## Links", "", ...links] : [])].join("\n");
+}
diff --git a/next/src/state/labels.ts b/next/src/state/labels.ts
new file mode 100644
index 0000000..fe5891a
--- /dev/null
+++ b/next/src/state/labels.ts
@@ -0,0 +1,31 @@
+import type { Card, Proposal, Provenance, StructureProvenance } from "./types";
+
+/**
+ * Provenance wording, shared by the chat, the map and what the model is shown, so the
+ * labels can't drift apart. The writer's own words get no label (null).
+ */
+export function provenanceLabel(p: Provenance): string | null {
+ if (p.kind === "combined") return `From ${p.messages} messages`;
+ if (p.kind === "draft") return "From your draft";
+ if (p.kind === "ai_suggested") return "AI suggestion";
+ return null;
+}
+
+export function structureLabel(p: StructureProvenance | undefined): string | null {
+ return p?.kind === "ai_suggested" ? "AI suggested" : null;
+}
+
+/**
+ * The label a proposal will carry on the map, shown on its chat line so the writer
+ * sees it before deciding.
+ */
+export function proposalLabel(p: Proposal): string | null {
+ if (p.kind === "card") return provenanceLabel(p.provenance);
+ if (p.kind === "suggestion") return provenanceLabel({ kind: "ai_suggested" });
+ return structureLabel(p.provenance);
+}
+
+/** For the model's state block, where every card states its source. */
+export function cardSourceForModel(card: Pick): string {
+ return provenanceLabel(card.provenance) ?? "writer's own";
+}
diff --git a/next/src/state/map.ts b/next/src/state/map.ts
new file mode 100644
index 0000000..97d7106
--- /dev/null
+++ b/next/src/state/map.ts
@@ -0,0 +1,251 @@
+import { reconcileAdoption } from "./adoption";
+import type { Card, Link, MapState, Proposal, Session, StructureProvenance } from "./types";
+
+const UNDO_LIMIT = 50;
+
+/** Next free spot for a top-level card in a simple grid, so new cards don't stack. */
+function nextPosition(map: MapState): { x: number; y: number } {
+ const n = map.cards.filter((c) => !c.parentId).length;
+ return { x: 40 + (n % 3) * 300, y: 40 + Math.floor(n / 3) * 180 };
+}
+
+/** A card's position on the canvas: its own offset plus every ancestor's. */
+export function absolutePosition(map: MapState, id: string): { x: number; y: number } {
+ let x = 0;
+ let y = 0;
+ const seen = new Set();
+ let at = findCard(map, id);
+ while (at && !seen.has(at.id)) {
+ seen.add(at.id);
+ x += at.x;
+ y += at.y;
+ at = at.parentId ? findCard(map, at.parentId) : undefined;
+ }
+ return { x, y };
+}
+
+// Where a newly nested card sits inside its parent (positions are parent-relative).
+const CHILD_LEFT = 16;
+const CHILD_TOP = 48;
+const CHILD_STEP = 110;
+
+/**
+ * Re-parent a card and place it: inside the parent below its existing children, or,
+ * when taken out, just to the right of where it was so it doesn't sit inside the old
+ * parent's box. Positions only; callers check for cycles.
+ */
+function placeUnder(map: MapState, child: string, parent: string | null, mark?: StructureProvenance): MapState {
+ const card = findCard(map, child);
+ if (!card) return map;
+ let next: Card;
+ if (parent) {
+ const siblings = map.cards.filter((c) => c.parentId === parent && c.id !== child).length;
+ next = { ...card, parentId: parent, x: CHILD_LEFT, y: CHILD_TOP + siblings * CHILD_STEP, parentProvenance: mark?.kind === "ai_suggested" ? mark : undefined };
+ } else {
+ const abs = absolutePosition(map, child);
+ const out = card.parentId ? absolutePosition(map, card.parentId) : abs;
+ next = { ...card, parentId: null, x: Math.max(abs.x, out.x) + 320, y: abs.y, parentProvenance: undefined };
+ }
+ return { ...map, cards: map.cards.map((c) => (c.id === child ? next : c)) };
+}
+
+function withUndo(session: Session): Session {
+ const entry = { map: session.map, proposals: session.proposals.map(({ id, status, note }) => ({ id, status, note })) };
+ return { ...session, undo: [...session.undo, entry].slice(-UNDO_LIMIT) };
+}
+
+export function findCard(map: MapState, id: string): Card | undefined {
+ return map.cards.find((c) => c.id === id);
+}
+
+/** The text a model reference points at: an existing card, or a card proposal still pending. */
+export function refText(session: Session, ref: string): string | undefined {
+ const card = findCard(session.map, ref);
+ if (card) return card.text;
+ const p = session.proposals.find((x) => x.id === ref);
+ return p && p.kind === "card" && p.status === "pending" ? p.text : undefined;
+}
+
+/** True if making `parent` the parent of `child` would create a loop. */
+export function wouldCycle(map: MapState, child: string, parent: string): boolean {
+ let at: string | null = parent;
+ const seen = new Set();
+ while (at && !seen.has(at)) {
+ if (at === child) return true;
+ seen.add(at);
+ at = findCard(map, at)?.parentId ?? null;
+ }
+ return false;
+}
+
+export function hasLink(map: MapState, from: string, to: string): boolean {
+ return map.links.some((l) => l.from === from && l.to === to);
+}
+
+// ---- Writer's own map actions (immediate: they are the writer's explicit acts) ----
+
+export function addCard(session: Session, text: string): Session {
+ const s = withUndo(session);
+ const id = `c${s.counters.card + 1}`;
+ const card: Card = { id, text, parentId: null, provenance: { kind: "user" }, ...nextPosition(s.map) };
+ const next = { ...s, counters: { ...s.counters, card: s.counters.card + 1 }, map: { ...s.map, cards: [...s.map.cards, card] } };
+ return reconcileAdoption(next, new Set([id]));
+}
+
+/**
+ * The writer's edit. Once the words change they are the writer's own, so a card quoted
+ * from messages or the draft drops those sources and its "From …" label. An AI
+ * suggestion keeps its label; adoption tracking (below) handles that influence.
+ */
+export function editCard(session: Session, id: string, text: string): Session {
+ const s = withUndo(session);
+ const cards = s.map.cards.map((c) => {
+ if (c.id !== id || c.text === text) return c;
+ const { sources: _stale, ...rest } = c;
+ return { ...rest, text, provenance: c.provenance.kind === "ai_suggested" ? c.provenance : { kind: "user" as const } };
+ });
+ return reconcileAdoption({ ...s, map: { ...s.map, cards } }, new Set([id]));
+}
+
+export function moveCard(session: Session, id: string, x: number, y: number): Session {
+ // Dragging isn't an undo step: it changes layout, not ideas.
+ return { ...session, map: { ...session.map, cards: session.map.cards.map((c) => (c.id === id ? { ...c, x, y } : c)) } };
+}
+
+export function deleteCard(session: Session, id: string): Session {
+ const s = withUndo(session);
+ // Children move up to the top level and keep their place on the canvas.
+ const cards = s.map.cards
+ .filter((c) => c.id !== id)
+ .map((c) => (c.parentId === id ? { ...c, parentId: null, parentProvenance: undefined, ...absolutePosition(s.map, c.id) } : c));
+ const links = s.map.links.filter((l) => l.from !== id && l.to !== id);
+ return { ...s, map: { cards, links } };
+}
+
+/** The writer's own nesting (or un-nesting with `null`). Clears any AI-suggested mark. */
+export function setParent(session: Session, child: string, parent: string | null): Session {
+ const card = findCard(session.map, child);
+ if (!card || card.parentId === parent) return session;
+ if (parent && (child === parent || !findCard(session.map, parent) || wouldCycle(session.map, child, parent))) return session;
+ const s = withUndo(session);
+ return { ...s, map: placeUnder(s.map, child, parent) };
+}
+
+export function addLink(session: Session, from: string, to: string, label: string): Session {
+ if (from === to || hasLink(session.map, from, to)) return session;
+ const s = withUndo(session);
+ const link: Link = { id: `l${s.counters.link + 1}`, from, to, label, provenance: { kind: "user" } };
+ return { ...s, counters: { ...s.counters, link: s.counters.link + 1 }, map: { ...s.map, links: [...s.map.links, link] } };
+}
+
+export function editLinkLabel(session: Session, id: string, label: string): Session {
+ const s = withUndo(session);
+ return { ...s, map: { ...s.map, links: s.map.links.map((l) => (l.id === id ? { ...l, label } : l)) } };
+}
+
+export function deleteLink(session: Session, id: string): Session {
+ const s = withUndo(session);
+ return { ...s, map: { ...s.map, links: s.map.links.filter((l) => l.id !== id) } };
+}
+
+/**
+ * Undo the writer's last map change. Proposal states come back too, but only for
+ * proposals that existed then: ones the coach made later stay. If an Accept is undone,
+ * the model is told, since it may already have heard "accepted".
+ */
+export function undo(session: Session): Session {
+ const prev = session.undo[session.undo.length - 1];
+ if (!prev) return session;
+ const before = new Map(prev.proposals.map((p) => [p.id, p]));
+ const undone: string[] = [];
+ const proposals = session.proposals.map((p) => {
+ const was = before.get(p.id);
+ if (!was || (was.status === p.status && was.note === p.note)) return p;
+ if (p.status === "accepted") undone.push(`writer undid accepting ${p.id} (${describe(p)}); it is pending again`);
+ return { ...p, status: was.status, note: was.note } as Proposal;
+ });
+ return { ...session, map: prev.map, proposals, undo: session.undo.slice(0, -1), decisions: [...session.decisions, ...undone] };
+}
+
+// ---- Proposals: only the writer's click changes the map ----
+
+/** A model reference as a card id: an existing card, or the card an accepted card proposal became. */
+function resolve(session: Session, ref: string): string | undefined {
+ if (findCard(session.map, ref)) return ref;
+ const p = session.proposals.find((x) => x.id === ref);
+ const cardId = p && (p.kind === "card" || p.kind === "suggestion") ? p.cardId : undefined;
+ return cardId && findCard(session.map, cardId) ? cardId : undefined;
+}
+
+/** Why a link or nest proposal can't be accepted yet, or undefined if it can. */
+export function blockedReason(session: Session, p: Proposal): string | undefined {
+ if (p.kind !== "link" && p.kind !== "nest") return undefined;
+ const refs = p.kind === "link" ? [p.from, p.to] : [p.child, p.parent];
+ for (const ref of refs) {
+ if (resolve(session, ref)) continue;
+ const pending = session.proposals.find((x) => x.id === ref && x.status === "pending");
+ return pending ? "Accept the card it connects first." : "A card it connects is gone.";
+ }
+ return undefined;
+}
+
+function setStatus(session: Session, id: string, status: Proposal["status"], note?: string): Session {
+ return { ...session, proposals: session.proposals.map((p) => (p.id === id ? { ...p, status, note } : p)) };
+}
+
+function describe(p: Proposal): string {
+ return p.kind === "card" || p.kind === "suggestion" ? `${p.kind} "${p.text}"` : p.kind;
+}
+
+export function acceptProposal(session: Session, id: string): Session {
+ const p = session.proposals.find((x) => x.id === id);
+ if (!p || p.status !== "pending" || blockedReason(session, p)) return session;
+ let s = withUndo(session);
+
+ if (p.kind === "card" || p.kind === "suggestion") {
+ const cardId = `c${s.counters.card + 1}`;
+ const card: Card = {
+ id: cardId,
+ text: p.text,
+ parentId: null,
+ provenance: p.kind === "card" ? p.provenance : { kind: "ai_suggested" },
+ ...(p.kind === "card" && p.sources.length ? { sources: p.sources } : {}),
+ ...nextPosition(s.map),
+ };
+ s = {
+ ...s,
+ counters: { ...s.counters, card: s.counters.card + 1 },
+ map: { ...s.map, cards: [...s.map.cards, card] },
+ proposals: s.proposals.map((x) => (x.id === p.id ? { ...x, cardId } : x)),
+ };
+ } else if (p.kind === "link") {
+ const from = resolve(s, p.from)!;
+ const to = resolve(s, p.to)!;
+ if (from === to || hasLink(s.map, from, to)) return setStatus(session, id, "dropped", "That link is already on the map.");
+ const link: Link = { id: `l${s.counters.link + 1}`, from, to, label: p.label, provenance: p.provenance };
+ s = { ...s, counters: { ...s.counters, link: s.counters.link + 1 }, map: { ...s.map, links: [...s.map.links, link] } };
+ } else {
+ const child = resolve(s, p.child)!;
+ const parent = resolve(s, p.parent)!;
+ if (child === parent || wouldCycle(s.map, child, parent)) return setStatus(session, id, "dropped", "The map has changed so this nesting would make a loop.");
+ s = { ...s, map: placeUnder(s.map, child, parent, p.provenance) };
+ }
+ s = setStatus(s, id, "accepted");
+ // Name the card a card proposal became, so the model can refer to it next turn.
+ const after = s.proposals.find((x) => x.id === p.id);
+ const became = after && (after.kind === "card" || after.kind === "suggestion") ? after.cardId : undefined;
+ return { ...s, decisions: [...s.decisions, `accepted ${p.id} (${describe(p)})${became ? `, now card ${became}` : ""}`] };
+}
+
+export function rejectProposal(session: Session, id: string): Session {
+ const p = session.proposals.find((x) => x.id === id);
+ if (!p || p.status !== "pending") return session;
+ let s = setStatus(session, id, "rejected");
+ // A link or nest that named this pending card can no longer happen.
+ for (const other of s.proposals) {
+ if (other.status !== "pending") continue;
+ const refs = other.kind === "link" ? [other.from, other.to] : other.kind === "nest" ? [other.child, other.parent] : [];
+ if (refs.includes(id)) s = setStatus(s, other.id, "dropped", "Its card was rejected.");
+ }
+ return { ...s, decisions: [...s.decisions, `rejected ${p.id} (${describe(p)})`] };
+}
diff --git a/next/src/state/match.ts b/next/src/state/match.ts
new file mode 100644
index 0000000..7606b10
--- /dev/null
+++ b/next/src/state/match.ts
@@ -0,0 +1,86 @@
+import type { Level, WriterRecord } from "./types";
+
+/**
+ * The matching rule every handler uses (spec §5): a mechanical comparison, never an
+ * interpretation. Unicode NFC, lower-case, curly quotes treated as straight, and
+ * whitespace runs collapsed to one space.
+ */
+export function norm(text: string): string {
+ return text
+ .normalize("NFC")
+ .toLowerCase()
+ .replace(/[‘’]/g, "'")
+ .replace(/[“”]/g, '"')
+ .replace(/\s+/g, " ")
+ .trim();
+}
+
+const WORD_CHAR = /[\p{L}\p{N}]/u;
+
+/**
+ * Index of `piece` inside `haystack` (both normalized), starting and ending on word
+ * boundaries so a piece can't begin or end mid-word ("ike x" inside "like x").
+ * -1 when absent.
+ */
+export function findOnWordBoundary(haystack: string, piece: string, from = 0): number {
+ if (!piece) return -1;
+ let at = haystack.indexOf(piece, from);
+ while (at !== -1) {
+ const before = at === 0 ? "" : haystack[at - 1]!;
+ const after = haystack[at + piece.length] ?? "";
+ const startsWord = !WORD_CHAR.test(piece[0]!) || !WORD_CHAR.test(before);
+ const endsWord = !WORD_CHAR.test(piece[piece.length - 1]!) || !WORD_CHAR.test(after);
+ if (startsWord && endsWord) return at;
+ at = haystack.indexOf(piece, at + 1);
+ }
+ return -1;
+}
+
+/** One place a piece can come from: a writer chat message or the current draft. */
+export interface Source {
+ kind: "chat" | "draft";
+ /** Position in the writer's message list (chat only), for adjacency checks. */
+ index: number;
+ turn: number;
+ text: string;
+}
+
+/** The writer's words a level allows quoting from (spec §3). */
+export function eligibleSources(record: WriterRecord, level: Level): Source[] {
+ const chat: Source[] = record.messages.map((m, index) => ({ kind: "chat", index, turn: m.turn, text: norm(m.text) }));
+ if (level >= 1 && record.draft.trim()) chat.push({ kind: "draft", index: -1, turn: -1, text: norm(record.draft) });
+ return chat;
+}
+
+/** Every source containing the piece, most recent chat message first, draft last. */
+export function sourcesContaining(piece: string, sources: Source[]): Source[] {
+ const p = norm(piece);
+ return sources
+ .filter((s) => findOnWordBoundary(s.text, p) !== -1)
+ .sort((a, b) => (a.kind === b.kind ? b.index - a.index : a.kind === "chat" ? -1 : 1));
+}
+
+/**
+ * When a piece occurs in a source only inside longer words (a typo like "lik ethe"
+ * glued "the" to the letter before it), the whole-word stretch it sits in, so a
+ * refusal can say so. Undefined when the piece doesn't occur at all.
+ */
+export function enclosingWords(piece: string, sources: Source[]): string | undefined {
+ const p = norm(piece);
+ if (!p) return undefined;
+ for (const s of sources) {
+ const at = s.text.indexOf(p);
+ if (at === -1) continue;
+ let start = at;
+ let end = at + p.length;
+ while (start > 0 && WORD_CHAR.test(s.text[start - 1]!)) start--;
+ while (end < s.text.length && WORD_CHAR.test(s.text[end]!)) end++;
+ return s.text.slice(start, end);
+ }
+ return undefined;
+}
+
+/** A piece is usable only if it has at least one letter or number after normalizing. */
+export function hasWords(piece: string): boolean {
+ return WORD_CHAR.test(norm(piece));
+}
diff --git a/next/src/state/persist-export.test.ts b/next/src/state/persist-export.test.ts
new file mode 100644
index 0000000..96b2a7f
--- /dev/null
+++ b/next/src/state/persist-export.test.ts
@@ -0,0 +1,81 @@
+import { describe, expect, it } from "vitest";
+import { mapToMarkdown } from "./export";
+import { proposalLabel } from "./labels";
+import { loadSession, saveSession, STORAGE_KEY } from "./persist";
+import { emptySession, type MapState } from "./types";
+
+function memoryStorage(initial: Record = {}): Storage {
+ const data = new Map(Object.entries(initial));
+ return {
+ get length() { return data.size; },
+ clear: () => data.clear(),
+ getItem: (k) => data.get(k) ?? null,
+ key: (i) => [...data.keys()][i] ?? null,
+ removeItem: (k) => void data.delete(k),
+ setItem: (k, v) => void data.set(k, v),
+ };
+}
+
+describe("browser saving", () => {
+ it("saves and loads a session", () => {
+ const storage = memoryStorage();
+ const s = { ...emptySession(), level: 2 as const, record: { messages: [{ turn: 1, text: "hi" }], draft: "d" } };
+ expect(saveSession(s, storage)).toBe(true);
+ expect(loadSession(storage)).toEqual(s);
+ });
+
+ it("starts fresh on missing, malformed or wrong-shaped data", () => {
+ expect(loadSession(memoryStorage())).toEqual(emptySession());
+ expect(loadSession(memoryStorage({ [STORAGE_KEY]: "{not json" }))).toEqual(emptySession());
+ expect(loadSession(memoryStorage({ [STORAGE_KEY]: JSON.stringify({ level: 7 }) }))).toEqual(emptySession());
+ });
+
+ it("reports failure instead of throwing when storage is missing or blocked", () => {
+ const blocked = { ...memoryStorage(), setItem: () => { throw new Error("QuotaExceeded"); } } as Storage;
+ expect(saveSession(emptySession(), blocked)).toBe(false);
+ expect(saveSession(emptySession(), undefined)).toBe(false);
+ });
+});
+
+describe("copy map", () => {
+ it("writes nesting as an indented list, then links, with provenance marks", () => {
+ const map: MapState = {
+ cards: [
+ { id: "c1", text: "Money", parentId: null, provenance: { kind: "user" }, x: 0, y: 0 },
+ { id: "c2", text: "Rent", parentId: "c1", parentProvenance: { kind: "ai_suggested" }, provenance: { kind: "combined", messages: 2 }, x: 0, y: 0 },
+ { id: "c3", text: "Time", parentId: null, provenance: { kind: "draft" }, x: 0, y: 0 },
+ ],
+ links: [{ id: "l1", from: "c1", to: "c3", label: "outweighs", provenance: { kind: "ai_suggested" } }],
+ };
+ expect(mapToMarkdown(map)).toBe([
+ "# Map",
+ "",
+ "- Money",
+ " - Rent _(From 2 messages; nesting AI suggested)_",
+ "- Time _(From your draft)_",
+ "",
+ "## Links",
+ "",
+ "- Money → outweighs → Time _(AI suggested)_",
+ ].join("\n"));
+ });
+});
+
+describe("proposal labels in the chat", () => {
+ it("shows the label the proposal will carry on the map", () => {
+ const base = { id: "p1", status: "pending" as const };
+ expect(proposalLabel({ ...base, kind: "card", text: "A", provenance: { kind: "combined", messages: 2 }, sources: [] })).toBe("From 2 messages");
+ expect(proposalLabel({ ...base, kind: "card", text: "A", provenance: { kind: "draft" }, sources: [] })).toBe("From your draft");
+ expect(proposalLabel({ ...base, kind: "card", text: "A", provenance: { kind: "user" }, sources: [] })).toBeNull();
+ expect(proposalLabel({ ...base, kind: "suggestion", text: "B", nearCard: null })).toBe("AI suggestion");
+ expect(proposalLabel({ ...base, kind: "link", from: "c1", to: "c2", label: "so", provenance: { kind: "ai_suggested" } })).toBe("AI suggested");
+ expect(proposalLabel({ ...base, kind: "nest", child: "c1", parent: "c2", provenance: { kind: "user" } })).toBeNull();
+ });
+});
+
+describe("saved undo steps from before proposal states were kept", () => {
+ it("are dropped on load instead of being replayed", () => {
+ const old = { ...emptySession(), undo: [{ cards: [], links: [] }] };
+ expect(loadSession(memoryStorage({ [STORAGE_KEY]: JSON.stringify(old) })).undo).toEqual([]);
+ });
+});
diff --git a/next/src/state/persist.ts b/next/src/state/persist.ts
new file mode 100644
index 0000000..8250df0
--- /dev/null
+++ b/next/src/state/persist.ts
@@ -0,0 +1,56 @@
+import { emptySession, type Session } from "./types";
+
+/** Browser saving (spec §4). Storage can be missing or blocked, so every access is guarded. */
+export const STORAGE_KEY = "mindmap-next:session:v1";
+
+function isArray(v: unknown): boolean {
+ return Array.isArray(v);
+}
+
+/** Enough shape to trust a saved session; anything else starts fresh. */
+function looksLikeSession(v: unknown): v is Session {
+ if (!v || typeof v !== "object") return false;
+ const s = v as Partial;
+ return (
+ (s.level === 0 || s.level === 1 || s.level === 2) &&
+ !!s.record && isArray(s.record.messages) && typeof s.record.draft === "string" &&
+ !!s.map && isArray(s.map.cards) && isArray(s.map.links) &&
+ isArray(s.proposals) && isArray(s.transcript) && isArray(s.history) && isArray(s.decisions) && isArray(s.undo) &&
+ !!s.counters && typeof s.counters.card === "number"
+ );
+}
+
+export function loadSession(storage: Storage | undefined = safeStorage()): Session {
+ try {
+ const raw = storage?.getItem(STORAGE_KEY);
+ if (!raw) return emptySession();
+ const parsed: unknown = JSON.parse(raw);
+ if (!looksLikeSession(parsed)) return emptySession();
+ const fresh = emptySession();
+ // Undo steps saved before they carried proposal states can't be replayed safely; drop them.
+ const undo = parsed.undo.every((u) => u && typeof u === "object" && "map" in u && Array.isArray(u.proposals)) ? parsed.undo : [];
+ return { ...fresh, ...parsed, undo, settings: { ...fresh.settings, ...parsed.settings }, counters: { ...fresh.counters, ...parsed.counters } };
+ } catch {
+ return emptySession();
+ }
+}
+
+/** Returns false when the session couldn't be saved (no storage, blocked, or full). */
+export function saveSession(session: Session, storage: Storage | undefined = safeStorage()): boolean {
+ try {
+ if (!storage) return false;
+ storage.setItem(STORAGE_KEY, JSON.stringify(session));
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/** Reading `localStorage` itself throws in some embedded and private contexts. */
+function safeStorage(): Storage | undefined {
+ try {
+ return typeof localStorage === "undefined" ? undefined : localStorage;
+ } catch {
+ return undefined;
+ }
+}
diff --git a/next/src/state/state.test.ts b/next/src/state/state.test.ts
new file mode 100644
index 0000000..d0c056d
--- /dev/null
+++ b/next/src/state/state.test.ts
@@ -0,0 +1,136 @@
+import { describe, expect, it } from "vitest";
+import { runOverlap } from "./adoption";
+import { absolutePosition, acceptProposal, addCard, addLink, deleteCard, editCard, rejectProposal, setParent, undo } from "./map";
+import { emptySession, type Proposal, type Session } from "./types";
+
+function withProposals(proposals: Proposal[]): Session {
+ return { ...emptySession(), proposals, counters: { card: 0, link: 0, proposal: proposals.length, turn: 0 } };
+}
+
+describe("proposals", () => {
+ const card = (id: string, text: string): Proposal => ({ id, status: "pending", kind: "card", text, provenance: { kind: "user" }, sources: [] });
+
+ it("accepting a card puts it on the map; a link to it can then be accepted", () => {
+ let s = withProposals([card("p1", "A"), card("p2", "B"), { id: "p3", status: "pending", kind: "link", from: "p1", to: "p2", label: "so", provenance: { kind: "user" } }]);
+ s = acceptProposal(s, "p3");
+ expect(s.map.links).toHaveLength(0); // blocked until both cards are accepted
+ s = acceptProposal(acceptProposal(acceptProposal(s, "p1"), "p2"), "p3");
+ expect(s.map.links).toEqual([{ id: "l1", from: "c1", to: "c2", label: "so", provenance: { kind: "user" } }]);
+ expect(s.decisions).toHaveLength(3);
+ });
+
+ it("rejecting a card drops the link and nest proposals that named it", () => {
+ let s = withProposals([card("p1", "A"), { id: "p2", status: "pending", kind: "nest", child: "p1", parent: "c9", provenance: { kind: "user" } }]);
+ s = rejectProposal(s, "p1");
+ expect(s.proposals.map((p) => p.status)).toEqual(["rejected", "dropped"]);
+ });
+
+ it("an accepted suggestion becomes an AI-suggested card", () => {
+ const s = acceptProposal(withProposals([{ id: "p1", status: "pending", kind: "suggestion", text: "Try Z", nearCard: null }]), "p1");
+ expect(s.map.cards[0]?.provenance).toEqual({ kind: "ai_suggested" });
+ });
+});
+
+describe("writer map actions", () => {
+ it("add, edit, link, nest, delete and undo", () => {
+ let s = addCard(addCard(emptySession(), "A"), "B");
+ s = addLink(s, "c1", "c2", "so");
+ s = setParent(s, "c2", "c1");
+ expect(setParent(s, "c1", "c2")).toBe(s); // would loop
+ s = editCard(s, "c1", "A2");
+ s = deleteCard(s, "c1");
+ expect(s.map.cards).toEqual([expect.objectContaining({ id: "c2", parentId: null })]);
+ expect(s.map.links).toHaveLength(0);
+ s = undo(s);
+ expect(s.map.cards.map((c) => c.text)).toEqual(["A2", "B"]);
+ });
+});
+
+describe("suggestion adoption (three-word runs)", () => {
+ it("measures the share of card words inside shared runs of three or more", () => {
+ expect(runOverlap("the real topic is flaws", "maybe the real topic is X")).toBeCloseTo(4 / 5);
+ expect(runOverlap("the cat sat", "a cat that sat")).toBe(0); // no shared three-word run
+ expect(runOverlap("flaws", "focus on flaws")).toBe(1); // short card: whole card appears
+ });
+
+ it("marks a card the writer types from a visible suggestion, only when its text changed", () => {
+ let s = withProposals([{ id: "p1", status: "rejected", kind: "suggestion", text: "maybe the real topic is flaws", nearCard: null }]);
+ s = addCard(s, "the real topic is flaws");
+ expect(s.map.cards[0]).toMatchObject({ provenance: { kind: "ai_suggested" }, adoption: { adoptedFrom: "p1" } });
+ s = editCard(s, "c1", "my own idea entirely");
+ expect(s.map.cards[0]).toMatchObject({ provenance: { kind: "ai_suggested" }, adoption: { currentOverlap: 0, peakOverlap: 1 } });
+ });
+
+ it("does nothing when the setting is off", () => {
+ const base = withProposals([{ id: "p1", status: "pending", kind: "suggestion", text: "the real topic is flaws", nearCard: null }]);
+ const s = addCard({ ...base, settings: { suggestionAdoption: false } }, "the real topic is flaws");
+ expect(s.map.cards[0]?.provenance).toEqual({ kind: "user" });
+ });
+});
+
+describe("review fixes: nesting positions, marks and decisions", () => {
+ const base = (): Session => ({
+ ...emptySession(),
+ map: { cards: [{ id: "c1", text: "A", parentId: null, provenance: { kind: "user" }, x: 100, y: 50 }, { id: "c2", text: "B", parentId: null, provenance: { kind: "user" }, x: 500, y: 50 }], links: [] },
+ counters: { card: 2, link: 0, proposal: 0, turn: 0 },
+ });
+
+ it("nesting places the child inside the parent; taking it out puts it beside the parent", () => {
+ let s = setParent(base(), "c2", "c1");
+ const child = s.map.cards.find((c) => c.id === "c2")!;
+ expect(child.parentId).toBe("c1");
+ expect(absolutePosition(s.map, "c2")).toEqual({ x: 100 + child.x, y: 50 + child.y });
+ s = setParent(s, "c2", null);
+ const out = s.map.cards.find((c) => c.id === "c2")!;
+ expect(out.parentId).toBeNull();
+ expect(out.x).toBeGreaterThan(100 + 200); // to the right of the old parent, not inside it
+ });
+
+ it("the writer re-nesting a card clears an AI-suggested mark", () => {
+ let s = base();
+ s = { ...s, map: { ...s.map, cards: s.map.cards.map((c) => (c.id === "c2" ? { ...c, parentId: "c1", parentProvenance: { kind: "ai_suggested" as const }, x: 16, y: 48 } : c)) } };
+ s = setParent(s, "c2", null);
+ expect(s.map.cards.find((c) => c.id === "c2")?.parentProvenance).toBeUndefined();
+ });
+
+ it("deleting a parent keeps its children where they were on the canvas", () => {
+ let s = setParent(base(), "c2", "c1");
+ const before = absolutePosition(s.map, "c2");
+ s = deleteCard(s, "c1");
+ const child = s.map.cards.find((c) => c.id === "c2")!;
+ expect(child.parentId).toBeNull();
+ expect({ x: child.x, y: child.y }).toEqual(before);
+ });
+
+ it("an accepted card proposal tells the model which card it became, and keeps its sources", () => {
+ const sources = [{ piece: "A", from: "chat" as const, turn: 1, allTurns: [1], inDraft: false }];
+ let s = withProposals([{ id: "p1", status: "pending", kind: "card", text: "A", provenance: { kind: "user" }, sources }]);
+ s = acceptProposal(s, "p1");
+ expect(s.decisions).toEqual(['accepted p1 (card "A"), now card c1']);
+ expect(s.map.cards[0]?.sources).toEqual(sources);
+ });
+});
+
+describe("review minors: undo after Accept, editing quoted cards", () => {
+ const card = (id: string, text: string): Proposal => ({ id, status: "pending", kind: "card", text, provenance: { kind: "combined", messages: 2 }, sources: [{ piece: text, from: "chat", turn: 1, allTurns: [1], inDraft: false }] });
+
+ it("undoing an Accept makes the proposal pending again, tells the model, and keeps later proposals", () => {
+ let s = acceptProposal(withProposals([card("p1", "A")]), "p1");
+ // A later turn added another proposal before the writer pressed Undo.
+ s = { ...s, proposals: [...s.proposals, card("p2", "B")], decisions: [] };
+ s = undo(s);
+ expect(s.map.cards).toHaveLength(0);
+ expect(s.proposals.map((p) => `${p.id}:${p.status}`)).toEqual(["p1:pending", "p2:pending"]);
+ expect(s.decisions).toEqual(['writer undid accepting p1 (card "A"); it is pending again']);
+ });
+
+ it("editing a quoted card drops its stale sources and From label; an AI suggestion keeps its label", () => {
+ let s = acceptProposal(withProposals([card("p1", "A")]), "p1");
+ s = editCard(s, "c1", "A, in my own words");
+ expect(s.map.cards[0]).toMatchObject({ text: "A, in my own words", provenance: { kind: "user" } });
+ expect(s.map.cards[0]?.sources).toBeUndefined();
+ s = { ...s, map: { ...s.map, cards: s.map.cards.map((c) => ({ ...c, provenance: { kind: "ai_suggested" as const } })) } };
+ s = editCard(s, "c1", "changed again");
+ expect(s.map.cards[0]?.provenance).toEqual({ kind: "ai_suggested" });
+ });
+});
diff --git a/next/src/state/types.ts b/next/src/state/types.ts
new file mode 100644
index 0000000..c69a88e
--- /dev/null
+++ b/next/src/state/types.ts
@@ -0,0 +1,149 @@
+/** Assistance level. L3 (checks off) is planned, not built: see docs/tools-first-pass.md §3. */
+export type Level = 0 | 1 | 2;
+
+/** One message the writer sent, exactly as typed. The model's messages never go here. */
+export interface WriterMessage {
+ turn: number;
+ text: string;
+}
+
+/** What the handlers check against: the writer's own words, kept by the app. */
+export interface WriterRecord {
+ messages: WriterMessage[];
+ draft: string;
+}
+
+/**
+ * Where a card's words came from. Always set by code from what a handler found,
+ * never passed by the model.
+ */
+export type Provenance =
+ | { kind: "user" }
+ | { kind: "combined"; messages: number }
+ | { kind: "draft" }
+ | { kind: "ai_suggested" };
+
+/** Mark for model-made structure (a link, or a nesting) the writer accepted at L2. */
+export type StructureProvenance = { kind: "user" } | { kind: "ai_suggested" };
+
+/**
+ * Where one piece of a card's text was found (spec §5.1): the most recent match, and
+ * every match. Recorded by the handler, never passed by the model.
+ */
+export interface PieceSource {
+ piece: string;
+ /** Where the most recent match is: chat wins over the draft. */
+ from: "chat" | "draft";
+ /** Turn of the most recent chat match, or null when the piece is only in the draft. */
+ turn: number | null;
+ /** Every writer turn whose message contains the piece, most recent first. */
+ allTurns: number[];
+ /** Whether the current draft also contains it (L1 and L2 only). */
+ inDraft: boolean;
+}
+
+export interface AdoptionTrace {
+ /** Suggestion proposal whose wording this card took on (the original influence). */
+ adoptedFrom: string;
+ currentOverlap: number;
+ peakOverlap: number;
+}
+
+export interface Card {
+ id: string;
+ text: string;
+ parentId: string | null;
+ /** Set when the coach proposed this card's current nesting (L2) and the writer accepted it. */
+ parentProvenance?: StructureProvenance;
+ provenance: Provenance;
+ /** Where the card's words came from, for cards proposed from the writer's words. */
+ sources?: PieceSource[];
+ /** Position, relative to the parent card when nested. */
+ x: number;
+ y: number;
+ adoption?: AdoptionTrace;
+}
+
+export interface Link {
+ id: string;
+ from: string;
+ to: string;
+ label: string;
+ provenance: StructureProvenance;
+}
+
+export interface MapState {
+ cards: Card[];
+ links: Link[];
+}
+
+export type ProposalStatus = "pending" | "accepted" | "rejected" | "dropped";
+
+interface ProposalBase {
+ id: string;
+ status: ProposalStatus;
+ /** Why a proposal was dropped (its card was rejected, or it no longer fits the map). */
+ note?: string;
+}
+
+export type Proposal =
+ | (ProposalBase & { kind: "card"; text: string; provenance: Provenance; sources: PieceSource[]; cardId?: string })
+ | (ProposalBase & { kind: "link"; from: string; to: string; label: string; provenance: StructureProvenance })
+ | (ProposalBase & { kind: "nest"; child: string; parent: string; provenance: StructureProvenance })
+ | (ProposalBase & { kind: "suggestion"; text: string; nearCard: string | null; cardId?: string });
+
+/** What the chat shows. Tool lines are part of the transcript, refusals included. */
+export type TranscriptItem =
+ | { kind: "writer"; text: string }
+ | { kind: "coach"; text: string }
+ | { kind: "tool"; name: string; summary: string; ok: boolean; reason?: string; proposalId?: string }
+ | { kind: "notice"; text: string };
+
+/** Responses API input items we keep for later turns (no reasoning items). */
+export type HistoryItem =
+ | { role: "user" | "assistant"; content: string }
+ | { type: "function_call"; call_id: string; name: string; arguments: string }
+ | { type: "function_call_output"; call_id: string; output: string };
+
+export interface Settings {
+ suggestionAdoption: boolean;
+}
+
+/**
+ * One undo step: the map, plus each proposal's state at that moment, so undoing an
+ * Accept also makes that proposal pending again.
+ */
+export interface UndoEntry {
+ map: MapState;
+ proposals: Pick[];
+}
+
+export interface Session {
+ level: Level;
+ settings: Settings;
+ record: WriterRecord;
+ map: MapState;
+ proposals: Proposal[];
+ transcript: TranscriptItem[];
+ history: HistoryItem[];
+ /** Writer decisions on proposals the model hasn't been told about yet. */
+ decisions: string[];
+ counters: { card: number; link: number; proposal: number; turn: number };
+ /** Undo steps, newest last. */
+ undo: UndoEntry[];
+}
+
+export function emptySession(): Session {
+ return {
+ level: 0,
+ settings: { suggestionAdoption: true },
+ record: { messages: [], draft: "" },
+ map: { cards: [], links: [] },
+ proposals: [],
+ transcript: [],
+ history: [],
+ decisions: [],
+ counters: { card: 0, link: 0, proposal: 0, turn: 0 },
+ undo: [],
+ };
+}
diff --git a/next/src/styles.css b/next/src/styles.css
new file mode 100644
index 0000000..713bd12
--- /dev/null
+++ b/next/src/styles.css
@@ -0,0 +1,176 @@
+/* Plain v1 styling (spec §8). Matching the current app's look comes after v1. */
+:root {
+ --bg: #f5f4f0;
+ --panel: #fafaf8;
+ --line: #e2ded5;
+ --ink: #2a2723;
+ --muted: #8a8780;
+ --blue: #1a6fa3;
+ --green: #1a7a3c;
+ --red: #a33a2a;
+ --ai: #7a5b99;
+ font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif;
+ color: var(--ink);
+}
+* { box-sizing: border-box; }
+html, body, #root { height: 100%; margin: 0; }
+body { background: var(--bg); }
+button, select, textarea, input { font: inherit; color: inherit; }
+/* Spaces typed at the end of a wrapped line take up room and wrap like any character,
+ so the caret visibly moves (by default they "hang" past the edge, invisibly). */
+textarea { white-space: break-spaces; overflow-wrap: anywhere; }
+button {
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: #fff;
+ padding: 4px 10px;
+ cursor: pointer;
+}
+button:hover:not(:disabled) { background: #f4f2ed; }
+button:disabled { opacity: 0.5; cursor: default; }
+button:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid var(--blue); outline-offset: 1px; }
+.muted { color: var(--muted); }
+.small { font-size: 12px; }
+
+.app {
+ height: 100%;
+ display: grid;
+ grid-template-columns: clamp(340px, 30vw, 480px) minmax(0, 1fr);
+ grid-template-rows: auto minmax(0, 1fr);
+ grid-template-areas: "bar bar" "chat map";
+}
+.app.with-draft { grid-template-columns: clamp(340px, 30vw, 480px) minmax(0, 1fr) 320px; grid-template-areas: "bar bar bar" "chat map draft"; }
+
+.toolbar {
+ grid-area: bar;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 12px;
+ background: var(--panel);
+ border-bottom: 1px solid var(--line);
+}
+.toolbar .spacer { flex: 1; }
+.toolbar .level { display: flex; align-items: center; gap: 8px; }
+.toolbar select { border: 1px solid var(--line); border-radius: 8px; padding: 4px 8px; background: #fff; }
+.toolbar .check { display: flex; align-items: center; gap: 4px; font-size: 13px; }
+.toolbar button.on { border-color: var(--blue); color: var(--blue); }
+.toolbar button.quiet { color: var(--muted); }
+
+/* Chat */
+.chat { grid-area: chat; display: flex; flex-direction: column; min-height: 0; border-right: 1px solid var(--line); background: #fff; }
+.chat-log { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 6px; }
+.chat-empty { margin: 8px 4px; }
+.msg { padding: 8px 10px; border-radius: 10px; white-space: pre-wrap; max-width: 92%; }
+.msg-writer { align-self: flex-end; background: #e8f2f9; }
+.msg-coach { align-self: flex-start; background: var(--bg); }
+.msg-notice { align-self: center; color: var(--muted); font-size: 12px; font-style: italic; }
+.tool-line {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 4px 6px;
+ font-size: 12.5px;
+ padding: 4px 8px;
+ border-left: 3px solid var(--line);
+}
+.tool-ok { border-left-color: var(--green); }
+.tool-refused { border-left-color: var(--red); }
+.tool-mark { font-weight: 700; }
+.tool-ok .tool-mark { color: var(--green); }
+.tool-refused .tool-mark { color: var(--red); }
+.tool-name { font-family: ui-monospace, Consolas, monospace; font-size: 12px; }
+.tool-summary { color: var(--ink); overflow-wrap: anywhere; }
+.tool-buttons { margin-left: auto; display: flex; gap: 4px; }
+.tool-buttons button { padding: 2px 8px; font-size: 12px; }
+.tool-buttons .accept { border-color: #b5dfc5; background: #eafaf0; color: var(--green); }
+.tool-status { margin-left: auto; font-size: 12px; color: var(--muted); }
+.status-accepted { color: var(--green); }
+.tool-why { width: 100%; }
+.tool-why summary { cursor: pointer; color: var(--muted); font-size: 12px; }
+.tool-why > div { padding: 4px 0 2px 14px; }
+.composer { display: flex; gap: 8px; padding: 10px 12px 4px; border-top: 1px solid var(--line); }
+.composer textarea { flex: 1; min-height: 76px; max-height: 40vh; overflow-y: auto; resize: none; border: 1px solid var(--line); border-radius: 10px; padding: 8px 10px; line-height: 1.45; }
+.composer .send { align-self: flex-end; background: var(--blue); border-color: var(--blue); color: #fff; }
+.composer .send:hover:not(:disabled) { background: #155d89; }
+.hint { padding: 0 12px 8px; font-size: 11px; }
+
+/* Map */
+.map { grid-area: map; position: relative; min-width: 0; min-height: 0; }
+.map-empty { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); font-style: italic; pointer-events: none; }
+.map-hint { position: absolute; left: 56px; right: 230px; bottom: 10px; pointer-events: none; }
+.card {
+ width: 220px;
+ min-height: 80px;
+ background: #fff;
+ border: 1px solid #d8d5ce;
+ border-radius: 10px;
+ box-shadow: 0 1px 2px rgba(35, 31, 24, 0.05), 0 6px 18px rgba(35, 31, 24, 0.07);
+ padding: 6px 10px 10px;
+}
+.card-parent { width: 100%; height: 100%; background: #fbfaf7; border-style: solid; border-color: #c9c3b6; }
+.card-parent .card-text { -webkit-line-clamp: 2; display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; font-weight: 600; }
+.card-ai { border-color: #c9b8dc; }
+.react-flow__node.selected > .card { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(26, 111, 163, 0.15); }
+.card-head { display: flex; align-items: center; gap: 4px; min-height: 22px; }
+.badge { font-size: 10.5px; padding: 1px 6px; border-radius: 999px; background: #f1edf6; color: var(--ai); }
+.card-actions { margin-left: auto; display: flex; gap: 4px; opacity: 0.55; }
+.card:hover .card-actions { opacity: 1; }
+.card-actions button { padding: 0 6px; font-size: 11px; line-height: 18px; }
+.card-text { white-space: pre-wrap; overflow-wrap: anywhere; cursor: text; }
+.card-editor { width: 100%; min-height: 60px; resize: vertical; border: 1px solid var(--blue); border-radius: 6px; padding: 4px; }
+.react-flow__edge.edge-ai .react-flow__edge-path { stroke-dasharray: 6 4; stroke: var(--ai); }
+.react-flow__edge-text { font-size: 12px; }
+.react-flow__controls, .react-flow__minimap { border-radius: 10px; overflow: hidden; border: 1px solid var(--line); }
+
+/* Sign-in (same look as the current app's gate) */
+.platform-gate {
+ min-height: 100%;
+ display: grid;
+ align-content: center;
+ justify-items: center;
+ gap: 14px;
+ padding: 32px;
+ background: #f7f5ef;
+ text-align: center;
+}
+.platform-gate h1, .platform-gate p { max-width: 620px; margin: 0; }
+.platform-gate p { color: #625e55; }
+.platform-gate button { border-color: #b8b1a4; border-radius: 10px; padding: 12px 18px; }
+.platform-detail { font-size: 12px; }
+.access-banner {
+ position: fixed;
+ top: 52px;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 20;
+ max-width: 640px;
+ padding: 8px 12px;
+ border-radius: 8px;
+ background: #fff4f2;
+ border: 1px solid #ead3cf;
+ color: var(--red);
+ font-size: 13px;
+}
+
+/* Draft */
+.draft { grid-area: draft; display: flex; flex-direction: column; min-height: 0; border-left: 1px solid var(--line); background: #fff; }
+.draft-head { padding: 10px 12px; display: flex; flex-direction: column; gap: 2px; border-bottom: 1px solid var(--line); }
+.draft textarea { flex: 1; border: 0; padding: 12px; resize: none; }
+
+.toast {
+ position: fixed;
+ right: 16px;
+ bottom: 16px;
+ padding: 8px 12px;
+ border-radius: 8px;
+ background: var(--ink);
+ color: #fff;
+ font-size: 13px;
+}
+
+@media (max-width: 900px) {
+ .app, .app.with-draft { grid-template-columns: 1fr; grid-template-rows: auto 45vh minmax(0, 1fr) auto; grid-template-areas: "bar" "map" "chat" "draft"; }
+ .draft { max-height: 30vh; }
+}
diff --git a/next/src/tools/common.ts b/next/src/tools/common.ts
new file mode 100644
index 0000000..d226d5b
--- /dev/null
+++ b/next/src/tools/common.ts
@@ -0,0 +1,55 @@
+import type { Level, Proposal, Session } from "../state/types";
+
+/** What a handler is given. `checks: false` is reserved for L3 (spec §3), not used in v1. */
+export interface ToolContext {
+ session: Session;
+ level: Level;
+ checks: boolean;
+ /** Reserves the next proposal id ("p7"). */
+ nextProposalId: () => string;
+}
+
+export type HandlerResult =
+ | { ok: true; proposal: Proposal; summary: string }
+ | { ok: false; reason: string; summary: string };
+
+/** A tool as the model sees it (Responses API function tool) plus its handler. */
+export interface Tool {
+ name: string;
+ /** States the rule its handler enforces, for this level. */
+ description: (level: Level) => string;
+ /** Shape only (spec §2): types and required fields. Rules live in the handler. */
+ parameters: Record;
+ handle: (args: unknown, ctx: ToolContext) => HandlerResult;
+}
+
+export function refuse(reason: string, summary: string): HandlerResult {
+ return { ok: false, reason, summary };
+}
+
+export function obj(value: unknown): Record | undefined {
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record) : undefined;
+}
+
+export function stringList(value: unknown): string[] | undefined {
+ return Array.isArray(value) && value.every((v) => typeof v === "string") ? (value as string[]) : undefined;
+}
+
+/** How the eligible text is named in refusals, so the reason matches the level. */
+export function whereFrom(level: Level): string {
+ return level === 0 ? "your messages" : "your messages or draft";
+}
+
+/**
+ * Refusal for a piece that isn't in the eligible text on whole-word boundaries. Says
+ * so when the words are there but only inside longer words, so the model can retry.
+ */
+export function notFoundReason(piece: string, level: Level, inside: string | undefined): string {
+ return inside
+ ? `${quote(piece)} is in ${whereFrom(level)} only inside longer words (${quote(inside)}). Quote whole words, exactly as typed.`
+ : `${quote(piece)} is not in ${whereFrom(level)}.`;
+}
+
+export function quote(text: string): string {
+ return `"${text.length > 80 ? `${text.slice(0, 77)}…` : text}"`;
+}
diff --git a/next/src/tools/evidence.ts b/next/src/tools/evidence.ts
new file mode 100644
index 0000000..eeb15ad
--- /dev/null
+++ b/next/src/tools/evidence.ts
@@ -0,0 +1,59 @@
+import { eligibleSources, enclosingWords, findOnWordBoundary, hasWords, norm, sourcesContaining } from "../state/match";
+import type { Level, WriterRecord } from "../state/types";
+import { notFoundReason, quote } from "./common";
+
+/**
+ * The evidence rule for links and nesting at L0 and L1 (spec §5.2). Text comparisons
+ * and message order only: it never decides what the words mean.
+ *
+ * 1. One or two pieces, each word for word in the writer's eligible text.
+ * 2. Two pieces must come from two adjacent writer messages, in order.
+ * 3. Both cards' text appears inside the evidence.
+ * 4. With a label: it appears after the `from` card's text and before the `to` card's.
+ *
+ * Returns a refusal reason, or undefined when the evidence holds.
+ */
+export function checkEvidence(
+ evidence: string[],
+ ends: { fromText: string; toText: string },
+ label: string | null,
+ record: WriterRecord,
+ level: Level,
+): string | undefined {
+ if (evidence.length < 1 || evidence.length > 2) return "`evidence` must be one or two pieces of the writer's words.";
+ if (evidence.some((e) => !hasWords(e))) return "Every evidence piece needs at least one word.";
+
+ const sources = eligibleSources(record, level);
+ const found = evidence.map((piece) => sourcesContaining(piece, sources));
+ for (let i = 0; i < evidence.length; i++) {
+ if (!found[i]!.length) return notFoundReason(evidence[i]!, level, enclosingWords(evidence[i]!, sources));
+ }
+
+ if (evidence.length === 2) {
+ const first = found[0]!.filter((s) => s.kind === "chat").map((s) => s.index);
+ const second = new Set(found[1]!.filter((s) => s.kind === "chat").map((s) => s.index));
+ if (!first.some((i) => second.has(i + 1))) {
+ return "The two evidence pieces must come from two of your messages next to each other, in that order.";
+ }
+ }
+
+ const text = norm(evidence.join(" "));
+ const from = norm(ends.fromText);
+ const to = norm(ends.toText);
+ const fromAt = findOnWordBoundary(text, from);
+ if (fromAt === -1) return `The card ${quote(ends.fromText)} doesn't appear in the evidence.`;
+
+ if (label === null) {
+ if (findOnWordBoundary(text, to) === -1) return `The card ${quote(ends.toText)} doesn't appear in the evidence.`;
+ return undefined;
+ }
+
+ const l = norm(label);
+ if (!hasWords(l)) return "At this level a link's label must be the writer's own words from the evidence.";
+ const labelAt = findOnWordBoundary(text, l, fromAt + from.length);
+ if (labelAt === -1) return `The label ${quote(label)} doesn't appear in the evidence after ${quote(ends.fromText)}.`;
+ if (findOnWordBoundary(text, to, labelAt + l.length) === -1) {
+ return `The card ${quote(ends.toText)} doesn't appear in the evidence after the label ${quote(label)}.`;
+ }
+ return undefined;
+}
diff --git a/next/src/tools/index.ts b/next/src/tools/index.ts
new file mode 100644
index 0000000..eaffc3a
--- /dev/null
+++ b/next/src/tools/index.ts
@@ -0,0 +1,24 @@
+import type { Level } from "../state/types";
+import type { Tool } from "./common";
+import { proposeCard } from "./proposeCard";
+import { proposeLink } from "./proposeLink";
+import { proposeNest } from "./proposeNest";
+import { suggestIdea } from "./suggestIdea";
+
+/** All four are offered at every level (spec §5); handlers decide what each level allows. */
+export const TOOLS: Tool[] = [proposeCard, proposeLink, proposeNest, suggestIdea];
+
+export function toolByName(name: string): Tool | undefined {
+ return TOOLS.find((t) => t.name === name);
+}
+
+/** Responses API function-tool definitions for this level. */
+export function toolDefinitions(level: Level): unknown[] {
+ return TOOLS.map((t) => ({
+ type: "function",
+ name: t.name,
+ description: t.description(level),
+ parameters: t.parameters,
+ strict: true,
+ }));
+}
diff --git a/next/src/tools/proposeCard.ts b/next/src/tools/proposeCard.ts
new file mode 100644
index 0000000..3d14ffa
--- /dev/null
+++ b/next/src/tools/proposeCard.ts
@@ -0,0 +1,72 @@
+import { eligibleSources, enclosingWords, hasWords, norm, sourcesContaining } from "../state/match";
+import type { PieceSource, Provenance } from "../state/types";
+import { notFoundReason, quote, refuse, stringList, obj, type Tool } from "./common";
+
+/** Spec §5.1. The model writes the pieces; the handler checks each against the writer's words. */
+export const proposeCard: Tool = {
+ name: "propose_card",
+ description: (level) =>
+ [
+ "Propose one card for the writer's map, made only of the writer's own words.",
+ "`pieces` are joined in your order with a single space to form the card text; nothing else is added.",
+ level === 0
+ ? "Each piece must appear word for word in one of the writer's chat messages (any earlier message counts). The draft can't be quoted at this level."
+ : "Each piece must appear word for word in one of the writer's chat messages or in their current draft.",
+ "Quote exactly as the writer typed, typos included; don't correct spelling (the writer can fix it on the card after accepting). Pieces start and end on whole words.",
+ "Partial sentences are fine. The writer sees the card and accepts or rejects it.",
+ ].join(" "),
+ parameters: {
+ type: "object",
+ properties: { pieces: { type: "array", items: { type: "string" } } },
+ required: ["pieces"],
+ additionalProperties: false,
+ },
+ handle(args, ctx) {
+ const pieces = stringList(obj(args)?.pieces);
+ if (!pieces || pieces.length === 0) return refuse("`pieces` must be a non-empty list of strings.", "invalid arguments");
+ const text = pieces.join(" ").trim();
+ // Not shortened: the chat shows this and the writer must see the whole card to decide.
+ const summary = `"${text}"`;
+
+ if (pieces.some((p) => !hasWords(p))) return refuse("Every piece needs at least one word.", summary);
+
+ const turns = new Set();
+ let fromDraft = false;
+ const pieceSources: PieceSource[] = [];
+ if (ctx.checks) {
+ const sources = eligibleSources(ctx.session.record, ctx.level);
+ for (const piece of pieces) {
+ const all = sourcesContaining(piece, sources);
+ const found = all[0];
+ if (!found) {
+ const inDraft = ctx.level === 0 && sourcesContaining(piece, eligibleSources(ctx.session.record, 1)).some((s) => s.kind === "draft");
+ return refuse(
+ inDraft
+ ? `${quote(piece)} is in your draft, but at L0 cards can only use your chat messages.`
+ : notFoundReason(piece, ctx.level, enclosingWords(piece, sources)),
+ summary,
+ );
+ }
+ if (found.kind === "draft") fromDraft = true;
+ else turns.add(found.turn);
+ // Spec §5.1: record the most recent match, and every match.
+ pieceSources.push({
+ piece,
+ from: found.kind,
+ turn: found.kind === "chat" ? found.turn : null,
+ allTurns: [...new Set(all.filter((x) => x.kind === "chat").map((x) => x.turn))],
+ inDraft: all.some((x) => x.kind === "draft"),
+ });
+ }
+ }
+
+ const key = norm(text);
+ if (ctx.session.map.cards.some((c) => norm(c.text) === key)) return refuse(`A card with the text ${summary} is already on the map.`, summary);
+ if (ctx.session.proposals.some((p) => p.kind === "card" && p.status === "pending" && norm(p.text) === key)) {
+ return refuse(`A card with the text ${summary} is already proposed.`, summary);
+ }
+
+ const provenance: Provenance = fromDraft ? { kind: "draft" } : turns.size > 1 ? { kind: "combined", messages: turns.size } : { kind: "user" };
+ return { ok: true, summary, proposal: { id: ctx.nextProposalId(), status: "pending", kind: "card", text, provenance, sources: pieceSources } };
+ },
+};
diff --git a/next/src/tools/proposeLink.ts b/next/src/tools/proposeLink.ts
new file mode 100644
index 0000000..77b3910
--- /dev/null
+++ b/next/src/tools/proposeLink.ts
@@ -0,0 +1,63 @@
+import { hasLink, refText } from "../state/map";
+import { obj, quote, refuse, stringList, type Tool } from "./common";
+import { checkEvidence } from "./evidence";
+
+/** Spec §5.2. */
+export const proposeLink: Tool = {
+ name: "propose_link",
+ description: (level) =>
+ [
+ "Propose a labelled link from one card to another. Cards are referred to by id: an existing card (c3) or a card proposal still waiting for the writer (p5).",
+ level === 2
+ ? "At this level you may propose links freely; the label is your choice and the link is shown as AI suggested. `evidence` may be empty."
+ : "At this level the writer must have stated the link themselves. `evidence` is one piece of the writer's words, or two pieces from two of their messages next to each other. Both cards' text must appear in the evidence, and the label must be words from the evidence that sit after the `from` card and before the `to` card.",
+ ].join(" "),
+ parameters: {
+ type: "object",
+ properties: {
+ from_card: { type: "string" },
+ to_card: { type: "string" },
+ label: { type: "string" },
+ evidence: { type: "array", items: { type: "string" } },
+ },
+ required: ["from_card", "to_card", "label", "evidence"],
+ additionalProperties: false,
+ },
+ handle(args, ctx) {
+ const a = obj(args);
+ const from = typeof a?.from_card === "string" ? a.from_card : "";
+ const to = typeof a?.to_card === "string" ? a.to_card : "";
+ const label = typeof a?.label === "string" ? a.label.trim() : "";
+ const evidence = stringList(a?.evidence) ?? [];
+ const fromText = refText(ctx.session, from);
+ const toText = refText(ctx.session, to);
+ const summary = `${fromText ? quote(fromText) : from} → ${label || "(no label)"} → ${toText ? quote(toText) : to}`;
+
+ if (!fromText) return refuse(`There is no card or pending card proposal "${from}".`, summary);
+ if (!toText) return refuse(`There is no card or pending card proposal "${to}".`, summary);
+ if (from === to) return refuse("A link needs two different cards.", summary);
+ if (hasLink(ctx.session.map, from, to) || ctx.session.proposals.some((p) => p.kind === "link" && p.status === "pending" && p.from === from && p.to === to)) {
+ return refuse("That link is already on the map or already proposed.", summary);
+ }
+
+ const free = ctx.level === 2 || !ctx.checks;
+ if (!free) {
+ const reason = checkEvidence(evidence, { fromText, toText }, label, ctx.session.record, ctx.level);
+ if (reason) return refuse(reason, summary);
+ }
+
+ return {
+ ok: true,
+ summary,
+ proposal: {
+ id: ctx.nextProposalId(),
+ status: "pending",
+ kind: "link",
+ from,
+ to,
+ label,
+ provenance: free ? { kind: "ai_suggested" } : { kind: "user" },
+ },
+ };
+ },
+};
diff --git a/next/src/tools/proposeNest.ts b/next/src/tools/proposeNest.ts
new file mode 100644
index 0000000..78c72ba
--- /dev/null
+++ b/next/src/tools/proposeNest.ts
@@ -0,0 +1,55 @@
+import { findCard, refText, wouldCycle } from "../state/map";
+import { obj, quote, refuse, stringList, type Tool } from "./common";
+import { checkEvidence } from "./evidence";
+
+/** Spec §5.3. */
+export const proposeNest: Tool = {
+ name: "propose_nest",
+ description: (level) =>
+ [
+ "Propose putting one card under another (child under parent). Cards are referred to by id: an existing card (c3) or a card proposal still waiting for the writer (p5).",
+ level === 2
+ ? "At this level you may propose nesting freely; it is shown as AI suggested. `evidence` may be empty."
+ : "At this level the writer must have stated it themselves. `evidence` is one piece of the writer's words, or two pieces from two of their messages next to each other, and both cards' text must appear in it.",
+ ].join(" "),
+ parameters: {
+ type: "object",
+ properties: {
+ child: { type: "string" },
+ parent: { type: "string" },
+ evidence: { type: "array", items: { type: "string" } },
+ },
+ required: ["child", "parent", "evidence"],
+ additionalProperties: false,
+ },
+ handle(args, ctx) {
+ const a = obj(args);
+ const child = typeof a?.child === "string" ? a.child : "";
+ const parent = typeof a?.parent === "string" ? a.parent : "";
+ const evidence = stringList(a?.evidence) ?? [];
+ const childText = refText(ctx.session, child);
+ const parentText = refText(ctx.session, parent);
+ const summary = `${childText ? quote(childText) : child} under ${parentText ? quote(parentText) : parent}`;
+
+ if (!childText) return refuse(`There is no card or pending card proposal "${child}".`, summary);
+ if (!parentText) return refuse(`There is no card or pending card proposal "${parent}".`, summary);
+ if (child === parent) return refuse("A card can't be nested under itself.", summary);
+ if (findCard(ctx.session.map, child)?.parentId === parent) return refuse("That card is already under that parent.", summary);
+ if (wouldCycle(ctx.session.map, child, parent)) return refuse("That nesting would make a loop: the parent is already inside the child.", summary);
+ if (ctx.session.proposals.some((p) => p.kind === "nest" && p.status === "pending" && p.child === child && p.parent === parent)) {
+ return refuse("That nesting is already proposed.", summary);
+ }
+
+ const free = ctx.level === 2 || !ctx.checks;
+ if (!free) {
+ const reason = checkEvidence(evidence, { fromText: childText, toText: parentText }, null, ctx.session.record, ctx.level);
+ if (reason) return refuse(reason, summary);
+ }
+
+ return {
+ ok: true,
+ summary,
+ proposal: { id: ctx.nextProposalId(), status: "pending", kind: "nest", child, parent, provenance: free ? { kind: "ai_suggested" } : { kind: "user" } },
+ };
+ },
+};
diff --git a/next/src/tools/suggestIdea.ts b/next/src/tools/suggestIdea.ts
new file mode 100644
index 0000000..b87f089
--- /dev/null
+++ b/next/src/tools/suggestIdea.ts
@@ -0,0 +1,30 @@
+import { refText } from "../state/map";
+import { obj, refuse, type Tool } from "./common";
+
+/**
+ * Spec §5.4. Offered at every level but refused below L2, so the model meets the
+ * boundary and can draw the idea out of the writer instead.
+ */
+export const suggestIdea: Tool = {
+ name: "suggest_idea",
+ description: (level) =>
+ level === 2
+ ? "Suggest an idea of your own. It is shown to the writer as an AI suggestion, and they accept or reject it. `near_card` optionally names the card it relates to, or null."
+ : `Suggest an idea of your own. Only works at L2; at L${level} it will be refused.`,
+ parameters: {
+ type: "object",
+ properties: { text: { type: "string" }, near_card: { type: ["string", "null"] } },
+ required: ["text", "near_card"],
+ additionalProperties: false,
+ },
+ handle(args, ctx) {
+ const a = obj(args);
+ const text = typeof a?.text === "string" ? a.text.trim() : "";
+ const summary = text ? `"${text}"` : "(empty)";
+ if (ctx.level !== 2 && ctx.checks) return refuse(`suggest_idea isn't available at L${ctx.level}.`, summary);
+ if (!text) return refuse("A suggestion needs some text.", summary);
+ const near = typeof a?.near_card === "string" && a.near_card ? a.near_card : null;
+ if (near && !refText(ctx.session, near)) return refuse(`There is no card or pending card proposal "${near}".`, summary);
+ return { ok: true, summary, proposal: { id: ctx.nextProposalId(), status: "pending", kind: "suggestion", text, nearCard: near } };
+ },
+};
diff --git a/next/src/tools/tools.test.ts b/next/src/tools/tools.test.ts
new file mode 100644
index 0000000..3390904
--- /dev/null
+++ b/next/src/tools/tools.test.ts
@@ -0,0 +1,234 @@
+import { describe, expect, it } from "vitest";
+import { acceptProposal } from "../state/map";
+import { emptySession, type Level, type Session } from "../state/types";
+import type { HandlerResult, Tool, ToolContext } from "./common";
+import { proposeCard } from "./proposeCard";
+import { proposeLink } from "./proposeLink";
+import { proposeNest } from "./proposeNest";
+import { suggestIdea } from "./suggestIdea";
+
+function session(messages: string[], opts: { draft?: string; level?: Level } = {}): Session {
+ const s = emptySession();
+ return {
+ ...s,
+ level: opts.level ?? 0,
+ record: { messages: messages.map((text, i) => ({ turn: i + 1, text })), draft: opts.draft ?? "" },
+ };
+}
+
+function run(tool: Tool, args: unknown, s: Session, level: Level = s.level): HandlerResult {
+ let n = s.counters.proposal;
+ const ctx: ToolContext = { session: s, level, checks: true, nextProposalId: () => `p${++n}` };
+ return tool.handle(args, ctx);
+}
+
+/** Adds a proposal to the session, as the loop would. */
+function apply(s: Session, r: HandlerResult): Session {
+ if (!r.ok) throw new Error(r.reason);
+ return { ...s, proposals: [...s.proposals, r.proposal], counters: { ...s.counters, proposal: s.counters.proposal + 1 } };
+}
+
+describe("propose_card", () => {
+ it("accepts verbatim pieces from any earlier message at L0", () => {
+ const s = session(["I like X but I'm not sure", "because X is bad"]);
+ const r = run(proposeCard, { pieces: ["I like X"] }, s);
+ expect(r.ok).toBe(true);
+ if (r.ok && r.proposal.kind === "card") {
+ expect(r.proposal.text).toBe("I like X");
+ expect(r.proposal.provenance).toEqual({ kind: "user" });
+ }
+ });
+
+ it("labels a card built from two messages", () => {
+ const s = session(["I like X but I'm not sure", "because X is bad"]);
+ const r = run(proposeCard, { pieces: ["I like X", "because X is bad"] }, s);
+ expect(r.ok && r.proposal.kind === "card" && r.proposal.provenance).toEqual({ kind: "combined", messages: 2 });
+ });
+
+ it("refuses words the writer didn't write", () => {
+ const r = run(proposeCard, { pieces: ["X is good"] }, session(["I don't think X is great"]));
+ expect(r).toMatchObject({ ok: false, reason: '"X is good" is not in your messages.' });
+ });
+
+ it("allows partial pieces, including one that drops a negation (documented gap)", () => {
+ expect(run(proposeCard, { pieces: ["X is great"] }, session(["I don't think X is great"])).ok).toBe(true);
+ });
+
+ it("refuses a piece that starts or ends mid-word", () => {
+ expect(run(proposeCard, { pieces: ["ike X"] }, session(["I like X"])).ok).toBe(false);
+ });
+
+ it("matches across case, curly quotes and extra spaces", () => {
+ expect(run(proposeCard, { pieces: ["it's GOOD"] }, session(["I think it’s good"])).ok).toBe(true);
+ });
+
+ it("says the draft can't be quoted at L0", () => {
+ const r = run(proposeCard, { pieces: ["the draft line"] }, session(["hello"], { draft: "This is the draft line." }));
+ expect(r).toMatchObject({ ok: false, reason: '"the draft line" is in your draft, but at L0 cards can only use your chat messages.' });
+ });
+
+ it("allows the draft at L1 and labels it", () => {
+ const s = session(["hello"], { draft: "This is the draft line.", level: 1 });
+ const r = run(proposeCard, { pieces: ["the draft line"] }, s);
+ expect(r.ok && r.proposal.kind === "card" && r.proposal.provenance).toEqual({ kind: "draft" });
+ });
+
+ it("refuses empty pieces and duplicates", () => {
+ const s = session(["I like X"]);
+ expect(run(proposeCard, { pieces: [] }, s).ok).toBe(false);
+ expect(run(proposeCard, { pieces: [" ,"] }, s).ok).toBe(false);
+ const withCard = apply(s, run(proposeCard, { pieces: ["I like X"] }, s));
+ expect(run(proposeCard, { pieces: ["i like x"] }, withCard)).toMatchObject({ ok: false, reason: 'A card with the text "i like x" is already proposed.' });
+ });
+});
+
+describe("propose_link and propose_nest: the evidence rule", () => {
+ // Two cards from the writer's words, accepted onto the map as c1 and c2.
+ function withCards(messages: string[], a: string, b: string, level: Level = 0): Session {
+ let s = session(messages, { level });
+ s = apply(s, run(proposeCard, { pieces: [a] }, s));
+ s = apply(s, run(proposeCard, { pieces: [b] }, s));
+ s = acceptProposal(acceptProposal(s, "p1"), "p2");
+ return s;
+ }
+
+ it("accepts a link the writer stated in one message", () => {
+ const s = withCards(["I like X because X is bad"], "I like X", "X is bad");
+ expect(run(proposeLink, { from_card: "c1", to_card: "c2", label: "because", evidence: ["I like X because X is bad"] }, s).ok).toBe(true);
+ });
+
+ it("accepts a link stated across two adjacent messages (the 'I like X… because X is bad' case)", () => {
+ const s = withCards(["I like X but I'm not sure", "because X is bad"], "I like X", "X is bad");
+ const r = run(proposeLink, { from_card: "c1", to_card: "c2", label: "because", evidence: ["I like X", "because X is bad"] }, s);
+ expect(r.ok).toBe(true);
+ expect(r.ok && r.proposal.kind === "link" && r.proposal.provenance).toEqual({ kind: "user" });
+ });
+
+ it("refuses two pieces from messages that aren't next to each other", () => {
+ const s = withCards(["I like X", "something else", "because X is bad"], "I like X", "X is bad");
+ const r = run(proposeLink, { from_card: "c1", to_card: "c2", label: "because", evidence: ["I like X", "because X is bad"] }, s);
+ expect(r).toMatchObject({ ok: false, reason: "The two evidence pieces must come from two of your messages next to each other, in that order." });
+ });
+
+ it("refuses two pieces in the wrong order", () => {
+ const s = withCards(["because X is bad", "I like X"], "I like X", "X is bad");
+ expect(run(proposeLink, { from_card: "c1", to_card: "c2", label: "because", evidence: ["I like X", "because X is bad"] }, s).ok).toBe(false);
+ });
+
+ it("refuses a label that isn't between the two cards", () => {
+ const s = withCards(["because I like X, X is bad"], "I like X", "X is bad");
+ const r = run(proposeLink, { from_card: "c1", to_card: "c2", label: "because", evidence: ["because I like X, X is bad"] }, s);
+ expect(r).toMatchObject({ ok: false, reason: 'The label "because" doesn\'t appear in the evidence after "I like X".' });
+ });
+
+ it("refuses a label the writer didn't write", () => {
+ const s = withCards(["I like X because X is bad"], "I like X", "X is bad");
+ expect(run(proposeLink, { from_card: "c1", to_card: "c2", label: "therefore", evidence: ["I like X because X is bad"] }, s).ok).toBe(false);
+ });
+
+ it("refuses evidence missing one card", () => {
+ const s = withCards(["I like X", "and Y matters"], "I like X", "Y matters");
+ const r = run(proposeLink, { from_card: "c1", to_card: "c2", label: "and", evidence: ["I like X"] }, s);
+ expect(r.ok).toBe(false);
+ });
+
+ it("can link to a card proposed earlier in the same turn", () => {
+ let s = session(["I like X because X is bad"]);
+ s = apply(s, run(proposeCard, { pieces: ["I like X"] }, s));
+ s = apply(s, run(proposeCard, { pieces: ["X is bad"] }, s));
+ expect(run(proposeLink, { from_card: "p1", to_card: "p2", label: "because", evidence: ["I like X because X is bad"] }, s).ok).toBe(true);
+ });
+
+ it("allows free links at L2, shown as AI suggested", () => {
+ const s = withCards(["I like X", "Y matters"], "I like X", "Y matters", 2);
+ const r = run(proposeLink, { from_card: "c1", to_card: "c2", label: "leads to", evidence: [] }, s);
+ expect(r.ok && r.proposal.kind === "link" && r.proposal.provenance).toEqual({ kind: "ai_suggested" });
+ });
+
+ it("refuses links between missing, identical or already linked cards", () => {
+ const s = withCards(["I like X because X is bad"], "I like X", "X is bad", 2);
+ expect(run(proposeLink, { from_card: "c9", to_card: "c2", label: "", evidence: [] }, s).ok).toBe(false);
+ expect(run(proposeLink, { from_card: "c1", to_card: "c1", label: "", evidence: [] }, s).ok).toBe(false);
+ const linked = apply(s, run(proposeLink, { from_card: "c1", to_card: "c2", label: "x", evidence: [] }, s));
+ expect(run(proposeLink, { from_card: "c1", to_card: "c2", label: "y", evidence: [] }, linked).ok).toBe(false);
+ });
+
+ it("nests with evidence at L0 and refuses loops", () => {
+ const s = withCards(["put X is bad under I like X"], "I like X", "X is bad");
+ const r = run(proposeNest, { child: "c2", parent: "c1", evidence: ["put X is bad under I like X"] }, s);
+ expect(r.ok).toBe(true);
+ const nested = acceptProposal(apply(s, r), "p3");
+ expect(run(proposeNest, { child: "c1", parent: "c2", evidence: ["put X is bad under I like X"] }, nested)).toMatchObject({ ok: false });
+ });
+
+ it("refuses nesting without evidence at L0", () => {
+ const s = withCards(["I like X", "Y matters"], "I like X", "Y matters");
+ expect(run(proposeNest, { child: "c2", parent: "c1", evidence: [] }, s).ok).toBe(false);
+ });
+});
+
+describe("suggest_idea", () => {
+ it("is refused at L0 and L1", () => {
+ expect(run(suggestIdea, { text: "Try Z", near_card: null }, session([], { level: 0 }))).toMatchObject({ ok: false, reason: "suggest_idea isn't available at L0." });
+ expect(run(suggestIdea, { text: "Try Z", near_card: null }, session([], { level: 1 }))).toMatchObject({ ok: false, reason: "suggest_idea isn't available at L1." });
+ });
+
+ it("works at L2", () => {
+ const r = run(suggestIdea, { text: "Try Z", near_card: null }, session([], { level: 2 }));
+ expect(r.ok && r.proposal.kind).toBe("suggestion");
+ });
+});
+
+describe("review fixes: sources and nesting marks", () => {
+ it("records the most recent match and every match for each piece (spec §5.1)", () => {
+ const s = session(["I like X", "honestly I like X", "because X is bad"], { draft: "I like X a lot", level: 1 });
+ const r = run(proposeCard, { pieces: ["I like X", "X is bad"] }, s);
+ expect(r.ok && r.proposal.kind === "card" && r.proposal.sources).toEqual([
+ { piece: "I like X", from: "chat", turn: 2, allTurns: [2, 1], inDraft: true },
+ { piece: "X is bad", from: "chat", turn: 3, allTurns: [3], inDraft: false },
+ ]);
+ });
+
+ it("marks a nesting proposed at L2 as AI suggested, and keeps the mark once accepted", () => {
+ let s = session(["A and B"], { level: 2 });
+ s = { ...s, map: { cards: [{ id: "c1", text: "A", parentId: null, provenance: { kind: "user" }, x: 0, y: 0 }, { id: "c2", text: "B", parentId: null, provenance: { kind: "user" }, x: 300, y: 0 }], links: [] }, counters: { ...s.counters, card: 2 } };
+ const r = run(proposeNest, { child: "c2", parent: "c1", evidence: [] }, s);
+ expect(r.ok && r.proposal.kind === "nest" && r.proposal.provenance).toEqual({ kind: "ai_suggested" });
+ s = acceptProposal(apply(s, r), "p1");
+ expect(s.map.cards.find((c) => c.id === "c2")).toMatchObject({ parentId: "c1", parentProvenance: { kind: "ai_suggested" } });
+ });
+
+ it("a nesting the writer stated (L0) carries no AI mark", () => {
+ let s = session(["B is part of A"]);
+ s = { ...s, map: { cards: [{ id: "c1", text: "A", parentId: null, provenance: { kind: "user" }, x: 0, y: 0 }, { id: "c2", text: "B", parentId: null, provenance: { kind: "user" }, x: 300, y: 0 }], links: [] }, counters: { ...s.counters, card: 2 } };
+ const r = run(proposeNest, { child: "c2", parent: "c1", evidence: ["B is part of A"] }, s);
+ s = acceptProposal(apply(s, r), "p1");
+ expect(s.map.cards.find((c) => c.id === "c2")?.parentProvenance).toBeUndefined();
+ });
+});
+
+describe("typos (Nhyira, option A: quote exactly as typed)", () => {
+ it("accepts a typo quoted as typed", () => {
+ const s = session(["how harnesses could be better defveloped for those cases"]);
+ expect(run(proposeCard, { pieces: ["how harnesses could be better defveloped"] }, s).ok).toBe(true);
+ });
+
+ it("says when the words are there only inside a longer word, so the model can retry", () => {
+ const s = session(["I want ai agent workflows and lik ethe creative work intersection"]);
+ const r = run(proposeCard, { pieces: ["the creative work intersection"] }, s);
+ expect(r.ok).toBe(false);
+ if (!r.ok) expect(r.reason).toBe('"the creative work intersection" is in your messages only inside longer words ("ethe creative work intersection"). Quote whole words, exactly as typed.');
+ expect(run(proposeCard, { pieces: ["creative work intersection"] }, s).ok).toBe(true);
+ });
+
+ it("gives the same clearer reason for link evidence", () => {
+ let s = session(["lik ethe creative work so it matters"]);
+ s = { ...s, map: { cards: [{ id: "c1", text: "creative work", parentId: null, provenance: { kind: "user" }, x: 0, y: 0 }, { id: "c2", text: "it matters", parentId: null, provenance: { kind: "user" }, x: 0, y: 0 }], links: [] } };
+ const r = run(proposeLink, { from_card: "c1", to_card: "c2", label: "so", evidence: ["the creative work so it matters"] }, s);
+ expect(!r.ok && r.reason).toContain("only inside longer words");
+ });
+
+ it("tells the model to quote typos as typed", () => {
+ expect(proposeCard.description(0)).toContain("typos included");
+ });
+});
diff --git a/next/src/ui/App.tsx b/next/src/ui/App.tsx
new file mode 100644
index 0000000..cd5be7e
--- /dev/null
+++ b/next/src/ui/App.tsx
@@ -0,0 +1,205 @@
+import { ReactFlowProvider } from "@xyflow/react";
+import { useEffect, useMemo, useRef, useState } from "react";
+import { makeCallResponses } from "../loop/client";
+import { runTurn } from "../loop/turn";
+import { mapToMarkdown } from "../state/export";
+import {
+ acceptProposal,
+ addCard,
+ addLink,
+ deleteCard,
+ deleteLink,
+ editCard,
+ editLinkLabel,
+ moveCard,
+ rejectProposal,
+ setParent,
+ undo,
+} from "../state/map";
+import { loadSession, saveSession } from "../state/persist";
+import { emptySession, type Level, type Session } from "../state/types";
+import { Chat } from "./Chat";
+import { copyText } from "./clipboard";
+import { MapView, type MapActions } from "./MapView";
+
+const LEVELS: { value: Level; label: string }[] = [
+ { value: 0, label: "L0 · cards from your messages" },
+ { value: 1, label: "L1 · also from your draft" },
+ { value: 2, label: "L2 · coach may suggest" },
+];
+
+interface AppProps {
+ /** Writing Tools access token; absent in local dev without sign-in. */
+ bearerToken?: string;
+ onAccessError?: (status: 401 | 403) => void;
+ /** The signed-in account isn't allowed to use the AI (403). */
+ aiAccessDenied?: boolean;
+ /** Shown as a toolbar button when signed in. */
+ onDisconnect?: () => void;
+}
+
+export function App({ bearerToken, onAccessError, aiAccessDenied = false, onDisconnect }: AppProps = {}) {
+ const [session, setSession] = useState(() => loadSession());
+ const [busy, setBusy] = useState(false);
+ const [editId, setEditId] = useState(null);
+ const [showDraft, setShowDraft] = useState(true);
+ const [flash, setFlash] = useState(null);
+ const [saved, setSaved] = useState(true);
+
+ // Refs so callbacks always see the latest values without being rebuilt.
+ const current = useRef(session);
+ current.current = session;
+ const busyRef = useRef(busy);
+ busyRef.current = busy;
+ /** A card just made with "+ Card"; if it's left empty, it goes away again. */
+ const fresh = useRef(null);
+
+ useEffect(() => {
+ setSaved(saveSession(session));
+ }, [session]);
+
+ useEffect(() => {
+ if (!flash) return;
+ const t = setTimeout(() => setFlash(null), 2000);
+ return () => clearTimeout(t);
+ }, [flash]);
+
+ /** Every writer edit goes through here; nothing changes while a turn runs (it works from a snapshot). */
+ const act = (fn: (s: Session) => Session) => {
+ if (!busyRef.current) setSession((s) => fn(s));
+ };
+
+ const actions = useMemo(
+ () => ({
+ move: (id, x, y) => act((s) => moveCard(s, id, x, y)),
+ nest: (child, parent) => act((s) => setParent(s, child, parent)),
+ takeOutAt: (id, x, y) => act((s) => moveCard(setParent(s, id, null), id, x, y)),
+ takeOut: (id) => act((s) => setParent(s, id, null)),
+ finishEdit: (id, text) => {
+ const isFresh = fresh.current === id;
+ if (isFresh) fresh.current = null;
+ const final = text ?? current.current.map.cards.find((c) => c.id === id)?.text ?? "";
+ // Undo the "+ Card" step rather than delete, so no extra undo step is left behind.
+ if (isFresh && !final.trim()) act(undo);
+ else if (text !== null) act((s) => editCard(s, id, text));
+ },
+ remove: (id) => act((s) => deleteCard(s, id)),
+ link: (from, to) => {
+ if (busyRef.current) return;
+ const label = window.prompt("Label for this link (optional):", "");
+ if (label !== null) act((s) => addLink(s, from, to, label.trim()));
+ },
+ relabel: (linkId) => {
+ const link = current.current.map.links.find((l) => l.id === linkId);
+ if (!link || busyRef.current) return;
+ const label = window.prompt("Link label:", link.label);
+ if (label !== null) act((s) => editLinkLabel(s, linkId, label.trim()));
+ },
+ unlink: (linkId) => act((s) => deleteLink(s, linkId)),
+ }),
+ [],
+ );
+
+ const send = async (text: string) => {
+ if (busyRef.current) return;
+ setEditId(null);
+ setBusy(true);
+ try {
+ const done = await runTurn(current.current, text, { callModel: makeCallResponses({ bearerToken, onAccessError }), onUpdate: setSession });
+ setSession(done);
+ } catch (error) {
+ // runTurn reports its own errors; this only catches a failure outside it.
+ const note = `Something went wrong: ${error instanceof Error ? error.message : String(error)}`;
+ setSession((s) => ({ ...s, transcript: [...s.transcript, { kind: "notice", text: note }] }));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const newCard = () => {
+ if (busyRef.current) return;
+ // Let go of the button at once, so a space or Enter typed before the new card's
+ // editor takes focus can't press "+ Card" again.
+ (document.activeElement as HTMLElement | null)?.blur();
+ const id = `c${current.current.counters.card + 1}`;
+ act((s) => addCard(s, ""));
+ fresh.current = id;
+ setEditId(id);
+ };
+
+ const copy = async (what: "map" | "draft") => {
+ const text = what === "map" ? mapToMarkdown(session.map) : session.record.draft;
+ setFlash((await copyText(text)) ? `Copied ${what}` : `Couldn't copy the ${what}`);
+ };
+
+ const reset = () => {
+ if (!busyRef.current && window.confirm("Start a new session? This clears the chat, map and draft in this browser.")) setSession(emptySession());
+ };
+
+ return (
+
+
+
+ applies from your next message
+
+
+
+
+
+
+
+
+ {onDisconnect && }
+
+
+ {aiAccessDenied && (
+
+ This account isn't enabled for Writing Tools AI. Contact the Writing Tools team for access. Your draft and map stay available.
+
+ )}
+ {/* Not allowed blocks sending only; accepting or rejecting proposals is a map edit. */}
+ act((s) => acceptProposal(s, id))} onReject={(id) => act((s) => rejectProposal(s, id))} />
+
+
+
+
+
+ {session.map.cards.length === 0 &&
No cards yet
}
+
Double-click to edit · drop a card on another to nest · drag out or "Out" to separate · drag from a card's dot to link · select a link + Delete to remove