diff --git a/docs/tools-first-pass.md b/docs/tools-first-pass.md index c9efbd5..a62817c 100644 --- a/docs/tools-first-pass.md +++ b/docs/tools-first-pass.md @@ -39,7 +39,7 @@ the rules are stated, and enforced only when an action executes. | `suggest_idea` below L2 | Offered, always refused, so the model learns the boundary and draws the idea out of the writer instead. The instructions and the tool description also say up front that it is refused (Nhyira, 10-28): the prompt tells the model it won't work, so it doesn't waste an attempt, and the handler's refusal stays as the real check in case it tries anyway or ignores the prompt. So the coach declining in words at L0 without trying the tool is expected. | | Memory between calls | None. Every call is stateless and sends everything (section 7), ordered so the provider's prompt cache covers the repeated part. No provider-side conversation storage. | | Suggestion adoption | Behind an on/off setting. Measured by shared runs of three or more words, not today's common-word list (see §9). The list is the fallback if runs prove problematic. | -| Out of v1 | i18n, a restating tool, working memory, the fuzz harness (after v1), L3 (after v1), the Think/Map slider, steering buttons, voice, Control Room diagnostics, sign-in and deploy. | +| Out of v1 | i18n, a restating tool, working memory, the fuzz harness (after v1), L3 (after v1), the Think/Map slider, steering buttons, voice (added 11-01, see §8), Control Room diagnostics, sign-in and deploy. | ## 3. Levels @@ -71,8 +71,13 @@ provenance labels is a study-design question for later. tracking covers it. Undo also restores proposal states, so undoing an Accept makes that proposal pending again and the model is told. A `+ Card` left empty is removed. - **Proposals:** pending `{ id, kind: card | link | nest | suggestion, ... }`, each - accepted or rejected by the writer. A link or nest that refers to a pending card is - dropped if that card is rejected. + accepted or rejected by the writer. Links, nesting and a suggestion's `near_card` + refer only to cards on the map (`c3`), never to a card proposal still pending (`p5`): + the coach must not build on something the writer hasn't decided, or the writer is + pushed into deciding it later (Nhyira, 11-01; this replaces proposing a card and a + link to it in the same turn). A pending link or nest is dropped if a card it names is + deleted (or, in sessions saved earlier, rejected), so no Accept is left that can't + work. - **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. @@ -83,15 +88,22 @@ Saved in the browser, like today. All tools return a result to the model: ```json -{ "ok": true, "proposal": "p5", "shown_as": "card: \"X is bad\"" } +{ "ok": true, "proposal": "p5", "shown_as": "card: \"X is bad\"", "status": "pending: shown to the writer to accept or reject. It is not on the map until they accept it." } { "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. +lower-case, punctuation ignored, and whitespace runs collapsed to one space. Apostrophes +are dropped ("don't" matches "dont"); any other punctuation counts as a space +("well-known" matches "well known"). No other transformation: every letter and number +still has to match, in order, on word boundaries. + +- **Punctuation (Nhyira, 11-01):** ignored because dictated text has little of it and the + model tends to add some when it quotes, which cost refused attempts. It also lets a + writer punctuate a card without losing the link and nesting evidence for it. Fillers + ("um") are not removed: the model quotes around them with two pieces. When a piece matches in more than one message, record the most recent match, and all matches. Each accepted card keeps these `sources` (piece, most recent turn, every turn, @@ -123,12 +135,14 @@ whether the draft also has it). ### 5.2 `propose_link` ```json -{ "from_card": "c1", "to_card": "p5", "label": "because", "evidence": ["string", "..."] } +{ "from_card": "c1", "to_card": "c3", "label": "because", "evidence": ["string", "..."] } ``` -`from_card` and `to_card` may be existing cards or card proposals from this turn. +`from_card` and `to_card` are cards on the map. A card proposal the writer hasn't +accepted (`p5`) is refused with the reason, at every level; an accepted proposal's id is +refused with the card id to use instead (Nhyira, 11-01). -- **Always (L0–L2):** both cards exist (or are pending), they differ, and no identical +- **Always (L0–L2):** both cards are on the map, 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. @@ -149,7 +163,8 @@ whether the draft also has it). { "child": "c2", "parent": "c1", "evidence": ["string", "..."] } ``` -- **Always:** both exist, differ, and nesting would not create a cycle. +- **Always:** both are cards on the map (as for links), differ, and nesting would not + create a cycle. - **L0 and L1:** the same evidence rule as links, steps 1–3 (no label). - **L2:** no evidence needed. An accepted L2 nesting keeps an `AI suggested` mark on the child (like an L2 link) until the writer re-nests the card themselves. @@ -224,20 +239,36 @@ 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**: +- **Chat**, with each tool call as a small block: a top row with the tool name, its + label and **Accept**/**Reject** (or the outcome, e.g. "Accepted as card 3"), and the full + text underneath at any length. Pending proposals stand out; decided ones fade back; a + refusal is one quiet row whose reason opens on request (Nhyira, 11-01): ``` - ✕ suggest_idea · refused ▸ why - ✓ propose_card · "X is bad" [Accept] [Reject] - ✓ propose_link · I like X → because → X is bad [Accept] [Reject] + ✕ suggest_idea refused · why + ✓ propose_card [Accept] [Reject] + "X is bad" + ✓ propose_link AI suggested [Accept] [Reject] + "I like X" → because → "X is bad" That sounds like the reason behind your doubt… ``` -- **Map:** accepted proposals appear; pending ones are not on the map. +- **Map:** accepted proposals appear; pending ones are not on the map. Each card shows + its number (3 for the card the coach knows as `c3`; the instructions tell the coach + to say "card 3"), and an accepted card proposal says which card it became ("Accepted + as card 3"). A card's text is its edit box: click into it and type, Enter or clicking + away saves (one undo step), Escape reverts. The card is as tall as its text, so + nothing scrolls inside it; it is dragged by its top row (Nhyira, 11-01). - **Level picker:** a change applies from the next turn. -- **Draft panel:** a box to paste the draft into. +- **Draft panel:** a box to paste the draft into. Drag its left edge to make it wider or + narrower (arrow keys when the edge has focus, double-click to reset); the width is + remembered in this browser, and the map always keeps at least 280px (Nhyira, 11-01). - **Copy map** and **copy draft** out. +- **Voice input (Nhyira, 11-01):** a mic button above Send, using the browser's own + speech recognition (the current app's `useSpeechToText`; Chrome, Edge and Safari, not + Firefox). Spoken words go into the message box, never straight to the coach: the + writer reads, edits and sends them like typed text. Typing or Send stops dictation. + Better transcription through the backend (punctuation, fewer fillers) is postponed. Keep it simple: no extra panels or diagnostics in v1. diff --git a/next/package-lock.json b/next/package-lock.json index 2b0e555..b4c1ded 100644 --- a/next/package-lock.json +++ b/next/package-lock.json @@ -16,11 +16,63 @@ "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^29.1.1", "typescript": "^5.6.3", "vite": "^6.0.3", "vitest": "^3.2.4" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -303,6 +355,159 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.3.tgz", + "integrity": "sha512-0GEz8qJWokkUVYQYRukmkSHIFNoBH8QTreeHrAFbvz1XFYc3Y+puhMs8PQ5x8GNugiC2l5jvDBXKLbBIByeSBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.2.1" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.3.tgz", + "integrity": "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.7.tgz", + "integrity": "sha512-zgm9yjQz5iJB4VL8RyVwd71TWB8Q89FSxt/NzXoWqPSkEBOVtn9L6vAPtSc9hpKfB0Awo/AL2VKFHjYetBLFHQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.2", + "@csstools/css-calc": "^3.4.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.2", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.2.tgz", + "integrity": "sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.16.tgz", + "integrity": "sha512-LsDbJPP/B8cPasUJyTA5S99F7C4G93ngW8hUluXz8CFeOaaRnBPtBiIlvgcPYU0HmHNz8CXg+1vwFrIB4F6Dgw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -745,6 +950,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1559,6 +1782,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/browserslist": { "version": "4.29.5", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.5.tgz", @@ -1664,6 +1897,20 @@ "dev": true, "license": "MIT" }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1776,6 +2023,20 @@ "node": ">=12" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1794,6 +2055,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -1811,6 +2079,19 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-9.0.0.tgz", + "integrity": "sha512-oJFNDJg3kTPwAD1cNn2+WmUq4wFoo09npc3Ydf1D7ED4smRiWrsKnaLxykrTWgyGEcusc87P5dxlAleol9kpkg==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1933,12 +2214,83 @@ "node": ">=6.9.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "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/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2004,6 +2356,13 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2040,6 +2399,19 @@ "node": ">=18" } }, + "node_modules/parse5": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.1.0.tgz", + "integrity": "sha512-sq3qBFpee5jeocbFbO48VHlGr8sT7mbXS0vxlKN6sottBtJzrOG3998sr0llSZcQAGgfpdmEYAIZE0P9z0dE8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^9.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -2106,6 +2478,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -2141,6 +2523,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "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", @@ -2187,6 +2579,19 @@ "fsevents": "~2.3.2" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -2257,6 +2662,13 @@ "dev": true, "license": "MIT" }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2318,6 +2730,52 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.19", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.19.tgz", + "integrity": "sha512-48dH6aSpQnpbzDUc4vIQZ1VjB8tkK+e8c1yx05/SZK+rkssgjEOlxDd/yU8/a4vGYQ9vRgJr9dwMq9TJ0b3X2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.19" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.20", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.20.tgz", + "integrity": "sha512-C630TWXZCZmLq0F2bW4QqwnaaxCQ/svpAqvJwnC4JvgEM5ohhBnGsEzTLSOXcN+xjz+kEaHS5uoWIw2klnqd0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2332,6 +2790,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "7.30.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz", + "integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/update-browserslist-db": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", @@ -2543,6 +3011,54 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "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", @@ -2560,6 +3076,23 @@ "node": ">=8" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/next/package.json b/next/package.json index 82cf14a..9b9f493 100644 --- a/next/package.json +++ b/next/package.json @@ -19,6 +19,7 @@ "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^29.1.1", "typescript": "^5.6.3", "vite": "^6.0.3", "vitest": "^3.2.4" diff --git a/next/src/loop/context.ts b/next/src/loop/context.ts index 5ecc733..4f78f5b 100644 --- a/next/src/loop/context.ts +++ b/next/src/loop/context.ts @@ -42,6 +42,7 @@ export function instructions(level: Level): string { "", "If the writer declined something, don't raise it again.", "Reply to the writer in plain, short messages.", + "The writer sees cards numbered 1, 2, 3. If you mention one, say \"card 3\", not its id c3.", ].join("\n"); } diff --git a/next/src/loop/turn.test.ts b/next/src/loop/turn.test.ts index 7ce76e4..8c33967 100644 --- a/next/src/loop/turn.test.ts +++ b/next/src/loop/turn.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; 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"; +import { MAX_CALLS, PENDING_NOTE, 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 }] }); @@ -34,6 +34,13 @@ describe("runTurn", () => { expect(s.transcript.map((t) => t.kind)).toEqual(["writer", "tool", "coach"]); }); + it("tells the model a successful proposal is only pending, not on the map", async () => { + const { model } = fake([[call("a", "propose_card", { pieces: ["X is bad"] })], [say("ok")]]); + const s = await runTurn(start(), "because X is bad", { callModel: model }); + const output = s.history.find((h): h is { type: "function_call_output"; call_id: string; output: string } => "type" in h && h.type === "function_call_output"); + expect(JSON.parse(output!.output)).toMatchObject({ ok: true, proposal: "p1", status: PENDING_NOTE }); + }); + 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.")]]); diff --git a/next/src/loop/turn.ts b/next/src/loop/turn.ts index 9f6b736..f604419 100644 --- a/next/src/loop/turn.ts +++ b/next/src/loop/turn.ts @@ -7,6 +7,9 @@ import { ATTEMPT_BUDGET, instructions, stateBlock, visibleHistory } from "./cont /** Safety cap (spec §6): a turn never makes more model calls than this, whatever happens. */ export const MAX_CALLS = 8; +/** What a successful tool call tells the model about the proposal it made. */ +export const PENDING_NOTE = "pending: shown to the writer to accept or reject. It is not on the map until they accept it."; + interface FunctionCall { type: "function_call"; call_id: string; @@ -119,7 +122,8 @@ export async function runTurn(start: Session, writerText: string, deps: TurnDeps 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 }; + // Say it's only proposed, so the model doesn't tell the writer it's on the map. + payload = { ok: true, proposal: result.proposal.id, shown_as: result.summary, status: PENDING_NOTE }; } 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 }] }; diff --git a/next/src/state/map.ts b/next/src/state/map.ts index 97d7106..658fd7e 100644 --- a/next/src/state/map.ts +++ b/next/src/state/map.ts @@ -24,10 +24,12 @@ export function absolutePosition(map: MapState, id: string): { x: number; y: num return { x, y }; } -// Where a newly nested card sits inside its parent (positions are parent-relative). +// Where a newly nested card sits inside its parent (positions are parent-relative): +// below the parent's title (up to three lines show), and far enough apart for cards +// that grow with their text. const CHILD_LEFT = 16; -const CHILD_TOP = 48; -const CHILD_STEP = 110; +const CHILD_TOP = 96; +const CHILD_STEP = 140; /** * Re-parent a card and place it: inside the parent below its existing children, or, @@ -58,12 +60,21 @@ 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 { +/** + * The text of the card on the map that a model reference (link, nesting, suggestion + * placement) points at, or why it can't point there. Only cards on the map count: a + * proposal the writer hasn't decided on mustn't be built on, or the writer gets pushed + * into deciding it later (Nhyira, 11-01). + */ +export function mapCardText(session: Session, ref: string): { text: string } | { reason: string } { const card = findCard(session.map, ref); - if (card) return card.text; + if (card) return { text: card.text }; const p = session.proposals.find((x) => x.id === ref); - return p && p.kind === "card" && p.status === "pending" ? p.text : undefined; + if (p && (p.kind === "card" || p.kind === "suggestion")) { + if (p.status === "pending") return { reason: `${ref} isn't on the map: the writer hasn't accepted it. Only cards on the map can be used here.` }; + if (p.cardId && findCard(session.map, p.cardId)) return { reason: `${ref} is on the map as ${p.cardId}; use ${p.cardId}.` }; + } + return { reason: `There is no card "${ref}" on the map.` }; } /** True if making `parent` the parent of `child` would create a loop. */ @@ -119,7 +130,7 @@ export function deleteCard(session: Session, id: string): Session { .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 } }; + return dropDependents({ ...s, map: { cards, links } }, id, "Its card was deleted."); } /** The writer's own nesting (or un-nesting with `null`). Clears any AI-suggested mark. */ @@ -240,12 +251,21 @@ export function acceptProposal(session: Session, id: string): Session { 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. + // Sessions saved before links needed cards on the map can still name a pending card. + const s = dropDependents(setStatus(session, id, "rejected"), id, "Its card was rejected."); + return { ...s, decisions: [...s.decisions, `rejected ${p.id} (${describe(p)})`] }; +} + +/** + * A pending link or nesting that names `id` can no longer happen, so it is dropped + * rather than left with an Accept that can't work. + */ +function dropDependents(session: Session, id: string, note: string): Session { + let s = session; 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."); + if (refs.includes(id)) s = setStatus(s, other.id, "dropped", note); } - return { ...s, decisions: [...s.decisions, `rejected ${p.id} (${describe(p)})`] }; + return s; } diff --git a/next/src/state/match.ts b/next/src/state/match.ts index 7606b10..48dc59a 100644 --- a/next/src/state/match.ts +++ b/next/src/state/match.ts @@ -2,15 +2,18 @@ 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. + * interpretation. Unicode NFC, lower-case, punctuation ignored, and whitespace runs + * collapsed to one space, so only the letters, numbers and their order are compared. + * Apostrophes are dropped ("don't" matches "dont"); any other punctuation counts as a + * space ("well-known" matches "well known"). Dictated text has little punctuation and + * the model tends to add some when it quotes (Nhyira, 11-01). */ export function norm(text: string): string { return text .normalize("NFC") .toLowerCase() - .replace(/[‘’]/g, "'") - .replace(/[“”]/g, '"') + .replace(/['‘’]/g, "") + .replace(/[^\p{L}\p{N}\p{M}\s]/gu, " ") .replace(/\s+/g, " ") .trim(); } diff --git a/next/src/state/state.test.ts b/next/src/state/state.test.ts index d0c056d..4e70ed9 100644 --- a/next/src/state/state.test.ts +++ b/next/src/state/state.test.ts @@ -25,6 +25,15 @@ describe("proposals", () => { expect(s.proposals.map((p) => p.status)).toEqual(["rejected", "dropped"]); }); + it("deleting a card drops the pending link that named it, so no Accept is left that can't work", () => { + let s = addCard(addCard(withProposals([]), "A"), "B"); + s = { ...s, proposals: [{ id: "p1", status: "pending", kind: "link", from: "c1", to: "c2", label: "so", provenance: { kind: "ai_suggested" } }] }; + s = deleteCard(s, "c2"); + expect(s.proposals[0]).toMatchObject({ status: "dropped", note: "Its card was deleted." }); + // Undo brings the card back and the link with it. + expect(undo(s).proposals[0]?.status).toBe("pending"); + }); + 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" }); diff --git a/next/src/styles.css b/next/src/styles.css index 713bd12..8e99045 100644 --- a/next/src/styles.css +++ b/next/src/styles.css @@ -33,13 +33,20 @@ button:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2p .small { font-size: 12px; } .app { + --chat-w: clamp(340px, 30vw, 480px); height: 100%; display: grid; - grid-template-columns: clamp(340px, 30vw, 480px) minmax(0, 1fr); + grid-template-columns: var(--chat-w) minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); grid-template-areas: "bar bar" "chat map"; } -.app.with-draft { grid-template-columns: clamp(340px, 30vw, 480px) minmax(0, 1fr) 320px; grid-template-areas: "bar bar bar" "chat map draft"; } +/* The draft is as wide as the writer dragged it (--draft-w, set by App), but never + leaves the map less than 280px if the window gets narrower. */ +.app.with-draft { + grid-template-columns: var(--chat-w) minmax(0, 1fr) min(var(--draft-w, 320px), max(240px, calc(100vw - var(--chat-w) - 280px))); + grid-template-areas: "bar bar bar" "chat map draft"; +} +body.resizing { cursor: col-resize; user-select: none; } .toolbar { grid-area: bar; @@ -60,40 +67,59 @@ button:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2p /* 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-log { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; } .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 calls: a top row (tool, label, Accept/Reject or outcome) and the text below. */ .tool-line { display: flex; - flex-wrap: wrap; - align-items: center; - gap: 4px 6px; + flex-direction: column; + gap: 3px; font-size: 12.5px; - padding: 4px 8px; + padding: 6px 10px 7px; + border: 1px solid var(--line); border-left: 3px solid var(--line); + border-radius: 8px; + background: var(--panel); } -.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); } +/* Waiting for the writer: the one that needs a decision stands out. */ +.tool-pending { background: #fff; border-color: #cfe3d6; border-left-color: var(--green); box-shadow: 0 1px 3px rgba(35, 31, 24, 0.06); } +.tool-decided { border-left-color: #b9d8c4; } +.tool-decided .tool-text { color: var(--muted); } +/* A refusal is the coach's own retry, not the writer's business: one quiet row. */ +.tool-refused { background: transparent; border-color: transparent; border-left-color: #e0b8b0; padding-top: 2px; padding-bottom: 2px; color: var(--muted); } +.tool-head { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; min-height: 24px; } +.tool-mark { font-weight: 700; width: 12px; } +.tool-pending .tool-mark, .tool-decided .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); } +.tool-name { font-family: ui-monospace, Consolas, monospace; font-size: 11.5px; color: var(--muted); } +.tool-end { margin-left: auto; display: flex; align-items: center; gap: 4px; } +.tool-end button { padding: 2px 10px; font-size: 12px; } +.tool-end .accept { border-color: #b5dfc5; background: #eafaf0; color: var(--green); } +.tool-end .accept:hover:not(:disabled) { background: #dcf3e5; } +.tool-text { font-size: 13px; color: var(--ink); overflow-wrap: anywhere; padding-left: 18px; } +.tool-note { font-size: 12px; color: var(--muted); padding-left: 18px; } +.tool-status { 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; } +.tool-why-toggle { border: 0; background: none; padding: 0 2px; font-size: 12px; color: var(--muted); } +.tool-why-toggle:hover:not(:disabled) { background: none; color: var(--ink); } +.tool-why { padding: 2px 0 2px 18px; font-size: 12px; color: var(--ink); } +.tool-why .tool-text { padding-left: 0; color: var(--muted); } .composer { display: flex; gap: 8px; padding: 10px 12px 4px; border-top: 1px solid var(--line); } .composer textarea { flex: 1; min-height: 76px; max-height: 40vh; overflow-y: auto; resize: none; border: 1px solid var(--line); border-radius: 10px; padding: 8px 10px; line-height: 1.45; } -.composer .send { align-self: flex-end; background: var(--blue); border-color: var(--blue); color: #fff; } +/* Mic above Send, both at the bottom of the box as it grows. */ +.composer-buttons { display: flex; flex-direction: column; justify-content: flex-end; gap: 6px; } +.composer .send { background: var(--blue); border-color: var(--blue); color: #fff; } .composer .send:hover:not(:disabled) { background: #155d89; } +.composer .mic { display: grid; place-items: center; padding: 5px 10px; } +.composer .mic svg { width: 18px; height: 18px; stroke-width: 1.8; } +.composer .mic.live { background: var(--red); border-color: var(--red); color: #fff; animation: mic-live 1.6s ease-in-out infinite; } +.composer .mic.live:hover:not(:disabled) { background: #8a2f22; } +@keyframes mic-live { 50% { box-shadow: 0 0 0 4px rgba(163, 58, 42, 0.18); } } +@media (prefers-reduced-motion: reduce) { .composer .mic.live { animation: none; } } .hint { padding: 0 12px 8px; font-size: 11px; } /* Map */ @@ -110,16 +136,25 @@ button:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2p 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; } +/* A parent's title shows up to three lines over its children (fading out if there's + more), and opens up while it's edited. */ +.card-parent .card-text { font-weight: 600; } +.card-parent .card-text:not(:focus) { max-height: 4.35em; } +.card-text.clipped { mask-image: linear-gradient(to bottom, #000 calc(100% - 1.3em), transparent); } .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; } +.card-num { font-size: 10.5px; font-weight: 600; color: var(--muted); font-variant-numeric: tabular-nums; margin-right: 2px; } .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; } +/* The card's text is its edit box, styled as plain text: click in and type. */ +.card-text { display: block; width: 100%; min-height: 1.45em; margin: 2px 0 0; padding: 0; border: 0; background: transparent; resize: none; overflow: hidden; line-height: 1.45; cursor: text; } +.card-text:focus-visible { outline: none; } +.card-text::placeholder { color: var(--muted); font-style: italic; } +.card:focus-within { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(26, 111, 163, 0.15); } +.card-head { cursor: grab; } .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); } @@ -155,7 +190,12 @@ button:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2p } /* Draft */ -.draft { grid-area: draft; display: flex; flex-direction: column; min-height: 0; border-left: 1px solid var(--line); background: #fff; } +.draft { grid-area: draft; position: relative; display: flex; flex-direction: column; min-height: 0; border-left: 1px solid var(--line); background: #fff; } +/* A strip over the draft's left border: drag it to resize the draft. */ +.draft-resizer { position: absolute; top: 0; bottom: 0; left: -4px; width: 8px; z-index: 6; cursor: col-resize; touch-action: none; } +.draft-resizer::after { content: ""; position: absolute; top: 0; bottom: 0; left: 3px; width: 2px; background: transparent; transition: background 0.15s; } +.draft-resizer:hover::after, .draft-resizer:focus-visible::after, body.resizing .draft-resizer::after { background: var(--blue); } +.draft-resizer:focus-visible { outline: none; } .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; } @@ -173,4 +213,5 @@ button:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2p @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; } + .draft-resizer { display: none; } } diff --git a/next/src/tools/proposeLink.ts b/next/src/tools/proposeLink.ts index 77b3910..c899ef8 100644 --- a/next/src/tools/proposeLink.ts +++ b/next/src/tools/proposeLink.ts @@ -1,4 +1,4 @@ -import { hasLink, refText } from "../state/map"; +import { hasLink, mapCardText } from "../state/map"; import { obj, quote, refuse, stringList, type Tool } from "./common"; import { checkEvidence } from "./evidence"; @@ -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 proposal still waiting for the writer (p5).", + "Propose a labelled link from one card to another. Cards are referred to by their id on the map (c3). Only cards on the map can be linked: a card proposal the writer hasn't accepted yet (p5) can't, so wait for their decision.", 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.", @@ -29,12 +29,14 @@ export const proposeLink: Tool = { 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}`; + const fromRef = mapCardText(ctx.session, from); + const toRef = mapCardText(ctx.session, to); + const summary = `${"text" in fromRef ? quote(fromRef.text) : from} → ${label || "(no label)"} → ${"text" in toRef ? quote(toRef.text) : 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 ("reason" in fromRef) return refuse(fromRef.reason, summary); + if ("reason" in toRef) return refuse(toRef.reason, summary); + const fromText = fromRef.text; + const toText = toRef.text; 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); diff --git a/next/src/tools/proposeNest.ts b/next/src/tools/proposeNest.ts index 78c72ba..345eeb4 100644 --- a/next/src/tools/proposeNest.ts +++ b/next/src/tools/proposeNest.ts @@ -1,4 +1,4 @@ -import { findCard, refText, wouldCycle } from "../state/map"; +import { findCard, mapCardText, wouldCycle } from "../state/map"; import { obj, quote, refuse, stringList, type Tool } from "./common"; import { checkEvidence } from "./evidence"; @@ -7,7 +7,7 @@ export const proposeNest: Tool = { name: "propose_nest", description: (level) => [ - "Propose putting one card under another (child under parent). Cards are referred to by id: an existing card (c3) or a card proposal still waiting for the writer (p5).", + "Propose putting one card under another (child under parent). Cards are referred to by their id on the map (c3). Only cards on the map can be nested: a card proposal the writer hasn't accepted yet (p5) can't, so wait for their decision.", level === 2 ? "At this level you may propose nesting freely; it is shown as AI suggested. `evidence` may be empty." : "At this level the writer must have stated it themselves. `evidence` is one piece of the writer's words, or two pieces from two of their messages next to each other, and both cards' text must appear in it.", @@ -27,12 +27,14 @@ export const proposeNest: Tool = { 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}`; + const childRef = mapCardText(ctx.session, child); + const parentRef = mapCardText(ctx.session, parent); + const summary = `${"text" in childRef ? quote(childRef.text) : child} under ${"text" in parentRef ? quote(parentRef.text) : 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 ("reason" in childRef) return refuse(childRef.reason, summary); + if ("reason" in parentRef) return refuse(parentRef.reason, summary); + const childText = childRef.text; + const parentText = parentRef.text; 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); diff --git a/next/src/tools/suggestIdea.ts b/next/src/tools/suggestIdea.ts index b87f089..9a37c52 100644 --- a/next/src/tools/suggestIdea.ts +++ b/next/src/tools/suggestIdea.ts @@ -1,4 +1,4 @@ -import { refText } from "../state/map"; +import { mapCardText } from "../state/map"; import { obj, refuse, type Tool } from "./common"; /** @@ -9,7 +9,7 @@ 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. It is shown to the writer as an AI suggestion, and they accept or reject it. `near_card` optionally names the card on the map (c3) 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", @@ -24,7 +24,8 @@ export const suggestIdea: Tool = { 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); + const nearRef = near ? mapCardText(ctx.session, near) : null; + if (nearRef && "reason" in nearRef) return refuse(nearRef.reason, 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 index 3390904..c1558ae 100644 --- a/next/src/tools/tools.test.ts +++ b/next/src/tools/tools.test.ts @@ -131,14 +131,6 @@ describe("propose_link and propose_nest: the evidence rule", () => { 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); @@ -167,6 +159,36 @@ describe("propose_link and propose_nest: the evidence rule", () => { }); }); +describe("only cards on the map can be linked, nested or placed near (Nhyira, 11-01)", () => { + // Two card proposals the writer hasn't decided on yet. + function pendingCards(level: Level = 0): Session { + let s = session(["I like X because X is bad"], { level }); + s = apply(s, run(proposeCard, { pieces: ["I like X"] }, s)); + return apply(s, run(proposeCard, { pieces: ["X is bad"] }, s)); + } + + it("refuses a link to a card the writer hasn't accepted, and says why", () => { + const r = run(proposeLink, { from_card: "p1", to_card: "p2", label: "because", evidence: ["I like X because X is bad"] }, pendingCards()); + expect(r).toMatchObject({ ok: false, reason: "p1 isn't on the map: the writer hasn't accepted it. Only cards on the map can be used here." }); + }); + + it("refuses it at L2 too, where links are otherwise free", () => { + expect(run(proposeLink, { from_card: "p1", to_card: "p2", label: "could use", evidence: [] }, pendingCards(2)).ok).toBe(false); + }); + + it("refuses nesting under, and suggestions near, a card the writer hasn't accepted", () => { + expect(run(proposeNest, { child: "p2", parent: "p1", evidence: ["I like X because X is bad"] }, pendingCards()).ok).toBe(false); + expect(run(suggestIdea, { text: "Try Z", near_card: "p1" }, pendingCards(2)).ok).toBe(false); + }); + + it("links them once both are accepted, by their card ids", () => { + const s = acceptProposal(acceptProposal(pendingCards(), "p1"), "p2"); + expect(run(proposeLink, { from_card: "c1", to_card: "c2", label: "because", evidence: ["I like X because X is bad"] }, s).ok).toBe(true); + // The proposal id of an accepted card points the model to its card id. + expect(run(proposeLink, { from_card: "p1", to_card: "c2", label: "because", evidence: ["I like X because X is bad"] }, s)).toMatchObject({ ok: false, reason: "p1 is on the map as c1; use c1." }); + }); +}); + 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." }); @@ -232,3 +254,51 @@ describe("typos (Nhyira, option A: quote exactly as typed)", () => { expect(proposeCard.description(0)).toContain("typos included"); }); }); + +describe("punctuation is ignored (Nhyira, 11-01: for dictated text)", () => { + // Dictation gives words with little or no punctuation. + const spoken = session(["so i think the main problem with remote work is that nobody sees the small wins um and that makes people feel invisible"]); + + it("accepts punctuation the writer didn't type", () => { + expect(run(proposeCard, { pieces: ["nobody sees the small wins."] }, spoken).ok).toBe(true); + expect(run(proposeCard, { pieces: ["the main problem with remote work, is that nobody sees"] }, spoken).ok).toBe(true); + expect(run(proposeCard, { pieces: ["“the small wins”"] }, spoken).ok).toBe(true); + }); + + it("accepts a piece without the punctuation the writer did type", () => { + const s = session(["Cost, mostly. And time; the well-known kind."]); + expect(run(proposeCard, { pieces: ["cost mostly and time"] }, s).ok).toBe(true); + expect(run(proposeCard, { pieces: ["the well known kind"] }, s).ok).toBe(true); + }); + + it("drops apostrophes, so don't and dont match both ways", () => { + expect(run(proposeCard, { pieces: ["I don't like it"] }, session(["i dont like it"])).ok).toBe(true); + expect(run(proposeCard, { pieces: ["I dont like it"] }, session(["I don’t like it"])).ok).toBe(true); + }); + + it("still compares every letter, word boundaries and fillers", () => { + expect(run(proposeCard, { pieces: ["I do now like it"] }, session(["I do not like it."])).ok).toBe(false); + expect(run(proposeCard, { pieces: ["ike it"] }, session(["I like it."])).ok).toBe(false); + // Leaving out a filler still needs two pieces around it. + expect(run(proposeCard, { pieces: ["the small wins and that makes people feel invisible"] }, spoken).ok).toBe(false); + expect(run(proposeCard, { pieces: ["the small wins", "and that makes people feel invisible"] }, spoken).ok).toBe(true); + }); + + it("refuses a piece that is only punctuation", () => { + const r = run(proposeCard, { pieces: ["..."] }, spoken); + expect(!r.ok && r.reason).toBe("Every piece needs at least one word."); + }); + + it("treats a card that differs only in punctuation as a duplicate", () => { + let s = session(["the real cost is time"]); + s = { ...s, map: { cards: [{ id: "c1", text: "The real cost is time.", parentId: null, provenance: { kind: "user" }, x: 0, y: 0 }], links: [] } }; + const r = run(proposeCard, { pieces: ["the real cost is time"] }, s); + expect(!r.ok && r.reason).toContain("already on the map"); + }); + + it("still links a card after the writer adds punctuation to it", () => { + let s = spoken; + s = { ...s, map: { cards: [{ id: "c1", text: "Remote work", parentId: null, provenance: { kind: "user" }, x: 0, y: 0 }, { id: "c2", text: "Nobody sees the small wins.", parentId: null, provenance: { kind: "user" }, x: 0, y: 0 }], links: [] } }; + expect(run(proposeLink, { from_card: "c1", to_card: "c2", label: "is that", evidence: ["remote work is that nobody sees the small wins"] }, s).ok).toBe(true); + }); +}); diff --git a/next/src/ui/App.tsx b/next/src/ui/App.tsx index 07aa8f2..375abff 100644 --- a/next/src/ui/App.tsx +++ b/next/src/ui/App.tsx @@ -1,5 +1,5 @@ import { ReactFlowProvider } from "@xyflow/react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; import { makeCallResponses } from "../loop/client"; import { runTurn } from "../loop/turn"; import { mapToMarkdown } from "../state/export"; @@ -20,6 +20,7 @@ import { loadSession, saveSession } from "../state/persist"; import { emptySession, type Level, type Session } from "../state/types"; import { Chat } from "./Chat"; import { copyText } from "./clipboard"; +import { DraftResizer, loadDraftWidth, saveDraftWidth } from "./DraftResizer"; import { MapView, type MapActions } from "./MapView"; const LEVELS: { value: Level; label: string }[] = [ @@ -43,6 +44,7 @@ export function App({ bearerToken, onAccessError, aiAccessDenied = false, onDisc const [busy, setBusy] = useState(false); const [editId, setEditId] = useState(null); const [showDraft, setShowDraft] = useState(true); + const [draftWidth, setDraftWidth] = useState(loadDraftWidth); const [flash, setFlash] = useState(null); const [saved, setSaved] = useState(true); @@ -58,6 +60,10 @@ export function App({ bearerToken, onAccessError, aiAccessDenied = false, onDisc setSaved(saveSession(session)); }, [session]); + useEffect(() => { + saveDraftWidth(draftWidth); + }, [draftWidth]); + useEffect(() => { if (!flash) return; const t = setTimeout(() => setFlash(null), 2000); @@ -137,7 +143,7 @@ export function App({ bearerToken, onAccessError, aiAccessDenied = false, onDisc }; return ( -
+