From 83048533511f8e45ab9e654780d2b5aa79760564 Mon Sep 17 00:00:00 2001 From: nhyiramante1 <196460314+nhyiramante1@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:59:06 -0400 Subject: [PATCH 01/12] docs: spec the tools-first pass Plan for a fresh Mindmap in next/ where the coach reasons freely and its map actions are checked inside the tool handlers when they execute: levels, the four tools and their checks, the attempt budget, what the model sees on each call, what the writer sees, and the v1 scope. Co-Authored-By: Claude Opus 5.5 --- docs/tools-first-pass.md | 252 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 docs/tools-first-pass.md diff --git a/docs/tools-first-pass.md b/docs/tools-first-pass.md new file mode 100644 index 0000000..5062d30 --- /dev/null +++ b/docs/tools-first-pass.md @@ -0,0 +1,252 @@ +# 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. | +| 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 | Kept as it works today, behind an on/off setting. | +| 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). +- **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. + +### 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`. + +### 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. + +### 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`. +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; the writer record keeps everything, so old quotes + still check. +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", "rejected p4"), the draft, and `level · attempts left`. +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** ported from `src/map/suggestion-adoption.ts`, behind its + setting. +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. + +## 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/`. From e77dea77c7e02a493e442330ca0a0965cb5de1a5 Mon Sep 17 00:00:00 2001 From: nhyiramante1 <196460314+nhyiramante1@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:27:56 -0400 Subject: [PATCH 02/12] feat(next): tools, handlers and the tool loop for the tools-first pass Core of next/ per docs/tools-first-pass.md: the writer record and matching rule, the map with proposals and undo, the four tools (propose_card, propose_link, propose_nest, suggest_idea) with their checks in the handlers, the evidence rule for links and nesting below L2, the context builder, the Responses client, and the turn loop with the 3-refusal budget and an 8-call cap. Suggestion adoption uses shared three-word runs instead of a common-word list; the spec is updated to match. 38 tests. Co-Authored-By: Claude Opus 5.5 --- docs/tools-first-pass.md | 10 +- next/index.html | 12 + next/package-lock.json | 2599 +++++++++++++++++++++++++++++++++ next/package.json | 26 + next/src/loop/client.ts | 41 + next/src/loop/context.ts | 81 + next/src/loop/turn.test.ts | 89 ++ next/src/loop/turn.ts | 150 ++ next/src/state/adoption.ts | 68 + next/src/state/map.ts | 178 +++ next/src/state/match.ts | 66 + next/src/state/state.test.ts | 69 + next/src/state/types.ts | 116 ++ next/src/tools/common.ts | 45 + next/src/tools/evidence.ts | 59 + next/src/tools/index.ts | 24 + next/src/tools/proposeCard.ts | 60 + next/src/tools/proposeLink.ts | 63 + next/src/tools/proposeNest.ts | 50 + next/src/tools/suggestIdea.ts | 30 + next/src/tools/tools.test.ts | 180 +++ next/tsconfig.json | 20 + next/vite.config.ts | 8 + 23 files changed, 4041 insertions(+), 3 deletions(-) create mode 100644 next/index.html create mode 100644 next/package-lock.json create mode 100644 next/package.json create mode 100644 next/src/loop/client.ts create mode 100644 next/src/loop/context.ts create mode 100644 next/src/loop/turn.test.ts create mode 100644 next/src/loop/turn.ts create mode 100644 next/src/state/adoption.ts create mode 100644 next/src/state/map.ts create mode 100644 next/src/state/match.ts create mode 100644 next/src/state/state.test.ts create mode 100644 next/src/state/types.ts create mode 100644 next/src/tools/common.ts create mode 100644 next/src/tools/evidence.ts create mode 100644 next/src/tools/index.ts create mode 100644 next/src/tools/proposeCard.ts create mode 100644 next/src/tools/proposeLink.ts create mode 100644 next/src/tools/proposeNest.ts create mode 100644 next/src/tools/suggestIdea.ts create mode 100644 next/src/tools/tools.test.ts create mode 100644 next/tsconfig.json create mode 100644 next/vite.config.ts diff --git a/docs/tools-first-pass.md b/docs/tools-first-pass.md index 5062d30..7fb76b9 100644 --- a/docs/tools-first-pass.md +++ b/docs/tools-first-pass.md @@ -38,7 +38,7 @@ the rules are stated, and enforced only when an action executes. | 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. | | 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 | Kept as it works today, behind an on/off setting. | +| 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 @@ -225,8 +225,12 @@ Keep it simple: no extra panels or diagnostics in v1. 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** ported from `src/map/suggestion-adoption.ts`, behind its - setting. +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 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 @@ + + + + + + Mindmap · tools-first + + +
+ + + diff --git a/next/package-lock.json b/next/package-lock.json new file mode 100644 index 0000000..2b0e555 --- /dev/null +++ b/next/package-lock.json @@ -0,0 +1,2599 @@ +{ + "name": "mindmap-next", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mindmap-next", + "version": "0.0.1", + "dependencies": { + "@xyflow/react": "^12.3.5", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^6.0.3", + "vitest": "^3.2.4" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.11.tgz", + "integrity": "sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.11.tgz", + "integrity": "sha512-OZ6+oaK7GMyg3IfCpj8JOmn8n+w2rJbDT0LLfVDTLb1ZFeKDh3AiNELgCMUw175IJHSkLl74hG0M0VLsvx+gag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.11" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", + "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.9", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.11.tgz", + "integrity": "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.11", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.66.0.tgz", + "integrity": "sha512-7y4XeoUHPecjfUDRmap8U0q6bjfLAPn7uOGT8BKyqy8uNDOjsGS8rlBhOx6sZLVwwjN6hapvGl2mW9/IS6UNww==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.66.0.tgz", + "integrity": "sha512-e8WugYZXpgqrlMXLCudYVRAxAcK7AIY36JxRDwFRD38L9q62G1VsEUW6ijXxzjxYjNB8i1zo3B/ycIQ/qjLC/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.66.0.tgz", + "integrity": "sha512-6vkDCfyDjNLokh+Rt1D2KtNgFFMEPdkWha/mbtP4cSjh9xiN8T/JBS/A7nDYKjmMAXHrvR9J3AzUk2LtFbIBLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.66.0.tgz", + "integrity": "sha512-/mzZAX0+Q6eXxvkR0HB0RZCCrsbmFD8DpsvCQoMYABDfq/DjqkbfytuJ7GvZ4AhR/Mgis2fXbZrGqfbcK2Z5fA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.66.0.tgz", + "integrity": "sha512-JQJx9E8tUjGqed1RevDmoHaS3V30ODdV8uyvGxlwd4K5se6bkPrMeltSybqAtpJ+ltDRfo7FORQaJG2Hd5nGog==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.66.0.tgz", + "integrity": "sha512-GEaUQQjMaG7X1304qMgqLjzCKv6CdDirzJSS+UqUs3xhJ9X2Kjzu0JDbLoUU42FWxwmZqz+SsgU1SlQv9GNRVA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.66.0.tgz", + "integrity": "sha512-DgmAU9N0NhDQDzHgFUocDio7zRO8jR/nY/obDGvCR8zbJ6J77Fg6X49yPuExJUpYPbC82EOaRhG969pWzUSnHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.66.0.tgz", + "integrity": "sha512-zG09YZ+sk/HoHq+P9gYB0EseQ3M20RbDuKGWey6fbiMDV1LxfXvhgSW9jDaCcvFPMgfLXpimzXY+UYvA8R2mLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.66.0.tgz", + "integrity": "sha512-vlArhgyXbcVc1D4WZPoiYuqJIaMiSHBj6etRXua0KlfjlZeuiQyVAPYi0/N49O4mSp3u1iGUBLEjNa8ec81XZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.66.0.tgz", + "integrity": "sha512-RxJZtk0G87YPotDaRDvRqStV8cbqLSC5TroHvQHHKsqBg0ok64RxKgtXeFsa7FBcy7mb4crBrG3xK8USWIqWNA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.66.0.tgz", + "integrity": "sha512-qlGcw959PrLLkZhiBxF3OZ6SQvj+OKY85mCqGNgSRs4LZlDJ6ICqmz6UdxtINdhcXYwNIbZoapQVBAmZlD2hUQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.66.0.tgz", + "integrity": "sha512-TvTI/7Cy0WvRA1SXgwzu6bwXBnMpmeT/ln5q7WvSpqmXy1VbmEnY8HVIDQNBiaHJvoWqRr0M4FNo7qiqtyMaFw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.66.0.tgz", + "integrity": "sha512-UZEDkSoDRZ50NyLj70nO6mpIK3dvs2cPTTvQb1bA/PBDmr9fvn5KiESuGfh6srd7Zi6Lt9Sv2sVrFr1QoHLvPQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.66.0.tgz", + "integrity": "sha512-VRbxTvagLh3HRcysx1CNq+HHcvJbSebNI6fCbk7jwmmWHkXjrSIV3igXBpRHUTFdg0pagWGu50xoSZXnrZHjVQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.66.0.tgz", + "integrity": "sha512-uWdJpvYqh0LE/i+YBL3sMX0a2kUkICENjmrPmyBmMOFc3jMrs6MSQH/pMD+VIpGEoUcr3XkHGLLfymzIqPSRRg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.66.0.tgz", + "integrity": "sha512-3FUd88pxgWAjK60OgCrika1AcNG+u8zaCdWjg4TlcZTDkt5TGyci8ZU64l3OJ9ax0MEjw3P80B/3PoSQDCLwJQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.66.0.tgz", + "integrity": "sha512-qT2rMmgyej62g5AdwktK+Bt2lBu497pzWZe12TTQ5JmZjOYPnEwcPEAOSLTBxBV+6gIES75rXm219v/QkT397A==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.66.0.tgz", + "integrity": "sha512-E46rnZgp7J84Qwe8RMlF+rgimnDo/9fuiGbUArGB0ZkMWf97O7yeSCfKls5x/YJOF0RcuSe9RctuKe1hlxICiA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.66.0.tgz", + "integrity": "sha512-Uz1HiZe+qBzqgdPsON+z0wRx3WdATuyzwvGT6cr2VZVrqqko0ZJVfkrQJtwDMuDcAfXYdo7XucaxJ7lTLjEcgg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.66.0.tgz", + "integrity": "sha512-Ph7TX3Ex5qdfcb7Tc6eEPugVkRfJ501eTovO4T5029YObfpZ7dzn3RiwpFvGp50nk2QUhvBMZ0PFGlEj6pOYTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.66.0.tgz", + "integrity": "sha512-nZYPEya7ZkeNxUEiF7qmNZbyLTyRN3MK5cO22KqsaoneUzXJuzBIQRw+7BEQy9LGj2O5vrCE9V3EpK06FYo1tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.66.0.tgz", + "integrity": "sha512-u7aTfS3R6C4hRsqXSzaO6qLZYLVuMkHMvcWlux4IM8IJif1VCl2cgPBSo5gSNoFoStQypQP+qwq8W1IyFBf1QQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.66.0.tgz", + "integrity": "sha512-e+i9ueHrH1Bafb0cf0woAIVJbYcoC8vRaD+vz+Xi8w6jz9cYJSV3vyKO6ELGSZ6g4W4MJOm2WPLN4/nQf77h7w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.66.0.tgz", + "integrity": "sha512-KfTCbrtr2zXTqH2E1F+uYlFTrYibxHKqPAHobpd8Kfg49cEHmuHebOidCTDRgqG1suaPFX7oejFQeGE71zWKfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.66.0.tgz", + "integrity": "sha512-xA+neBh1bqkqaS3b48T2UHZnDsTIpb9B0h+TdBzynjF/ymCpYndhfY10RqW0qSKB1HqqQA83mINVhAdwA+mETg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.12", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.12.tgz", + "integrity": "sha512-Qe/KWYhEiIIxGs7HrAAjMfShxKldx19SJtr5zu53f3afPsdZNz7HHtdTLXo/kqeiWNXVycI24kSnfzBYkTzpgw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.9.tgz", + "integrity": "sha512-0sE1406XBYJGiqD3AusTl9ZqC//2mIXix51tbom25gDCA8ri4xnSZg28CaSE8Srl6FClqABUKfsn0qghgdepMA==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@xyflow/react": { + "version": "12.12.0", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.12.0.tgz", + "integrity": "sha512-74oXI0Rgm1Eu33cHQAd74J6oVgCH9mbFyTVQbsGgp2kmWwh0HdtBr2VP04z8mbPD+M/xO69nqG5dDEt7/EN2rQ==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.83", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "@types/react": ">=17", + "@types/react-dom": ">=17", + "react": ">=17", + "react-dom": ">=17" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.83", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.83.tgz", + "integrity": "sha512-a//eDjez3WZF47aaPTfVUDD0OINOq+WVwBZCRTqG+m0a5euX8EwlJ4jMY1Wxz0Ocj4xOeekJ70V22Pq0ll8RJw==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.34", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.34.tgz", + "integrity": "sha512-Aqr8mA1kIvyJ3UY5YAY7ib8LSo1DmDMbMnVqGPtsKW1LxaFQFwn9Eb3yvq2fUKJsRvaXjnAV7R5osqETapzNiA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.29.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.5.tgz", + "integrity": "sha512-khTmKu5NGYTotqcYo8Bew2k4RAE9PuXRSFRXG2lQBrCEQHyeQ/vX6gKMxfokwywBsLiXZMnHsmWMG1pnO8J7uQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.33", + "caniuse-lite": "^1.0.30001819", + "electron-to-chromium": "^1.5.464", + "node-releases": "^2.0.60", + "update-browserslist-db": "^1.3.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001819", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001819.tgz", + "integrity": "sha512-LFUqIIAwpzsIuevnKnsR9qnStBcgzPShTX61lBwN9uFkkf8o8zLiebEjbW8AYtZiF+1YKDWfdt/pWDtMuG8Xwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.467", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.467.tgz", + "integrity": "sha512-Nw9X371rtZmYcBSnEijnCDONILjs93biCZO34juC7MBOmK8E+GFnyD4T/WvyrlJkppul7oxg6TwvtKst9SODTw==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.60", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.60.tgz", + "integrity": "sha512-OmO8+GM1JUZqXET720VrJCwkjUCXmCMQm0Z3UDDRbfNLHS4NEAvY60j5jiFv0tkKw2PNNlqOLhahVmwJIOLDIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.66.0.tgz", + "integrity": "sha512-Onq6/qO4ARhxIs+jYiHE0pYehadLyMECAiGEqQ+0/aWND3+2PmifJIIqBLYtuBODqHTlOUEQFtIVPyCy0Lck7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.66.0", + "@rollup/rollup-android-arm64": "4.66.0", + "@rollup/rollup-darwin-arm64": "4.66.0", + "@rollup/rollup-darwin-x64": "4.66.0", + "@rollup/rollup-freebsd-arm64": "4.66.0", + "@rollup/rollup-freebsd-x64": "4.66.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.66.0", + "@rollup/rollup-linux-arm-musleabihf": "4.66.0", + "@rollup/rollup-linux-arm64-gnu": "4.66.0", + "@rollup/rollup-linux-arm64-musl": "4.66.0", + "@rollup/rollup-linux-loong64-gnu": "4.66.0", + "@rollup/rollup-linux-loong64-musl": "4.66.0", + "@rollup/rollup-linux-ppc64-gnu": "4.66.0", + "@rollup/rollup-linux-ppc64-musl": "4.66.0", + "@rollup/rollup-linux-riscv64-gnu": "4.66.0", + "@rollup/rollup-linux-riscv64-musl": "4.66.0", + "@rollup/rollup-linux-s390x-gnu": "4.66.0", + "@rollup/rollup-linux-x64-gnu": "4.66.0", + "@rollup/rollup-linux-x64-musl": "4.66.0", + "@rollup/rollup-openbsd-x64": "4.66.0", + "@rollup/rollup-openharmony-arm64": "4.66.0", + "@rollup/rollup-win32-arm64-msvc": "4.66.0", + "@rollup/rollup-win32-ia32-msvc": "4.66.0", + "@rollup/rollup-win32-x64-gnu": "4.66.0", + "@rollup/rollup-win32-x64-msvc": "4.66.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.6.tgz", + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "6.4.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.5.tgz", + "integrity": "sha512-4enwjFfi4IwtEh0w+jPzpLVOFQiPHmmzPxcHXQOyEur47o6u0jE573DmGpjA+7irXoMLYRZD5ZaFUEneS3abPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + } + } +} diff --git a/next/package.json b/next/package.json new file mode 100644 index 0000000..82cf14a --- /dev/null +++ b/next/package.json @@ -0,0 +1,26 @@ +{ + "name": "mindmap-next", + "private": true, + "version": "0.0.1", + "type": "module", + "description": "Tools-first Mindmap experiment: the coach reasons freely; its map actions are checked inside tool handlers when they execute. See ../docs/tools-first-pass.md.", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@xyflow/react": "^12.3.5", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^6.0.3", + "vitest": "^3.2.4" + } +} diff --git a/next/src/loop/client.ts b/next/src/loop/client.ts new file mode 100644 index 0000000..ec82d7e --- /dev/null +++ b/next/src/loop/client.ts @@ -0,0 +1,41 @@ +/** + * One call to the Responses API through the Writing Tools proxy (spec §7). Stateless: + * `store: false`, and reasoning comes back encrypted so it can be passed back within a + * turn without the provider keeping anything. + */ +export interface ModelRequest { + instructions: string; + input: unknown[]; + tools: unknown[]; + toolChoice: "auto" | "none"; +} + +/** The raw `output` items of one response. */ +export type ModelCall = (req: ModelRequest) => Promise; + +const env = import.meta.env ?? {}; +export const BACKEND_URL: string = env.VITE_BACKEND_URL ?? "http://localhost:8000/api"; +export const MODEL: string = env.VITE_MINDMAP_MODEL ?? "gpt-5.6-terra"; +export const REASONING_EFFORT: string = env.VITE_MINDMAP_REASONING_EFFORT ?? "low"; + +export const callResponses: ModelCall = async (req) => { + const res = await fetch(`${BACKEND_URL}/openai/responses`, { + method: "POST", + credentials: "omit", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: MODEL, + reasoning: { effort: REASONING_EFFORT }, + instructions: req.instructions, + input: req.input, + tools: req.tools, + tool_choice: req.toolChoice, + parallel_tool_calls: true, + store: false, + include: ["reasoning.encrypted_content"], + }), + }); + if (!res.ok) throw new Error(`Backend ${res.status}: ${(await res.text()).slice(0, 300)}`); + const body = (await res.json()) as { output?: unknown[] }; + return Array.isArray(body.output) ? body.output : []; +}; diff --git a/next/src/loop/context.ts b/next/src/loop/context.ts new file mode 100644 index 0000000..0acdc30 --- /dev/null +++ b/next/src/loop/context.ts @@ -0,0 +1,81 @@ +import type { Card, Level, Proposal, Session } from "../state/types"; + +/** + * What the model sees on every call (spec §7). Stateless: everything is sent each + * time, static first so the provider's prompt cache covers the repeated part: + * instructions and tools (passed separately), then the conversation so far, then the + * current state rebuilt fresh, then the writer's new message. + */ + +const LEVEL_RULES: Record = { + 0: [ + "Level L0. You can read the writer's chat messages and their draft.", + "Cards: only the writer's own words from their chat messages (any earlier message counts), not the draft.", + "Links and nesting: only when the writer has stated them in their own words.", + "Your own ideas: suggest_idea exists but is refused at L0. If you have an idea, help the writer find it themselves.", + ].join("\n"), + 1: [ + "Level L1. You can read the writer's chat messages and their draft.", + "Cards: only the writer's own words, from their chat messages or their draft.", + "Links and nesting: only when the writer has stated them in their own words.", + "Your own ideas: suggest_idea exists but is refused at L1. If you have an idea, help the writer find it themselves.", + ].join("\n"), + 2: [ + "Level L2. You can read the writer's chat messages and their draft.", + "Cards: the writer's own words, from their chat messages or their draft.", + "Links and nesting: you may propose them freely; they are shown as AI suggested.", + "Your own ideas: you may offer them with suggest_idea; they are shown as AI suggestions.", + ].join("\n"), +}; + +export const ATTEMPT_BUDGET = 3; + +export function instructions(level: Level): string { + return [ + "You are a thinking coach for a writer working out their ideas. The writer owns the map: you help them think, and everything you put on the map is a proposal they accept or reject.", + "", + "Use the tools to propose map changes. Each tool checks its rule when it runs. If a tool refuses, you get the reason; decide for yourself what to do next: try differently, ask the writer, or tell them plainly what you tried and why it didn't work.", + `Up to ${ATTEMPT_BUDGET} refused attempts are allowed per turn. Every refusal tells you how many are left. After that, reply in words only.`, + "", + LEVEL_RULES[level], + "", + "If the writer declined something, don't raise it again.", + "Reply to the writer in plain, short messages.", + ].join("\n"); +} + +function provenanceLabel(card: Pick): string { + const p = card.provenance; + if (p.kind === "combined") return `from ${p.messages} messages`; + if (p.kind === "draft") return "from draft"; + if (p.kind === "ai_suggested") return "AI suggestion"; + return "writer"; +} + +function proposalLine(p: Proposal): string { + if (p.kind === "card") return `${p.id} card "${p.text}"`; + if (p.kind === "suggestion") return `${p.id} suggestion "${p.text}"`; + if (p.kind === "link") return `${p.id} link ${p.from} → "${p.label}" → ${p.to}`; + return `${p.id} nest ${p.child} under ${p.parent}`; +} + +/** Part 4 of what the model sees: the current state, rebuilt every call. */ +export function stateBlock(session: Session, attemptsLeft: number): string { + const cards = session.map.cards.map((c) => ` ${c.id} "${c.text}"${c.parentId ? ` under ${c.parentId}` : ""} (${provenanceLabel(c)})`); + const links = session.map.links.map((l) => ` ${l.from} → "${l.label}" → ${l.to}${l.provenance.kind === "ai_suggested" ? " (AI suggested)" : ""}`); + const pending = session.proposals.filter((p) => p.status === "pending").map((p) => ` ${proposalLine(p)}`); + return [ + "[Current state, rebuilt for this call. Not from the writer.]", + "Map cards:", + ...(cards.length ? cards : [" (none)"]), + "Map links:", + ...(links.length ? links : [" (none)"]), + "Pending proposals (waiting for the writer):", + ...(pending.length ? pending : [" (none)"]), + "Writer decisions since your last turn:", + ...(session.decisions.length ? session.decisions.map((d) => ` ${d}`) : [" (none)"]), + "Draft:", + session.record.draft.trim() ? session.record.draft : " (empty)", + `Level: L${session.level} · refused attempts left this turn: ${attemptsLeft}`, + ].join("\n"); +} diff --git a/next/src/loop/turn.test.ts b/next/src/loop/turn.test.ts new file mode 100644 index 0000000..51e0898 --- /dev/null +++ b/next/src/loop/turn.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { emptySession, type Session } from "../state/types"; +import type { ModelCall, ModelRequest } from "./client"; +import { MAX_CALLS, runTurn } from "./turn"; + +const call = (id: string, name: string, args: unknown) => ({ type: "function_call", call_id: id, name, arguments: JSON.stringify(args) }); +const say = (text: string) => ({ type: "message", content: [{ type: "output_text", text }] }); + +/** A fake model that returns scripted outputs in order and records each request. */ +function fake(outputs: unknown[][]): { model: ModelCall; requests: ModelRequest[] } { + const requests: ModelRequest[] = []; + let i = 0; + return { + requests, + model: async (req) => { + requests.push(req); + return outputs[Math.min(i++, outputs.length - 1)]!; + }, + }; +} + +function start(messages: string[] = []): Session { + const s = emptySession(); + return { ...s, record: { messages: messages.map((text, i) => ({ turn: i + 1, text })), draft: "" }, counters: { ...s.counters, turn: messages.length } }; +} + +describe("runTurn", () => { + it("records the writer's message, runs tools, and ends on text", async () => { + const { model } = fake([[call("a", "propose_card", { pieces: ["X is bad"] })], [say("Does that capture it?")]]); + const s = await runTurn(start(), "because X is bad", { callModel: model }); + expect(s.record.messages.at(-1)).toEqual({ turn: 1, text: "because X is bad" }); + expect(s.proposals).toHaveLength(1); + expect(s.transcript.map((t) => t.kind)).toEqual(["writer", "tool", "coach"]); + }); + + it("tells the model how many attempts are left, and goes text-only at zero", async () => { + const refused = call("x", "suggest_idea", { text: "idea", near_card: null }); + const { model, requests } = fake([[refused], [{ ...refused, call_id: "y" }], [{ ...refused, call_id: "z" }], [say("Let's find it together.")]]); + const s = await runTurn(start(), "hi", { callModel: model }); + const outputs = s.history.filter((h): h is { type: "function_call_output"; call_id: string; output: string } => "type" in h && h.type === "function_call_output"); + expect(outputs.map((o) => JSON.parse(o.output).attempts_left)).toEqual([2, 1, 0]); + expect(requests.map((r) => r.toolChoice)).toEqual(["auto", "auto", "auto", "none"]); + expect(s.transcript.at(-1)).toEqual({ kind: "coach", text: "Let's find it together." }); + }); + + it("refused attempts don't stop successful ones in the same turn", async () => { + const { model } = fake([ + [call("a", "suggest_idea", { text: "idea", near_card: null }), call("b", "propose_card", { pieces: ["I like X"] })], + [say("ok")], + ]); + const s = await runTurn(start(["I like X"]), "more", { callModel: model }); + expect(s.transcript.filter((t) => t.kind === "tool").map((t) => t.kind === "tool" && t.ok)).toEqual([false, true]); + }); + + it("stops after the call cap even if the model never finishes", async () => { + const { model, requests } = fake([[call("a", "propose_card", { pieces: ["I like X"] })]]); + const s = await runTurn(start(["I like X"]), "go", { callModel: model }); + expect(requests).toHaveLength(MAX_CALLS); + expect(s.transcript.at(-1)).toMatchObject({ kind: "notice" }); + }); + + it("sends the state block before the new message, and the history after the turn", async () => { + const { model, requests } = fake([[say("hello")]]); + const s = await runTurn(start(), "first", { callModel: model }); + const input = requests[0]!.input as Array<{ content?: string }>; + expect(input.at(-2)?.content).toContain("[Current state"); + expect(input.at(-1)).toEqual({ role: "user", content: "first" }); + expect(s.history).toEqual([{ role: "user", content: "first" }, { role: "assistant", content: "hello" }]); + }); + + it("refuses unknown tools and bad JSON", async () => { + const { model } = fake([[call("a", "delete_everything", {}), { type: "function_call", call_id: "b", name: "propose_card", arguments: "{not json" }], [say("sorry")]]); + const s = await runTurn(start(), "x", { callModel: model }); + const reasons = s.transcript.filter((t) => t.kind === "tool").map((t) => (t.kind === "tool" ? t.reason : "")); + expect(reasons).toEqual(['There is no tool called "delete_everything".', "The arguments were not valid JSON."]); + }); + + it("reports writer decisions once, then clears them", async () => { + const { model, requests } = fake([[say("noted")]]); + const s = await runTurn({ ...start(), decisions: ["accepted p1 (card \"X\")"] }, "x", { callModel: model }); + expect((requests[0]!.input as Array<{ content?: string }>).some((i) => i.content?.includes("accepted p1"))).toBe(true); + expect(s.decisions).toEqual([]); + }); + + it("shows a notice when the backend can't be reached", async () => { + const s = await runTurn(start(), "x", { callModel: async () => { throw new Error("Backend 401: nope"); } }); + expect(s.transcript.at(-1)).toEqual({ kind: "notice", text: "Couldn't reach the coach: Backend 401: nope" }); + }); +}); diff --git a/next/src/loop/turn.ts b/next/src/loop/turn.ts new file mode 100644 index 0000000..b8d6b3a --- /dev/null +++ b/next/src/loop/turn.ts @@ -0,0 +1,150 @@ +import type { HistoryItem, Session } from "../state/types"; +import { refuse, type HandlerResult } from "../tools/common"; +import { toolByName, toolDefinitions } from "../tools"; +import type { ModelCall } from "./client"; +import { ATTEMPT_BUDGET, instructions, stateBlock } from "./context"; + +/** Safety cap (spec §6): a turn never makes more model calls than this, whatever happens. */ +export const MAX_CALLS = 8; + +interface FunctionCall { + type: "function_call"; + call_id: string; + name: string; + arguments: string; +} + +function rec(v: unknown): Record | undefined { + return v !== null && typeof v === "object" && !Array.isArray(v) ? (v as Record) : undefined; +} + +function functionCalls(output: unknown[]): FunctionCall[] { + return output.filter((item): item is FunctionCall => { + const r = rec(item); + return r?.type === "function_call" && typeof r.call_id === "string" && typeof r.name === "string" && typeof r.arguments === "string"; + }); +} + +function outputText(output: unknown[]): string { + const parts: string[] = []; + for (const item of output) { + const r = rec(item); + if (r?.type !== "message" || !Array.isArray(r.content)) continue; + for (const c of r.content) { + const cr = rec(c); + if (cr?.type === "output_text" && typeof cr.text === "string") parts.push(cr.text); + } + } + return parts.join("\n").trim(); +} + +export interface TurnDeps { + callModel: ModelCall; + /** Called after every step so the chat shows tool lines as they happen. */ + onUpdate?: (session: Session) => void; +} + +/** + * One writer turn (spec §6): record the message, then call the model until it replies + * with text and no tool calls. Each tool call runs its handler; a refusal costs one + * attempt and tells the model how many are left. At zero, the next call is text only. + */ +export async function runTurn(start: Session, writerText: string, deps: TurnDeps): Promise { + const turn = start.counters.turn + 1; + const level = start.level; + const reported = start.decisions.length; + let s: Session = { + ...start, + counters: { ...start.counters, turn }, + record: { ...start.record, messages: [...start.record.messages, { turn, text: writerText }] }, + transcript: [...start.transcript, { kind: "writer", text: writerText }], + }; + deps.onUpdate?.(s); + + const writerItem: HistoryItem = { role: "user", content: writerText }; + const turnItems: unknown[] = []; // everything returned this turn, reasoning included + const kept: HistoryItem[] = []; // what later turns will see (no reasoning items) + let attemptsLeft = ATTEMPT_BUDGET; + let calls = 0; + let finished = false; + + while (calls < MAX_CALLS) { + const toolsOn = attemptsLeft > 0; + let output: unknown[]; + try { + output = await deps.callModel({ + instructions: instructions(level), + input: [...s.history, { role: "user", content: stateBlock(s, attemptsLeft) }, writerItem, ...turnItems], + tools: toolDefinitions(level), + toolChoice: toolsOn ? "auto" : "none", + }); + } catch (error) { + s = { ...s, transcript: [...s.transcript, { kind: "notice", text: `Couldn't reach the coach: ${error instanceof Error ? error.message : String(error)}` }] }; + deps.onUpdate?.(s); + finished = true; + break; + } + calls++; + turnItems.push(...output); + + const text = outputText(output); + if (text) { + s = { ...s, transcript: [...s.transcript, { kind: "coach", text }] }; + kept.push({ role: "assistant", content: text }); + } + + const toolCalls = functionCalls(output); + if (!toolCalls.length) { + finished = true; + break; + } + + for (const call of toolCalls) { + kept.push({ type: "function_call", call_id: call.call_id, name: call.name, arguments: call.arguments }); + const result = runTool(call, s, level, toolsOn, () => { + const id = `p${s.counters.proposal + 1}`; + s = { ...s, counters: { ...s.counters, proposal: s.counters.proposal + 1 } }; + return id; + }); + + let payload: Record; + if (result.ok) { + s = { + ...s, + proposals: [...s.proposals, result.proposal], + transcript: [...s.transcript, { kind: "tool", name: call.name, summary: result.summary, ok: true, proposalId: result.proposal.id }], + }; + payload = { ok: true, proposal: result.proposal.id, shown_as: result.summary }; + } else { + attemptsLeft = Math.max(0, attemptsLeft - 1); + s = { ...s, transcript: [...s.transcript, { kind: "tool", name: call.name, summary: result.summary, ok: false, reason: result.reason }] }; + payload = { ok: false, reason: result.reason, attempts_left: attemptsLeft }; + } + const outputItem: HistoryItem = { type: "function_call_output", call_id: call.call_id, output: JSON.stringify(payload) }; + turnItems.push(outputItem); + kept.push(outputItem); + deps.onUpdate?.(s); + } + } + + if (!finished) { + s = { ...s, transcript: [...s.transcript, { kind: "notice", text: `The coach stopped after ${MAX_CALLS} steps without finishing its reply.` }] }; + } + // The decisions reported in this turn's state block have now been told. + s = { ...s, history: [...s.history, writerItem, ...kept], decisions: s.decisions.slice(reported) }; + deps.onUpdate?.(s); + return s; +} + +function runTool(call: FunctionCall, session: Session, level: Session["level"], toolsOn: boolean, nextProposalId: () => string): HandlerResult { + if (!toolsOn) return refuse("No attempts left this turn; reply in words.", call.name); + const tool = toolByName(call.name); + if (!tool) return refuse(`There is no tool called "${call.name}".`, call.name); + let args: unknown; + try { + args = JSON.parse(call.arguments); + } catch { + return refuse("The arguments were not valid JSON.", call.name); + } + return tool.handle(args, { session, level, checks: true, nextProposalId }); +} 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/map.ts b/next/src/state/map.ts new file mode 100644 index 0000000..85f5eb0 --- /dev/null +++ b/next/src/state/map.ts @@ -0,0 +1,178 @@ +import { reconcileAdoption } from "./adoption"; +import type { Card, Link, MapState, Proposal, Session } from "./types"; + +const UNDO_LIMIT = 50; + +/** Next free spot in a simple grid, so new cards don't stack on each other. */ +function nextPosition(map: MapState): { x: number; y: number } { + const n = map.cards.length; + return { x: 40 + (n % 3) * 260, y: 40 + Math.floor(n / 3) * 140 }; +} + +function withUndo(session: Session): Session { + return { ...session, undo: [...session.undo, session.map].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])); +} + +export function editCard(session: Session, id: string, text: string): Session { + const s = withUndo(session); + const cards = s.map.cards.map((c) => (c.id === id ? { ...c, text } : c)); + 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); + const cards = s.map.cards.filter((c) => c.id !== id).map((c) => (c.parentId === id ? { ...c, parentId: null } : c)); + const links = s.map.links.filter((l) => l.from !== id && l.to !== id); + return { ...s, map: { cards, links } }; +} + +export function setParent(session: Session, child: string, parent: string | null): Session { + if (parent && (child === parent || wouldCycle(session.map, child, parent))) return session; + const s = withUndo(session); + return { ...s, map: { ...s.map, cards: s.map.cards.map((c) => (c.id === child ? { ...c, parentId: parent } : c)) } }; +} + +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) } }; +} + +export function undo(session: Session): Session { + const prev = session.undo[session.undo.length - 1]; + if (!prev) return session; + return { ...session, map: prev, undo: session.undo.slice(0, -1) }; +} + +// ---- 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" }, + ...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: { ...s.map, cards: s.map.cards.map((c) => (c.id === child ? { ...c, parentId: parent } : c)) } }; + } + s = setStatus(s, id, "accepted"); + return { ...s, decisions: [...s.decisions, `accepted ${p.id} (${describe(p)})`] }; +} + +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..77ec91c --- /dev/null +++ b/next/src/state/match.ts @@ -0,0 +1,66 @@ +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)); +} + +/** 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/state.test.ts b/next/src/state/state.test.ts new file mode 100644 index 0000000..b2c77b8 --- /dev/null +++ b/next/src/state/state.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { runOverlap } from "./adoption"; +import { 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" } }); + + 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" }]); + 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" }); + }); +}); diff --git a/next/src/state/types.ts b/next/src/state/types.ts new file mode 100644 index 0000000..b7fd7a4 --- /dev/null +++ b/next/src/state/types.ts @@ -0,0 +1,116 @@ +/** 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" }; + +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; + provenance: Provenance; + x: number; + y: number; + adoption?: AdoptionTrace; +} + +export interface Link { + id: string; + from: string; + to: string; + label: string; + provenance: { kind: "user" } | { kind: "ai_suggested" }; +} + +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; cardId?: string }) + | (ProposalBase & { kind: "link"; from: string; to: string; label: string; provenance: Link["provenance"] }) + | (ProposalBase & { kind: "nest"; child: string; parent: string }) + | (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; +} + +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 }; + /** Map snapshots for undo, newest last. */ + undo: MapState[]; +} + +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/tools/common.ts b/next/src/tools/common.ts new file mode 100644 index 0000000..f78aeb3 --- /dev/null +++ b/next/src/tools/common.ts @@ -0,0 +1,45 @@ +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"; +} + +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..5a0eb3c --- /dev/null +++ b/next/src/tools/evidence.ts @@ -0,0 +1,59 @@ +import { eligibleSources, findOnWordBoundary, hasWords, norm, sourcesContaining } from "../state/match"; +import type { Level, WriterRecord } from "../state/types"; +import { quote, whereFrom } 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 `${quote(evidence[i]!)} is not in ${whereFrom(level)}.`; + } + + 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..a2de17d --- /dev/null +++ b/next/src/tools/proposeCard.ts @@ -0,0 +1,60 @@ +import { eligibleSources, hasWords, norm, sourcesContaining } from "../state/match"; +import type { Provenance } from "../state/types"; +import { quote, refuse, stringList, obj, whereFrom, 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.", + "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(); + const summary = quote(text); + + if (pieces.some((p) => !hasWords(p))) return refuse("Every piece needs at least one word.", summary); + + const turns = new Set(); + let fromDraft = false; + if (ctx.checks) { + const sources = eligibleSources(ctx.session.record, ctx.level); + for (const piece of pieces) { + const found = sourcesContaining(piece, sources)[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.` + : `${quote(piece)} is not in ${whereFrom(ctx.level)}.`, + summary, + ); + } + if (found.kind === "draft") fromDraft = true; + else turns.add(found.turn); + } + } + + 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 } }; + }, +}; diff --git a/next/src/tools/proposeLink.ts b/next/src/tools/proposeLink.ts new file mode 100644 index 0000000..f6db9b6 --- /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 you proposed this turn (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..766506a --- /dev/null +++ b/next/src/tools/proposeNest.ts @@ -0,0 +1,50 @@ +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 you proposed this turn (p5).", + level === 2 + ? "At this level you may propose nesting freely. `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); + } + + if (ctx.level !== 2 && ctx.checks) { + 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 } }; + }, +}; diff --git a/next/src/tools/suggestIdea.ts b/next/src/tools/suggestIdea.ts new file mode 100644 index 0000000..c70865a --- /dev/null +++ b/next/src/tools/suggestIdea.ts @@ -0,0 +1,30 @@ +import { refText } from "../state/map"; +import { obj, quote, 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 ? quote(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..46861b5 --- /dev/null +++ b/next/src/tools/tools.test.ts @@ -0,0 +1,180 @@ +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"); + }); +}); diff --git a/next/tsconfig.json b/next/tsconfig.json new file mode 100644 index 0000000..a43a312 --- /dev/null +++ b/next/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "types": ["vite/client"] + }, + "include": ["src"] +} diff --git a/next/vite.config.ts b/next/vite.config.ts new file mode 100644 index 0000000..afbbf2c --- /dev/null +++ b/next/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +// Port 5183 so it can run next to the current app (5181). +export default defineConfig({ + plugins: [react()], + server: { port: Number(process.env.PORT) || 5183, strictPort: true }, +}); From 9632686b5ff7c8fde4c517578f27aaf0dd370108 Mon Sep 17 00:00:00 2001 From: nhyiramante1 <196460314+nhyiramante1@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:00:54 -0400 Subject: [PATCH 03/12] fix(next): review fixes for the tools-first core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The state block now lists everything the writer declined, for the whole session, so the coach doesn't propose it again (decisions alone were cleared after one turn). Sent as a developer message. - Only the last 40 writer turns of history are sent; the cut falls where a writer message starts, so tool calls stay paired with their results, and the state block says how many messages are hidden. - Card proposals record each piece's most recent match and every match (spec §5.1); accepted cards keep these sources. - The attempt budget is checked per call, so calls in one batch can't exceed it. - An accepted L2 nesting keeps an "AI suggested" mark, like L2 links. - Nested cards are positioned relative to their parent; nesting, taking a card out and deleting a parent keep cards visibly in place. - Accept decisions name the card a proposal became; tool descriptions say any pending proposal can be referenced; one shared set of provenance labels for the model and the UI. Co-Authored-By: Claude Opus 5.5 --- docs/tools-first-pass.md | 21 +++++++---- next/src/loop/context.ts | 37 +++++++++++++------ next/src/loop/turn.test.ts | 42 +++++++++++++++++++++- next/src/loop/turn.ts | 10 ++++-- next/src/state/labels.ts | 21 +++++++++++ next/src/state/map.ts | 68 ++++++++++++++++++++++++++++++----- next/src/state/state.test.ts | 49 +++++++++++++++++++++++-- next/src/state/types.ts | 32 ++++++++++++++--- next/src/tools/proposeCard.ts | 16 +++++++-- next/src/tools/proposeLink.ts | 2 +- next/src/tools/proposeNest.ts | 13 ++++--- next/src/tools/tools.test.ts | 28 +++++++++++++++ 12 files changed, 295 insertions(+), 44 deletions(-) create mode 100644 next/src/state/labels.ts diff --git a/docs/tools-first-pass.md b/docs/tools-first-pass.md index 7fb76b9..43d3ed1 100644 --- a/docs/tools-first-pass.md +++ b/docs/tools-first-pass.md @@ -90,7 +90,8 @@ lower-case, whitespace runs collapsed to one space, and straight/curly quotes tr alike. No other transformation. When a piece matches in more than one message, record the most recent match, and all -matches. +matches. Each accepted card keeps these `sources` (piece, most recent turn, every turn, +whether the draft also has it). ### 5.1 `propose_card` @@ -138,7 +139,8 @@ matches. - **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. +- **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` @@ -168,7 +170,9 @@ matches. 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`. +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. @@ -190,11 +194,16 @@ prompt cache covers it): 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; the writer record keeps everything, so old quotes - still check. + 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", "rejected p4"), the draft, and `level · attempts left`. + 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 diff --git a/next/src/loop/context.ts b/next/src/loop/context.ts index 0acdc30..5ecc733 100644 --- a/next/src/loop/context.ts +++ b/next/src/loop/context.ts @@ -1,4 +1,5 @@ -import type { Card, Level, Proposal, Session } from "../state/types"; +import { cardSourceForModel, structureLabel } from "../state/labels"; +import type { HistoryItem, Level, Proposal, Session } from "../state/types"; /** * What the model sees on every call (spec §7). Stateless: everything is sent each @@ -44,12 +45,19 @@ export function instructions(level: Level): string { ].join("\n"); } -function provenanceLabel(card: Pick): string { - const p = card.provenance; - if (p.kind === "combined") return `from ${p.messages} messages`; - if (p.kind === "draft") return "from draft"; - if (p.kind === "ai_suggested") return "AI suggestion"; - return "writer"; +/** How many recent writer turns the model sees (spec §7.3). The writer record keeps all of them. */ +export const TURNS_SEEN = 40; + +/** + * The part of the conversation the model sees: the last `TURNS_SEEN` writer turns. + * Cuts only where a writer message starts, so a tool call is never separated from its + * result. `hidden` counts the writer messages left out. + */ +export function visibleHistory(history: HistoryItem[], turnsSeen = TURNS_SEEN): { items: HistoryItem[]; hidden: number } { + const starts = history.flatMap((h, i) => ("role" in h && h.role === "user" ? [i] : [])); + if (starts.length <= turnsSeen) return { items: history, hidden: 0 }; + const cut = starts[starts.length - turnsSeen]!; + return { items: history.slice(cut), hidden: starts.length - turnsSeen }; } function proposalLine(p: Proposal): string { @@ -60,12 +68,19 @@ function proposalLine(p: Proposal): string { } /** Part 4 of what the model sees: the current state, rebuilt every call. */ -export function stateBlock(session: Session, attemptsLeft: number): string { - const cards = session.map.cards.map((c) => ` ${c.id} "${c.text}"${c.parentId ? ` under ${c.parentId}` : ""} (${provenanceLabel(c)})`); - const links = session.map.links.map((l) => ` ${l.from} → "${l.label}" → ${l.to}${l.provenance.kind === "ai_suggested" ? " (AI suggested)" : ""}`); +export function stateBlock(session: Session, attemptsLeft: number, hiddenMessages = 0): string { + const cards = session.map.cards.map((c) => { + const nest = c.parentId ? ` under ${c.parentId}${structureLabel(c.parentProvenance) ? " (nesting AI suggested)" : ""}` : ""; + return ` ${c.id} "${c.text}"${nest} (${cardSourceForModel(c)})`; + }); + const links = session.map.links.map((l) => ` ${l.from} → "${l.label}" → ${l.to}${structureLabel(l.provenance) ? " (AI suggested)" : ""}`); const pending = session.proposals.filter((p) => p.status === "pending").map((p) => ` ${proposalLine(p)}`); + // Kept for the whole session, so a declined idea isn't raised again (decisions below + // only cover the latest ones). + const declined = session.proposals.filter((p) => p.status === "rejected").map((p) => ` ${proposalLine(p)}`); return [ "[Current state, rebuilt for this call. Not from the writer.]", + ...(hiddenMessages ? [`(${hiddenMessages} earlier writer messages are not shown above. Your tools can still quote them.)`] : []), "Map cards:", ...(cards.length ? cards : [" (none)"]), "Map links:", @@ -74,6 +89,8 @@ export function stateBlock(session: Session, attemptsLeft: number): string { ...(pending.length ? pending : [" (none)"]), "Writer decisions since your last turn:", ...(session.decisions.length ? session.decisions.map((d) => ` ${d}`) : [" (none)"]), + "Declined by the writer earlier (don't propose these again):", + ...(declined.length ? declined : [" (none)"]), "Draft:", session.record.draft.trim() ? session.record.draft : " (empty)", `Level: L${session.level} · refused attempts left this turn: ${attemptsLeft}`, diff --git a/next/src/loop/turn.test.ts b/next/src/loop/turn.test.ts index 51e0898..b9b501d 100644 --- a/next/src/loop/turn.test.ts +++ b/next/src/loop/turn.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; -import { emptySession, type Session } from "../state/types"; +import { emptySession, type HistoryItem, type Session } from "../state/types"; +import { visibleHistory } from "./context"; import type { ModelCall, ModelRequest } from "./client"; import { MAX_CALLS, runTurn } from "./turn"; @@ -87,3 +88,42 @@ describe("runTurn", () => { expect(s.transcript.at(-1)).toEqual({ kind: "notice", text: "Couldn't reach the coach: Backend 401: nope" }); }); }); + +describe("review fixes: declined list, history trimming, budget in one batch", () => { + it("keeps what the writer declined in every later state block, not just the next one", async () => { + let s = start(); + s = { ...s, proposals: [{ id: "p1", status: "rejected", kind: "card", text: "X is bad", provenance: { kind: "user" }, sources: [] }], counters: { ...s.counters, proposal: 1 } }; + const first = fake([[say("ok")]]); + s = await runTurn(s, "hi", { callModel: first.model }); + const second = fake([[say("ok")]]); + await runTurn(s, "again", { callModel: second.model }); + const stateOf = (r: ModelRequest) => (r.input as { role?: string; content?: string }[]).find((i) => i.role === "developer")!.content!; + expect(stateOf(second.requests[0]!)).toContain('Declined by the writer earlier'); + expect(stateOf(second.requests[0]!)).toContain('p1 card "X is bad"'); + }); + + it("shows the model only recent turns, never splitting a tool call from its result", () => { + const history: HistoryItem[] = []; + for (let t = 1; t <= 45; t++) { + history.push({ role: "user", content: `m${t}` }); + history.push({ type: "function_call", call_id: `c${t}`, name: "propose_card", arguments: "{}" }); + history.push({ type: "function_call_output", call_id: `c${t}`, output: "{}" }); + history.push({ role: "assistant", content: `r${t}` }); + } + const seen = visibleHistory(history, 40); + expect(seen.hidden).toBe(5); + expect(seen.items[0]).toEqual({ role: "user", content: "m6" }); + const calls = seen.items.filter((h) => "type" in h && h.type === "function_call").length; + const outputs = seen.items.filter((h) => "type" in h && h.type === "function_call_output").length; + expect(calls).toBe(outputs); + }); + + it("refuses calls past the budget in the same batch without running them", async () => { + const bad = (id: string) => call(id, "suggest_idea", { text: "idea", near_card: null }); + const { model } = fake([[bad("a"), bad("b"), bad("c"), call("d", "propose_card", { pieces: ["I like X"] })], [say("ok")]]); + const s = await runTurn(start(["I like X"]), "hi", { callModel: model }); + expect(s.proposals).toHaveLength(0); // the fourth call came after the budget was spent + const last = s.transcript.filter((t) => t.kind === "tool").at(-1); + expect(last).toMatchObject({ ok: false, reason: "No attempts left this turn; reply in words." }); + }); +}); diff --git a/next/src/loop/turn.ts b/next/src/loop/turn.ts index b8d6b3a..eb93c72 100644 --- a/next/src/loop/turn.ts +++ b/next/src/loop/turn.ts @@ -2,7 +2,7 @@ import type { HistoryItem, Session } from "../state/types"; import { refuse, type HandlerResult } from "../tools/common"; import { toolByName, toolDefinitions } from "../tools"; import type { ModelCall } from "./client"; -import { ATTEMPT_BUDGET, instructions, stateBlock } from "./context"; +import { ATTEMPT_BUDGET, instructions, stateBlock, visibleHistory } from "./context"; /** Safety cap (spec §6): a turn never makes more model calls than this, whatever happens. */ export const MAX_CALLS = 8; @@ -68,13 +68,15 @@ export async function runTurn(start: Session, writerText: string, deps: TurnDeps let calls = 0; let finished = false; + const seen = visibleHistory(s.history); while (calls < MAX_CALLS) { const toolsOn = attemptsLeft > 0; let output: unknown[]; try { output = await deps.callModel({ instructions: instructions(level), - input: [...s.history, { role: "user", content: stateBlock(s, attemptsLeft) }, writerItem, ...turnItems], + // The state block is a developer message: it comes from the app, not the writer. + input: [...seen.items, { role: "developer", content: stateBlock(s, attemptsLeft, seen.hidden) }, writerItem, ...turnItems], tools: toolDefinitions(level), toolChoice: toolsOn ? "auto" : "none", }); @@ -101,7 +103,9 @@ export async function runTurn(start: Session, writerText: string, deps: TurnDeps for (const call of toolCalls) { kept.push({ type: "function_call", call_id: call.call_id, name: call.name, arguments: call.arguments }); - const result = runTool(call, s, level, toolsOn, () => { + // Checked per call: once the budget is spent, later calls in the same batch are + // refused without running, so a batch can't exceed it. + const result = runTool(call, s, level, attemptsLeft > 0, () => { const id = `p${s.counters.proposal + 1}`; s = { ...s, counters: { ...s.counters, proposal: s.counters.proposal + 1 } }; return id; diff --git a/next/src/state/labels.ts b/next/src/state/labels.ts new file mode 100644 index 0000000..594d946 --- /dev/null +++ b/next/src/state/labels.ts @@ -0,0 +1,21 @@ +import type { Card, 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; +} + +/** 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 index 85f5eb0..1cdd0e1 100644 --- a/next/src/state/map.ts +++ b/next/src/state/map.ts @@ -1,12 +1,52 @@ import { reconcileAdoption } from "./adoption"; -import type { Card, Link, MapState, Proposal, Session } from "./types"; +import type { Card, Link, MapState, Proposal, Session, StructureProvenance } from "./types"; const UNDO_LIMIT = 50; -/** Next free spot in a simple grid, so new cards don't stack on each other. */ +/** 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.length; - return { x: 40 + (n % 3) * 260, y: 40 + Math.floor(n / 3) * 140 }; + 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 { @@ -64,15 +104,21 @@ export function moveCard(session: Session, id: string, x: number, y: number): Se export function deleteCard(session: Session, id: string): Session { const s = withUndo(session); - const cards = s.map.cards.filter((c) => c.id !== id).map((c) => (c.parentId === id ? { ...c, parentId: null } : c)); + // 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 { - if (parent && (child === parent || wouldCycle(session.map, child, parent))) return 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: { ...s.map, cards: s.map.cards.map((c) => (c.id === child ? { ...c, parentId: parent } : c)) } }; + return { ...s, map: placeUnder(s.map, child, parent) }; } export function addLink(session: Session, from: string, to: string, label: string): Session { @@ -140,6 +186,7 @@ export function acceptProposal(session: Session, id: string): Session { 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 = { @@ -158,10 +205,13 @@ export function acceptProposal(session: Session, id: string): Session { 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: { ...s.map, cards: s.map.cards.map((c) => (c.id === child ? { ...c, parentId: parent } : c)) } }; + s = { ...s, map: placeUnder(s.map, child, parent, p.provenance) }; } s = setStatus(s, id, "accepted"); - return { ...s, decisions: [...s.decisions, `accepted ${p.id} (${describe(p)})`] }; + // 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 { diff --git a/next/src/state/state.test.ts b/next/src/state/state.test.ts index b2c77b8..528a444 100644 --- a/next/src/state/state.test.ts +++ b/next/src/state/state.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { runOverlap } from "./adoption"; -import { acceptProposal, addCard, addLink, deleteCard, editCard, rejectProposal, setParent, undo } from "./map"; +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 { @@ -8,7 +8,7 @@ function withProposals(proposals: Proposal[]): Session { } describe("proposals", () => { - const card = (id: string, text: string): Proposal => ({ id, status: "pending", kind: "card", text, provenance: { kind: "user" } }); + 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" } }]); @@ -20,7 +20,7 @@ describe("proposals", () => { }); 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" }]); + 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"]); }); @@ -67,3 +67,46 @@ describe("suggestion adoption (three-word runs)", () => { 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); + }); +}); diff --git a/next/src/state/types.ts b/next/src/state/types.ts index b7fd7a4..1462537 100644 --- a/next/src/state/types.ts +++ b/next/src/state/types.ts @@ -23,6 +23,25 @@ export type Provenance = | { 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; @@ -34,7 +53,12 @@ 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; @@ -45,7 +69,7 @@ export interface Link { from: string; to: string; label: string; - provenance: { kind: "user" } | { kind: "ai_suggested" }; + provenance: StructureProvenance; } export interface MapState { @@ -63,9 +87,9 @@ interface ProposalBase { } export type Proposal = - | (ProposalBase & { kind: "card"; text: string; provenance: Provenance; cardId?: string }) - | (ProposalBase & { kind: "link"; from: string; to: string; label: string; provenance: Link["provenance"] }) - | (ProposalBase & { kind: "nest"; child: string; parent: string }) + | (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. */ diff --git a/next/src/tools/proposeCard.ts b/next/src/tools/proposeCard.ts index a2de17d..42cbaa0 100644 --- a/next/src/tools/proposeCard.ts +++ b/next/src/tools/proposeCard.ts @@ -1,5 +1,5 @@ import { eligibleSources, hasWords, norm, sourcesContaining } from "../state/match"; -import type { Provenance } from "../state/types"; +import type { PieceSource, Provenance } from "../state/types"; import { quote, refuse, stringList, obj, whereFrom, type Tool } from "./common"; /** Spec §5.1. The model writes the pieces; the handler checks each against the writer's words. */ @@ -30,10 +30,12 @@ export const proposeCard: Tool = { 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 found = sourcesContaining(piece, sources)[0]; + 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( @@ -45,6 +47,14 @@ export const proposeCard: Tool = { } 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"), + }); } } @@ -55,6 +65,6 @@ export const proposeCard: Tool = { } 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 } }; + 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 index f6db9b6..77b3910 100644 --- a/next/src/tools/proposeLink.ts +++ b/next/src/tools/proposeLink.ts @@ -7,7 +7,7 @@ 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 you proposed this turn (p5).", + "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.", diff --git a/next/src/tools/proposeNest.ts b/next/src/tools/proposeNest.ts index 766506a..78c72ba 100644 --- a/next/src/tools/proposeNest.ts +++ b/next/src/tools/proposeNest.ts @@ -7,9 +7,9 @@ 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 you proposed this turn (p5).", + "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. `evidence` may be empty." + ? "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: { @@ -40,11 +40,16 @@ export const proposeNest: Tool = { return refuse("That nesting is already proposed.", summary); } - if (ctx.level !== 2 && ctx.checks) { + 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 } }; + 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/tools.test.ts b/next/src/tools/tools.test.ts index 46861b5..d6e15b2 100644 --- a/next/src/tools/tools.test.ts +++ b/next/src/tools/tools.test.ts @@ -178,3 +178,31 @@ describe("suggest_idea", () => { 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(); + }); +}); From 88bb10432d18799adfede3da446d5f112c4b9e58 Mon Sep 17 00:00:00 2001 From: nhyiramante1 <196460314+nhyiramante1@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:09:30 -0400 Subject: [PATCH 04/12] feat(next): plain UI and browser saving for the tools-first pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec §8 and §9 step 1's UI parts: - Chat with one line per tool call; Accept/Reject on pending proposals (a link or nesting waits for its card), the outcome once decided, and a collapsed "why" on refusals. - Map on React Flow with visual nesting: a parent card is a box sized to hold its children. 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 and press Delete to remove it, ✕ to delete a card (its children stay), Undo. Provenance badges on cards; AI-suggested links are dashed. - Level picker (applies from the next message), draft panel, copy map (Markdown) and copy draft, suggestion-adoption toggle, new session. - Browser saving that starts fresh on missing or malformed data and reports when storage is blocked. - Everything is locked while a turn runs, since the turn works from a snapshot. Tests for saving, the Markdown export and the nesting layout. Co-Authored-By: Claude Opus 5.5 --- next/src/main.tsx | 10 ++ next/src/state/export.ts | 27 ++++ next/src/state/persist-export.test.ts | 61 +++++++++ next/src/state/persist.ts | 54 ++++++++ next/src/styles.css | 143 +++++++++++++++++++++ next/src/ui/App.tsx | 172 ++++++++++++++++++++++++++ next/src/ui/CardNode.tsx | 94 ++++++++++++++ next/src/ui/Chat.tsx | 104 ++++++++++++++++ next/src/ui/MapView.tsx | 136 ++++++++++++++++++++ next/src/ui/clipboard.ts | 21 ++++ next/src/ui/flow.test.ts | 24 ++++ next/src/ui/flow.ts | 117 ++++++++++++++++++ 12 files changed, 963 insertions(+) create mode 100644 next/src/main.tsx create mode 100644 next/src/state/export.ts create mode 100644 next/src/state/persist-export.test.ts create mode 100644 next/src/state/persist.ts create mode 100644 next/src/styles.css create mode 100644 next/src/ui/App.tsx create mode 100644 next/src/ui/CardNode.tsx create mode 100644 next/src/ui/Chat.tsx create mode 100644 next/src/ui/MapView.tsx create mode 100644 next/src/ui/clipboard.ts create mode 100644 next/src/ui/flow.test.ts create mode 100644 next/src/ui/flow.ts diff --git a/next/src/main.tsx b/next/src/main.tsx new file mode 100644 index 0000000..18f49a5 --- /dev/null +++ b/next/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./ui/App"; +import "./styles.css"; + +createRoot(document.getElementById("root")!).render( + + + , +); 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/persist-export.test.ts b/next/src/state/persist-export.test.ts new file mode 100644 index 0000000..035029b --- /dev/null +++ b/next/src/state/persist-export.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { mapToMarkdown } from "./export"; +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")); + }); +}); diff --git a/next/src/state/persist.ts b/next/src/state/persist.ts new file mode 100644 index 0000000..20e1767 --- /dev/null +++ b/next/src/state/persist.ts @@ -0,0 +1,54 @@ +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(); + return { ...fresh, ...parsed, 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/styles.css b/next/src/styles.css new file mode 100644 index 0000000..3f7ceb8 --- /dev/null +++ b/next/src/styles.css @@ -0,0 +1,143 @@ +/* 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; } +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: 380px minmax(0, 1fr); + grid-template-rows: auto minmax(0, 1fr); + grid-template-areas: "bar bar" "chat map"; +} +.app.with-draft { grid-template-columns: 380px 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: 56px; resize: vertical; border: 1px solid var(--line); border-radius: 10px; padding: 8px; } +.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); } + +/* 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/ui/App.tsx b/next/src/ui/App.tsx new file mode 100644 index 0000000..87580f7 --- /dev/null +++ b/next/src/ui/App.tsx @@ -0,0 +1,172 @@ +import { ReactFlowProvider } from "@xyflow/react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { callResponses } 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" }, +]; + +export function App() { + 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; + + 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)), + edit: (id, text) => 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: callResponses, onUpdate: setSession }); + setSession(done); + } 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, "")); + 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 + + + + + + + + +
+ + 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
+
+ + {showDraft && ( +