From bb4a4bc5bec57fafd78a3dc135359eedf5b6949c Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Sun, 8 Nov 2026 23:18:22 +0800 Subject: [PATCH] Fold canonical equivalents into the three identity keys that bypassed the name rule Fixes #2692 A sibling Bot name, a role label and a memory topic compared with their own trim().toLowerCase(), so either canonical spelling of one visible name was two identities. They now use the name key #2691 put in core/src/shared/identity.js, and a topic file keeps the spelling it was written with so nothing already saved is lost. --- changelog.d/2692-identity-sites.fixed.md | 1 + core/client.js | 7 +- core/src/host/memory.js | 32 +++-- core/src/host/text.js | 8 +- core/src/shared/identity.js | 7 ++ core/src/shared/task-board.js | 7 +- core/test/bot-tasks.test.js | 19 ++- core/test/memory.test.js | 33 +++++ docs/subsystems/core-client.md | 2 +- docs/subsystems/core-host.md | 2 +- docs/subsystems/tree.md | 1 + patches/ds-bot/0001-core-fixes.patch | 113 +++++++++++++++--- patches/ds-bot/0008-client-bundle.patch | 17 +-- patches/ds-bot/files/src/shared/identity.js | 7 ++ patches/ds-bot/files/src/shared/task-board.js | 7 +- patches/ds-bot/files/test/bot-tasks.test.js | 19 ++- plugins/tree/src/identity.js | 6 + plugins/tree/src/limits.js | 7 +- plugins/tree/src/tree.js | 9 +- plugins/tree/test/identity.test.js | 23 ++++ plugins/tree/test/tree.test.js | 23 ++++ 21 files changed, 299 insertions(+), 51 deletions(-) create mode 100644 changelog.d/2692-identity-sites.fixed.md create mode 100644 core/src/shared/identity.js create mode 100644 patches/ds-bot/files/src/shared/identity.js create mode 100644 plugins/tree/src/identity.js create mode 100644 plugins/tree/test/identity.test.js diff --git a/changelog.d/2692-identity-sites.fixed.md b/changelog.d/2692-identity-sites.fixed.md new file mode 100644 index 000000000..8de090f5c --- /dev/null +++ b/changelog.d/2692-identity-sites.fixed.md @@ -0,0 +1 @@ +A sibling Bot name, a member's role label and a memory topic written in either Unicode canonical form are now one identity each: `create_child_bot` refuses a child whose name only spells the sibling's name the other way, a queued task card reaches the member whose role label is the same role, and one topic is one file — a file written under an older spelling is still the topic's file, so nothing already saved is lost ([#2692](https://github.com/ThinkFlowLab/FlyBot/issues/2692)). diff --git a/core/client.js b/core/client.js index bfa4ce3c9..2d131c7ae 100644 --- a/core/client.js +++ b/core/client.js @@ -8729,6 +8729,9 @@ body[data-ds-dark-theme] .bt-mw-ask{color:#f0b84a} @media (prefers-reduced-motion:reduce){.bt-mw-row{animation:none}} `; +// src/shared/identity.js +var nameKey = (value) => stripInvisible(String(value ?? "").normalize("NFC")).trim().toLowerCase().normalize("NFC"); + // src/shared/task-board.js var COLUMNS = ["not-started", "in-progress", "done"]; var PRIORITIES = ["urgent", "high", "normal", "low"]; @@ -8767,8 +8770,8 @@ function compareForWork(a, b) { return (b?.updatedAt ?? 0) - (a?.updatedAt ?? 0); } function sameRole(a, b) { - const left = String(a ?? "").trim().toLowerCase(); - const right = String(b ?? "").trim().toLowerCase(); + const left = nameKey(a); + const right = nameKey(b); return left !== "" && left === right; } function compareForQueue(a, b) { diff --git a/core/src/host/memory.js b/core/src/host/memory.js index acb6d606e..7854acb6e 100644 --- a/core/src/host/memory.js +++ b/core/src/host/memory.js @@ -29,6 +29,7 @@ import { currentParticipants } from './room-membership.js' import { defineTool } from './tool-schema.js' import { clip, codePoints, splitRef, unquote } from './text.js' import { cut, fits } from '../shared/text-boundary.js' +import { nameKey } from '../shared/identity.js' import { output } from './tools.js' import { retiredBot } from './store.js' import { botNode, buildGraph, buildIndex, relatedOf, searchIndex, topicNode } from './memory-search.js' @@ -106,9 +107,11 @@ export function cleanEntry(raw) { return splitMeta(line).text } -// A flat file name: case folds on some file systems, so names are lower case. +// A flat file name: case folds on some file systems, so names are lower case. Since #2692 the name +// is also the shared name key, so canonical equivalents are one topic and one file, and a +// decomposed Latin topic (refused while U+0301 was not a letter) is accepted as its NFC form. export function cleanTopic(raw) { - const name = String(raw ?? '').trim().replace(/^\[\[|\]\]$/g, '').replace(/\.md$/i, '').trim().toLowerCase().replace(/\s+/g, '-') + const name = nameKey(raw).replace(/^\[\[|\]\]$/g, '').replace(/\.md$/i, '').replace(/\s+/g, '-') return /^[\p{L}\p{N}][\p{L}\p{N}_-]{0,47}$/u.test(name) && name !== 'memory' ? name : undefined } @@ -511,23 +514,31 @@ export function install(rt) { return '' } } - const topicNamesIn = (dir) => { + // A topic file's name is the topic, but not always the key: a file written before #2692 keeps the + // spelling it was written with (a decomposed Hangul syllable among them), so the topic is the key + // computed from that name and the name itself is still the file recall reads and remember + // appends to. When both spellings exist, the one already in key form wins. + const topicFilesIn = (dir) => { try { - return readdirSync(dir).filter(name => name.endsWith('.md') && name !== MAIN).map(name => name.slice(0, -3)).sort() + const byTopic = new Map() + for (const file of readdirSync(dir).filter(name => name.endsWith('.md') && name !== MAIN).sort()) { + const topic = nameKey(file.slice(0, -3)) + if (!byTopic.has(topic) || file === `${topic}.md`) byTopic.set(topic, file) + } + return [...byTopic].map(([topic, file]) => ({ topic, file })) } catch { return [] } } const topicNames = (scope) => { const dir = scopeDir(scope) - return dir === undefined ? [] : topicNamesIn(dir) + return dir === undefined ? [] : topicFilesIn(dir).map(file => file.topic) } // MEMORY.md first, then the topics by name. const loadScope = (scope) => { const dir = dirOf(scope) const open = scopeDir(scope) !== undefined - return [undefined, ...(open ? topicNamesIn(dir) : [])].map((topic) => { - const file = topic === undefined ? MAIN : `${topic}.md` + return [{ topic: undefined, file: MAIN }, ...(open ? topicFilesIn(dir) : [])].map(({ topic, file }) => { const content = open ? readText(join(dir, file)) : '' return { topic, file, path: join(dir, file), content, ...parseMemory(content) } }) @@ -690,7 +701,7 @@ export function install(rt) { const next = joinLines(insertEntry(base, line)) checkSize(target, next, label) let index - if (topic !== undefined && !main.links.includes(topic)) { + if (topic !== undefined && !main.links.some(link => nameKey(link) === topic)) { index = joinLines(addLink(main.lines.length > 0 ? main.lines : parseMemory(EMPTY_MAIN).lines, topic)) checkSize(main, index, label) } @@ -745,7 +756,10 @@ export function install(rt) { if (empty) { await unlink(old.file.path).catch(error => { if (error?.code !== 'ENOENT') throw error }) const main = files[0] - if (main.links.includes(old.file.topic)) await writeText(main.path, joinLines(removeLink(main.lines, old.file.topic))) + // The link was written with whatever spelling the topic had then; remove that one, not a + // second entry the key would otherwise leave behind (#2692). + const link = main.links.find(one => nameKey(one) === old.file.topic) + if (link !== undefined) await writeText(main.path, joinLines(removeLink(main.lines, link))) } else { await writeText(old.file.path, joinLines(lines)) } diff --git a/core/src/host/text.js b/core/src/host/text.js index 61c41de38..903a5b650 100644 --- a/core/src/host/text.js +++ b/core/src/host/text.js @@ -2,6 +2,7 @@ // cutting and estimating text, and the clock format of chat anchors. import { COLORS, EXTRA_COLORS, LEGACY_COLORS, OWN_CHAT_LINE_MAX } from './constants.js' import { codePointEnd, codePointStart, cut, fits } from '../shared/text-boundary.js' +import { nameKey } from '../shared/identity.js' import { stripInvisible } from '../shared/mentions.js' export { cut, stripInvisible } @@ -21,10 +22,9 @@ export const textOf = content => (Array.isArray(content) ? content : []) .join('\n') .trim() -// The identity key for a name (#2691): both canonical spellings (NFC and NFD) of one -// visible name are one key, and so are the invisible characters `trim()` keeps. The NFC -// pass runs again after lowercasing because a lower can compose a new sequence (U+0130). -export const normalize = value => stripInvisible(String(value ?? '').normalize('NFC')).trim().toLowerCase().normalize('NFC') +// The identity key for a name (#2691, #2692): one function in `shared/identity.js`, so the tree's +// sibling names, a role label and a memory topic compare by the same rule as a Bot name does. +export const normalize = nameKey export const unquote = text => String(text ?? '').trim().replace(/^["'“”「『《]+|["'“”」』》]+$/g, '').trim() diff --git a/core/src/shared/identity.js b/core/src/shared/identity.js new file mode 100644 index 000000000..c87a6b29b --- /dev/null +++ b/core/src/shared/identity.js @@ -0,0 +1,7 @@ +// The identity key for a visible name (#2691, #2692), in one place so the host, a shared module and +// a name that is also a file name cannot drift apart. Both canonical spellings (NFC and NFD) of one +// visible name are one key, and so are the invisible format characters `trim()` keeps. The NFC pass +// runs again after lowercasing because a lower can compose a new sequence (U+0130). +import { stripInvisible } from './mentions.js' + +export const nameKey = value => stripInvisible(String(value ?? '').normalize('NFC')).trim().toLowerCase().normalize('NFC') diff --git a/core/src/shared/task-board.js b/core/src/shared/task-board.js index b44e6abfa..bd2b23a72 100644 --- a/core/src/shared/task-board.js +++ b/core/src/shared/task-board.js @@ -1,6 +1,7 @@ // Board columns for the task records in src/host/tasks.js (#1058). // The state machine stays the record. A column is how the board reads it. // result-ready still waits for acceptance, so it stays In progress. +import { nameKey } from './identity.js' export const COLUMNS = ['not-started', 'in-progress', 'done'] export const PRIORITIES = ['urgent', 'high', 'normal', 'low'] @@ -54,9 +55,11 @@ export function compareForWork(a, b) { // A role label, matched the way a person reads it (#1247): free text, trimmed, case-insensitive. // The label is not a membership role (`admin` / `member`), so nothing here reads that field. +// Since #2692 the comparison is the shared name key, so either canonical spelling of one visible +// label is one role and a queued card reaches the carrier that spells it the other way. export function sameRole(a, b) { - const left = String(a ?? '').trim().toLowerCase() - const right = String(b ?? '').trim().toLowerCase() + const left = nameKey(a) + const right = nameKey(b) return left !== '' && left === right } diff --git a/core/test/bot-tasks.test.js b/core/test/bot-tasks.test.js index 359681051..aca4d651d 100644 --- a/core/test/bot-tasks.test.js +++ b/core/test/bot-tasks.test.js @@ -9,7 +9,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' import { apply } from '../index.js' -import { columnOf, occupiesSlot } from '../src/shared/task-board.js' +import { columnOf, occupiesSlot, sameRole } from '../src/shared/task-board.js' import { fakeContext } from './fake-ctx.js' const team = () => ({ @@ -681,3 +681,20 @@ test('only a claimed or working card occupies the member\'s WIP slot (#1744)', ( assert.equal(occupiesSlot({}), false) assert.equal(columnOf({ status: 'result-ready' }), 'in-progress', 'the board reading is unchanged') }) + +// #2692: a role label is one identity whichever canonical spelling it is written in, so a queued +// card reaches the member whose label spells the same role instead of staying in the pool forever. +test('#2692 a role label is one identity whichever canonical spelling it uses', () => { + const nfc = 'Caf\u00e9' + const nfd = 'Cafe\u0301' + assert.equal(sameRole(nfc, nfd), true) + assert.equal(sameRole(nfd, nfc), true) + // the folded spelling is the one the tree plugin hands out as a role label + assert.equal(sameRole(` ${nfd} `, nfc), true) + // the invisible format characters trim() keeps are part of the same identity + assert.equal(sameRole(`${nfd}\u200b`, nfc), true) + // a different role still is a different role, and an empty label still matches nothing + assert.equal(sameRole(nfd, 'Caf\u00e9 writer'), false) + assert.equal(sameRole('', ''), false) + assert.equal(sameRole(undefined, nfc), false) +}) diff --git a/core/test/memory.test.js b/core/test/memory.test.js index f5cf339fa..d6a32dd01 100644 --- a/core/test/memory.test.js +++ b/core/test/memory.test.js @@ -227,6 +227,39 @@ test('topics hold details, linked from MEMORY.md, and go when their last entry d assert.equal(read('bots', 'b2', 'MEMORY.md'), '# Memory\n\n## Index\n') }) +// #2692: a topic is one identity and one file name. Canonically equivalent spellings used to be +// two topics writing two files, so one topic split into two memories. A decomposed Latin topic is +// refused by the \p{L} rule, so the collision needs a script whose decomposition stays letters: +// the Hangul syllable 가 (U+AC00) and U+1100 U+1161. +test('#2692 a topic is one identity whichever canonical spelling it is written in', async (t) => { + const { tool, read, stop, home } = start() + t.after(() => stop()) + assert.equal(cleanTopic('\uac00'), cleanTopic('\u1100\u1161')) + assert.equal(cleanTopic('\u1100\u1161'), '\uac00', 'the file stem is the canonical form') + await tool('remember', { text: 'Flies from Hangzhou', topic: '\uac00' }, 'b2') + // the other spelling names the same topic, and reads the same file + assert.match(await tool('recall', { topic: '\u1100\u1161' }, 'b2'), /^Topic \[\[\uac00\]\] in your memory \(\uac00\.md: \d+ of 16000 characters\):\n# \uac00\n\n- Flies from Hangzhou/) + assert.match(await tool('remember', { text: 'Prefers window seats', topic: '\u1100\u1161' }, 'b2'), /in topic \[\[\uac00\]\] \(\uac00\.md: \d+ of 16000 characters\)/) + assert.deepEqual(readdirSync(join(home, 'memory', 'bots', 'b2')).sort(), ['MEMORY.md', '\uac00.md']) + assert.equal(read('bots', 'b2', 'MEMORY.md'), '# Memory\n\n## Index\n- [[\uac00]]\n') +}) + +// A topic file written before this change keeps the spelling it was written with. The key is +// computed from the name, so the file is still the one recall reads and remember appends to. +test('#2692 a topic saved under another spelling before this change is still the file it names', async (t) => { + const { tool, read, stop, home } = start() + t.after(() => stop()) + const dir = join(home, 'memory', 'bots', 'b2') + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'MEMORY.md'), '# Memory\n\n## Index\n- [[\u1100\u1161]]\n') + writeFileSync(join(dir, '\u1100\u1161.md'), '# legs\n\n- Saved the old way [added: 2026-01-01]\n') + assert.match(await tool('recall', { topic: '\uac00' }, 'b2'), /^Topic \[\[\uac00\]\] in your memory \(\u1100\u1161\.md: \d+ of 16000 characters\):\n# legs/) + assert.match(await tool('remember', { text: 'Added after the fix', topic: '\uac00' }, 'b2'), /in topic \[\[\uac00\]\]/) + assert.deepEqual(readdirSync(dir).sort(), ['MEMORY.md', '\u1100\u1161.md'], 'no second file for the same topic') + assert.match(read('bots', 'b2', '\u1100\u1161.md'), /- Added after the fix \[/) + assert.equal(read('bots', 'b2', 'MEMORY.md'), '# Memory\n\n## Index\n- [[\u1100\u1161]]\n', 'the link it already had is not written twice') +}) + test('Main Bots and group admins write the team memory; only Main Bots touch another Bot\'s', async (t) => { const { tool, read, stop } = start() t.after(() => stop()) diff --git a/docs/subsystems/core-client.md b/docs/subsystems/core-client.md index 327d92a56..668d2912a 100644 --- a/docs/subsystems/core-client.md +++ b/docs/subsystems/core-client.md @@ -7,7 +7,7 @@ owned_paths: # core-client -The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/client/`, built into the committed bundle `core/client.js`. Split out of [ds-bot-core](ds-bot-core.md) so a host-only change does not have to edit this page. `client.js` is generated: never hand-edit it, run `npm run build` in `core/` and commit both. The bundle also carries the `core/src/shared/` modules the client imports — `mentions.js` (the `@Name` rule, `nameProblem` and `stripInvisible`), `text-boundary.js`, `remote-label.js`, `approval-waiting.js`, `room-share.js` among them — so a change in one of those rebuilds `client.js` too even when no file under `core/src/client/` was edited; the rules themselves are documented on [core-host](core-host.md#key-files). +The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/client/`, built into the committed bundle `core/client.js`. Split out of [ds-bot-core](ds-bot-core.md) so a host-only change does not have to edit this page. `client.js` is generated: never hand-edit it, run `npm run build` in `core/` and commit both. The bundle also carries the `core/src/shared/` modules the client imports — `mentions.js` (the `@Name` rule, `nameProblem` and `stripInvisible`), `text-boundary.js`, `remote-label.js`, `approval-waiting.js`, `room-share.js`, `identity.js` (the name key `nameKey`, #2692, which `task-board.js` compares a role label with) among them — so a change in one of those rebuilds `client.js` too even when no file under `core/src/client/` was edited; the rules themselves are documented on [core-host](core-host.md#key-files). ## Owns diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 7070b22a2..93cb6e75c 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -38,7 +38,7 @@ The server side of the patched `ds-bot` core (`core/`): the part of [ds-bot-core | --- | --- | | `core/index.js` | Server entry. | | `core/src/host/` | Host-side helpers (non-hooks). | -| `core/src/host/text.js` | The pure text helpers the host modules share: message text, names, model refs, cutting and estimating text, and the clock format of chat anchors. Since #1895 `codePoints(text)` (a step per code point, no array built since #1898) is how the host counts a limit — one character costs one whatever its UTF-16 width — and the bounds this change puts through it are: a memory entry of 300 and a `MEMORY.md` of 4,000 or a topic of 16,000 (the refusal and the size note print the code-point number), a scheduled task's name of 120, a message of 20,000, and the Project memory entry of 600 in both writers, so an emoji is never charged twice. Both cuts land on a code point boundary: `clip` and `excerpt` move back rather than keep half of a surrogate pair, so a clipped emoji reaches a UTF-8 encoder as the character and not as U+FFFD ([#1907](https://github.com/ThinkFlowLab/FlyBot/issues/1907)). Since #1912 that rule is one exported helper rather than a private detail: `cut(text, max)` answers `{ text, cut }` with the longest prefix that stops before a split pair, and the archive writers that used to `slice(0, max)` go through it — the `oneLine` of `decisions.js` and `meetings.js` (into `decisions.jsonl` and `decisions/-.md`), `claimLine` in `groups.js`, the report cap in `diagnostics.js`, `cleanLabel` in `flybot-feed.js` and `clean` in `browser.js` — so a decision or a meeting record holds the whole character instead of U+FFFD. Since #1916 the boundary rule itself lives in `core/src/shared/text-boundary.js` (`codePointStart`, `codePointEnd`, `cut`), and `host/text.js` builds its `cut` on it so a shared module can bound text too; the eight remaining `slice(0, max)` writers follow it — the claim cue's fallback reference (60), the task lead's name in a member prompt (80), a failed group Session's error text (300), `read_own_chat`'s `query` (80) and `around` (40), the open-page address and title (2000/300), the diagnostics browser-log line and its code (500/40), and `cleanPeerName` in `shared/remote-label.js` (60) — each keeping the longest prefix that stops before a split pair, `length <= max` and a byte prefix of the input. Since #1918 the memory review's own two bounds go through the same rule: its 800-character context `clip` cuts through `cut`, and its 3,000-character two-ended `ends` spends the budget in characters at both ends — `charIndex` steps whole code points to find the head's end and the tail's start, so the head is the rendered line's first 2,000 characters and the tail its last 1,000, and the text is flattened with `toWellFormed()` first as `cut` did — so the prompt and the log hold whole characters within the same character budget: a line of 3,500 emoji keeps 3,000 characters where unit arithmetic kept 1,503 ([#2553](https://github.com/ThinkFlowLab/FlyBot/issues/2553)); the exported `clip`'s reason is optional there too, so a two-argument call reads `… (cut)` instead of `… (cut; undefined)` while a call with a reason is unchanged. Since #1923 the four bounds whose text crosses an owner boundary go through the same `cut` — `cleanPublishedName` in `hooks.js` (60, into `state.flybot.remote`), the group member line's `displayName` 60 and `ownerName` 40 in `tools.js`, `label` in `reach.js` (60, the `othersInRoom` labels), and the local `clip` in `shared/room-share.js` (its per-field caps included) — so a published, listed or relayed name keeps whole characters and never reaches a UTF-8 encoder as U+FFFD ([#1923](https://github.com/ThinkFlowLab/FlyBot/issues/1923)). The same rule now covers the host's own line bounds: the busy ack's 600-unit task and 1,500-unit message text in `busy-ack.js`, the group line's notice (100), a Bot's job line (120), the group answer echo (300) and the rework line's note (200) in `tools.js`, and `cleanName`'s 60-unit peer name in `delivery.js`, which lands in `fenceUntrusted`'s `from="…"` attribute — so none of them can end in half a surrogate pair. The five bounds `turns.js` keeps go through `cut` as well: a failure's 500-unit message and the 200-unit message of its line (`turnFailure`, `failureLine`), the 79-unit step a running call shows (the `…` is appended only when `cut` dropped something, never by a length guess), an approval card's 2,000-unit description and 4,000-unit command (the card stores the command "Allow once" runs rather than a preview of it, and one that still had to be cut ends with `…` inside that bound, so the card says what it abridged and never drops the fact, [#1992](https://github.com/ThinkFlowLab/FlyBot/issues/1992)), and the 160-unit reason a failed turn leaves for the inbox. Since #1963 `cut` also flattens its input before it bounds — `String(text).toWellFormed()` first, `codePointEnd` on the flat string — so a title, a note or a name that arrives already carrying an unpaired surrogate is stored as U+FFFD instead of the lone surrogate, and U+FFFD is one UTF-16 unit for the one it replaces, so a well-formed input keeps its exact prefix and its `cut` flag ([#1963](https://github.com/ThinkFlowLab/FlyBot/issues/1963)). Since #1979 the six remaining bounds on text another home or another person supplied go through it too — a connector item name (80, `connectors.js`), a role label another home set on a face-room seat (80, `face-lead.js`), a cross-home group name (200, `project-team.js`), a capability description from a peer server (2,000, `capabilities.js`), the remote home's own error text (157, `remote-chat.js`) and an abbreviated remote reply (80, same file) — so each keeps the longest prefix that stops before a split pair, is well formed and is a code-point prefix of the input, while `remote-chat.js`'s `…` and trailing-period strip still apply to the cut value ([#1979](https://github.com/ThinkFlowLab/FlyBot/issues/1979)). The two bounds on a friend's Bot's own name in that file — the name its chat is created with (40) and the name a refresh writes (40) — go through `cut` as well, so a refresh cannot store a lone high surrogate and a create no longer hands `roster.createBot` half a pair to flatten into U+FFFD; the budget stays 40 and a shorter name is stored word for word. Since #1993 the last five bounds in `core/src/host/` go through it too — the quoted first line of a checkpoint's kept tail (80, `steward.js`), an approval record's `reason` (500, `approvals.js`), a mention receipt's reason line (240, `hooks.js`), the title given to a DSH Agent's Session (80, `AGENT_SESSION_TITLE_MAX` in `agents.js`) and a schedule run's failure `reason` (200, `schedules.js`) — so each keeps the longest prefix that stops before a split pair, is well formed and is a code-point prefix of the input, and none of the five maxima changes ([#1993](https://github.com/ThinkFlowLab/FlyBot/issues/1993)). Since #2002 the connector's own error text goes through it too: the `reason` in `core/src/host/connectors.js` — the text an MCP server's failure leaves on the owner's connector card — keeps the longest prefix that stops before a split pair, so it is well formed and a code-point prefix of what the server sent; since #2016 that prefix is 2,000 units rather than 300, and one that had to be cut ends with `…` inside the bound, so the card says what it abridged ([#2002](https://github.com/ThinkFlowLab/FlyBot/issues/2002), [#2016](https://github.com/ThinkFlowLab/FlyBot/issues/2016)). Since #2005 the five bounds the Project panels read go through it as well — the assistant card's 77-unit title in `project-assistant.js`, the board's 200-unit one-line value and a decision title's 200 in `project-board.js`, and `capped`'s name, role, persona, model and color caps plus the dossier's 4,000-unit memory head in `project-team.js`, whose head is bounded where `memoryDigest` reads it (`memory.js`), because a cut at the dossier alone could only turn half a pair into U+FFFD and never drop the character whole — so a dossier row or a board card keeps whole characters ([#2005](https://github.com/ThinkFlowLab/FlyBot/issues/2005)). Since [#2321](https://github.com/ThinkFlowLab/FlyBot/issues/2321) a bound also judges in code points, not only counts in them: `fits(text, max)` in `core/src/shared/text-boundary.js` is the one predicate — a character outside the BMP spends one of the budget and not two — and `clip` and `excerpt` judge with it while their cuts keep going through `codePointEnd`/`cut`, so a 300-character text is no longer cut by a 400-character limit on its way to `read_own_chat` and the search rows. Since [#2339](https://github.com/ThinkFlowLab/FlyBot/issues/2339) the two bounds in `core/src/host/` that still judged with `text.length` use it as well — the memory review's 3,000-character two-ended `ends` in `memory.js` and `room-context.js`'s `oneLine` (40/80/24/60) — so a user line or a board line of characters outside the BMP stays whole inside its own limit instead of being cut at half of it; both maxima are unchanged and a board line's cut stays on `cut`, so an ASCII text judges exactly as `length <= max` did. The memory review's own third `clip` is the exported one now, so its cut reads `… (cut)`, and a bound added later that judges with `text.length` fails `core/test/text-bounds.test.js` at its own `file:line`. Both render paths of the memory review flatten before they bound as well: the `fits()` answer of `ends` and the labelled Bot line on its way to `clip` pass through `toWellFormed()` first, so an unpaired surrogate that arrives in a message reaches the review prompt as U+FFFD, whole and in place, instead of as half a pair ([#2556](https://github.com/ThinkFlowLab/FlyBot/issues/2556)). Since [#2691](https://github.com/ThinkFlowLab/FlyBot/issues/2691) `normalize` is the one identity key for a name: it NFC-normalizes, drops the Unicode format characters (`\p{Cf}` — zero-width space / non-joiner / joiner, the byte-order mark, soft hyphen, the bidi controls) that `trim()` keeps, trims, lowercases and NFC-normalizes again, so either canonical spelling of one visible name is one key; `findBot` and `findRooms` compare with it, the duplicate guards in `roster.js` ask those same finds, and `createBot`, `updateBot` and `cleanRoomName` store the visible form (the format characters stripped before the 40 / `ROOM_NAME_MAX` cut) while `shared/mentions.js` `nameProblem` refuses a name that is only invisible characters. A name already in `state.json` is never rewritten: the key is computed from what is stored, so an old NFD name still resolves. Tests: `core/test/name-identity.test.js`. | +| `core/src/host/text.js` | The pure text helpers the host modules share: message text, names, model refs, cutting and estimating text, and the clock format of chat anchors. Since #1895 `codePoints(text)` (a step per code point, no array built since #1898) is how the host counts a limit — one character costs one whatever its UTF-16 width — and the bounds this change puts through it are: a memory entry of 300 and a `MEMORY.md` of 4,000 or a topic of 16,000 (the refusal and the size note print the code-point number), a scheduled task's name of 120, a message of 20,000, and the Project memory entry of 600 in both writers, so an emoji is never charged twice. Both cuts land on a code point boundary: `clip` and `excerpt` move back rather than keep half of a surrogate pair, so a clipped emoji reaches a UTF-8 encoder as the character and not as U+FFFD ([#1907](https://github.com/ThinkFlowLab/FlyBot/issues/1907)). Since #1912 that rule is one exported helper rather than a private detail: `cut(text, max)` answers `{ text, cut }` with the longest prefix that stops before a split pair, and the archive writers that used to `slice(0, max)` go through it — the `oneLine` of `decisions.js` and `meetings.js` (into `decisions.jsonl` and `decisions/-.md`), `claimLine` in `groups.js`, the report cap in `diagnostics.js`, `cleanLabel` in `flybot-feed.js` and `clean` in `browser.js` — so a decision or a meeting record holds the whole character instead of U+FFFD. Since #1916 the boundary rule itself lives in `core/src/shared/text-boundary.js` (`codePointStart`, `codePointEnd`, `cut`), and `host/text.js` builds its `cut` on it so a shared module can bound text too; the eight remaining `slice(0, max)` writers follow it — the claim cue's fallback reference (60), the task lead's name in a member prompt (80), a failed group Session's error text (300), `read_own_chat`'s `query` (80) and `around` (40), the open-page address and title (2000/300), the diagnostics browser-log line and its code (500/40), and `cleanPeerName` in `shared/remote-label.js` (60) — each keeping the longest prefix that stops before a split pair, `length <= max` and a byte prefix of the input. Since #1918 the memory review's own two bounds go through the same rule: its 800-character context `clip` cuts through `cut`, and its 3,000-character two-ended `ends` spends the budget in characters at both ends — `charIndex` steps whole code points to find the head's end and the tail's start, so the head is the rendered line's first 2,000 characters and the tail its last 1,000, and the text is flattened with `toWellFormed()` first as `cut` did — so the prompt and the log hold whole characters within the same character budget: a line of 3,500 emoji keeps 3,000 characters where unit arithmetic kept 1,503 ([#2553](https://github.com/ThinkFlowLab/FlyBot/issues/2553)); the exported `clip`'s reason is optional there too, so a two-argument call reads `… (cut)` instead of `… (cut; undefined)` while a call with a reason is unchanged. Since #1923 the four bounds whose text crosses an owner boundary go through the same `cut` — `cleanPublishedName` in `hooks.js` (60, into `state.flybot.remote`), the group member line's `displayName` 60 and `ownerName` 40 in `tools.js`, `label` in `reach.js` (60, the `othersInRoom` labels), and the local `clip` in `shared/room-share.js` (its per-field caps included) — so a published, listed or relayed name keeps whole characters and never reaches a UTF-8 encoder as U+FFFD ([#1923](https://github.com/ThinkFlowLab/FlyBot/issues/1923)). The same rule now covers the host's own line bounds: the busy ack's 600-unit task and 1,500-unit message text in `busy-ack.js`, the group line's notice (100), a Bot's job line (120), the group answer echo (300) and the rework line's note (200) in `tools.js`, and `cleanName`'s 60-unit peer name in `delivery.js`, which lands in `fenceUntrusted`'s `from="…"` attribute — so none of them can end in half a surrogate pair. The five bounds `turns.js` keeps go through `cut` as well: a failure's 500-unit message and the 200-unit message of its line (`turnFailure`, `failureLine`), the 79-unit step a running call shows (the `…` is appended only when `cut` dropped something, never by a length guess), an approval card's 2,000-unit description and 4,000-unit command (the card stores the command "Allow once" runs rather than a preview of it, and one that still had to be cut ends with `…` inside that bound, so the card says what it abridged and never drops the fact, [#1992](https://github.com/ThinkFlowLab/FlyBot/issues/1992)), and the 160-unit reason a failed turn leaves for the inbox. Since #1963 `cut` also flattens its input before it bounds — `String(text).toWellFormed()` first, `codePointEnd` on the flat string — so a title, a note or a name that arrives already carrying an unpaired surrogate is stored as U+FFFD instead of the lone surrogate, and U+FFFD is one UTF-16 unit for the one it replaces, so a well-formed input keeps its exact prefix and its `cut` flag ([#1963](https://github.com/ThinkFlowLab/FlyBot/issues/1963)). Since #1979 the six remaining bounds on text another home or another person supplied go through it too — a connector item name (80, `connectors.js`), a role label another home set on a face-room seat (80, `face-lead.js`), a cross-home group name (200, `project-team.js`), a capability description from a peer server (2,000, `capabilities.js`), the remote home's own error text (157, `remote-chat.js`) and an abbreviated remote reply (80, same file) — so each keeps the longest prefix that stops before a split pair, is well formed and is a code-point prefix of the input, while `remote-chat.js`'s `…` and trailing-period strip still apply to the cut value ([#1979](https://github.com/ThinkFlowLab/FlyBot/issues/1979)). The two bounds on a friend's Bot's own name in that file — the name its chat is created with (40) and the name a refresh writes (40) — go through `cut` as well, so a refresh cannot store a lone high surrogate and a create no longer hands `roster.createBot` half a pair to flatten into U+FFFD; the budget stays 40 and a shorter name is stored word for word. Since #1993 the last five bounds in `core/src/host/` go through it too — the quoted first line of a checkpoint's kept tail (80, `steward.js`), an approval record's `reason` (500, `approvals.js`), a mention receipt's reason line (240, `hooks.js`), the title given to a DSH Agent's Session (80, `AGENT_SESSION_TITLE_MAX` in `agents.js`) and a schedule run's failure `reason` (200, `schedules.js`) — so each keeps the longest prefix that stops before a split pair, is well formed and is a code-point prefix of the input, and none of the five maxima changes ([#1993](https://github.com/ThinkFlowLab/FlyBot/issues/1993)). Since #2002 the connector's own error text goes through it too: the `reason` in `core/src/host/connectors.js` — the text an MCP server's failure leaves on the owner's connector card — keeps the longest prefix that stops before a split pair, so it is well formed and a code-point prefix of what the server sent; since #2016 that prefix is 2,000 units rather than 300, and one that had to be cut ends with `…` inside the bound, so the card says what it abridged ([#2002](https://github.com/ThinkFlowLab/FlyBot/issues/2002), [#2016](https://github.com/ThinkFlowLab/FlyBot/issues/2016)). Since #2005 the five bounds the Project panels read go through it as well — the assistant card's 77-unit title in `project-assistant.js`, the board's 200-unit one-line value and a decision title's 200 in `project-board.js`, and `capped`'s name, role, persona, model and color caps plus the dossier's 4,000-unit memory head in `project-team.js`, whose head is bounded where `memoryDigest` reads it (`memory.js`), because a cut at the dossier alone could only turn half a pair into U+FFFD and never drop the character whole — so a dossier row or a board card keeps whole characters ([#2005](https://github.com/ThinkFlowLab/FlyBot/issues/2005)). Since [#2321](https://github.com/ThinkFlowLab/FlyBot/issues/2321) a bound also judges in code points, not only counts in them: `fits(text, max)` in `core/src/shared/text-boundary.js` is the one predicate — a character outside the BMP spends one of the budget and not two — and `clip` and `excerpt` judge with it while their cuts keep going through `codePointEnd`/`cut`, so a 300-character text is no longer cut by a 400-character limit on its way to `read_own_chat` and the search rows. Since [#2339](https://github.com/ThinkFlowLab/FlyBot/issues/2339) the two bounds in `core/src/host/` that still judged with `text.length` use it as well — the memory review's 3,000-character two-ended `ends` in `memory.js` and `room-context.js`'s `oneLine` (40/80/24/60) — so a user line or a board line of characters outside the BMP stays whole inside its own limit instead of being cut at half of it; both maxima are unchanged and a board line's cut stays on `cut`, so an ASCII text judges exactly as `length <= max` did. The memory review's own third `clip` is the exported one now, so its cut reads `… (cut)`, and a bound added later that judges with `text.length` fails `core/test/text-bounds.test.js` at its own `file:line`. Both render paths of the memory review flatten before they bound as well: the `fits()` answer of `ends` and the labelled Bot line on its way to `clip` pass through `toWellFormed()` first, so an unpaired surrogate that arrives in a message reaches the review prompt as U+FFFD, whole and in place, instead of as half a pair ([#2556](https://github.com/ThinkFlowLab/FlyBot/issues/2556)). Since [#2691](https://github.com/ThinkFlowLab/FlyBot/issues/2691) `normalize` is the one identity key for a name: it NFC-normalizes, drops the Unicode format characters (`\p{Cf}` — zero-width space / non-joiner / joiner, the byte-order mark, soft hyphen, the bidi controls) that `trim()` keeps, trims, lowercases and NFC-normalizes again, so either canonical spelling of one visible name is one key; `findBot` and `findRooms` compare with it, the duplicate guards in `roster.js` ask those same finds, and `createBot`, `updateBot` and `cleanRoomName` store the visible form (the format characters stripped before the 40 / `ROOM_NAME_MAX` cut) while `shared/mentions.js` `nameProblem` refuses a name that is only invisible characters. A name already in `state.json` is never rewritten: the key is computed from what is stored, so an old NFD name still resolves. Since [#2692](https://github.com/ThinkFlowLab/FlyBot/issues/2692) that one key is `nameKey` in `core/src/shared/identity.js` (`normalize` in `host/text.js` is it, so the host, a shared module and the browser bundle cannot drift apart), and three identity sites that used their own `trim().toLowerCase()` compare with it: `shared/task-board.js` `sameRole` — a member's role label and a queued card's `queueRole`, so a card reaches the carrier whose label spells the same role the other way instead of staying in the pool — and `memory.js`'s topic, where the key is also the file stem: `cleanTopic` answers the key, a topic file already on disk keeps the spelling it was written with (the key is computed from the name, so a file `가.md` is still topic `[[가]]` and remember appends to that same file), the file already in key form wins when both spellings exist, and the `## Index` link check and the link a delete removes both compare by key rather than by the text that was written. A decomposed Latin topic, refused while U+0301 was not a `\p{L}`, is accepted as its NFC form. `plugins/tree` keeps its own copy of the same function (`src/identity.js`) because it ships as its own package, and `plugins/tree/test/identity.test.js` holds the two to the same answers. Tests: `core/test/name-identity.test.js`, `core/test/memory.test.js`, `core/test/bot-tasks.test.js`. | | `core/src/host/prompt.js` | The Bot system prompt; a Main Bot's section is headed `You are the Main Bot` and uses that one term, the one the UI shows (#276). The team rules state the idle default (#699): a Bot does nothing until the user addresses it, it is @-mentioned in a group, its parent gives it a task, or unfinished tasks it was already given are still on its board. It does not invent work. A coordinator acts on a user task rather than on its own initiative — an addressed Bot answers as before. | | `core/src/host/health.js` | `GET /api/health` (#2128, P-392): the one answer this instance gives without a session, and exactly four fields — `ok` (always `true`), `version` (`core/package.json`), `commit` (`git rev-parse HEAD` of the clone `core/` sits in, `null` for a package or path install with no `.git`) and `startedAt` (this process's start, from `process.uptime()`, stable until a restart). It is an **exact** WebServer route, which the web server matches before the `/api` prefix whose admission is the sign-in wall, so no cookie is needed and no other `/api` route changes; outside the Web composition there is no `webServer` and the route does not exist. A method other than `GET` answers 405. Tests: `core/test/health.test.js`. | | `core/src/host/room-membership.js` | Typed room participants (human / local bot / granted remote), authorship, membership-window history (#50); rooms with friends' Bots or other owners take granted local Bots only and evict a Bot that stops qualifying (#90). Face-to-face rooms (#83) reuse this model (`origin: 'face-to-face'`, room-only remotes without a person-grant, `upsertFaceRoom` / `syncFaceParticipants` / `applyFaceRoomLine`); a face-room local Bot is `viaCode` (brought by number), `evictUnshared` skips face rooms (membership is the server's), and a Bot that returns to the member list drops the empty local `left` line (#236). Refusing an ungranted local Bot next to a friend's Bot throws `ungrantedRefusal`: a message that names the Bot, the friend and the two ways out, plus `error.details` `{ reason: 'ungranted-local', bots, remotes }`; `channel.js` `fail` forwards a plain-object `details` (empty otherwise) and `client/api.js` puts it on the thrown error, where `client/new-chat.js` draws a card (**Copy invite link for {Bot}** via `__flybot__.social.createInvite`, **Leave {Bot} out**) read through `ungrantedLocalFix` in `shared/remote-label.js`; the same file's `copiedToClipboard` reports whether the invite link really reached the clipboard (none, or a refused write, is `false`), and the card then shows the link to copy by hand instead of saying it was copied. | diff --git a/docs/subsystems/tree.md b/docs/subsystems/tree.md index 303c03837..885d00f17 100644 --- a/docs/subsystems/tree.md +++ b/docs/subsystems/tree.md @@ -45,6 +45,7 @@ Everything in `plugins/tree/` (entry `plugins/tree/index.js` and any helpers). - `maxChildren` and depth limits stay, checked at the target parent. Parallel creates are serialized per (target) parent. - `maxChildren` and `maxDepth` apply to **every** create path while the tree plugin is loaded (#446). A Bot made without tree fields (core `create_bot`, the Bot list) is placed by role by `src/organize.js` on `bot.created`: inside the create call when it fits a level or an existing lead, right after when a new lead has to be created first (it is marked `organizing` until then). No parent goes over `maxChildren` at any moment. `create_child_bot` keeps its limit contract (`limit reached` at a full target parent). - A newly created child starts with no person-grant (`bot.hasAnyGrant` false, even when the parent is granted). Sharing with another person still needs an independent per-Bot grant (default deny). Tree never copies grants and never publishes. +- A sibling name is one identity (#2692): `checkCreate` (the duplicate guard), `findBotByName` and `moveBot`'s duplicate check compare by `src/identity.js`'s `nameKey` — NFC, the invisible format characters `trim()` keeps dropped, trimmed, lowercased, NFC again — so either canonical spelling of one visible name is one child instead of two that render the same, and the name lookup resolves it. The plugin ships as its own package (invariant 1), so that function is a copy of `core/src/shared/identity.js`; `test/identity.test.js` fails when the two copies answer differently. ## The root follows the Main Bot diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index d0ca75190..06f865501 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -5775,7 +5775,7 @@ diff --git a/src/host/images.js b/src/host/images.js diff --git a/src/host/memory.js b/src/host/memory.js --- a/src/host/memory.js +++ b/src/host/memory.js -@@ -23,11 +23,16 @@ +@@ -23,11 +23,17 @@ import { createHash } from 'node:crypto' import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs' import { appendFile, mkdir, rename, unlink, writeFile } from 'node:fs/promises' @@ -5788,6 +5788,7 @@ diff --git a/src/host/memory.js b/src/host/memory.js +import { defineTool } from './tool-schema.js' +import { clip, codePoints, splitRef, unquote } from './text.js' +import { cut, fits } from '../shared/text-boundary.js' ++import { nameKey } from '../shared/identity.js' import { output } from './tools.js' +import { retiredBot } from './store.js' +import { botNode, buildGraph, buildIndex, relatedOf, searchIndex, topicNode } from './memory-search.js' @@ -5805,6 +5806,20 @@ diff --git a/src/host/memory.js b/src/host/memory.js // A change whose entry is gone; any other failure keeps the review's place. const NO_MATCH = 'memory/no-match' const GONE = 'memory/bot-gone' +@@ -98,9 +98,11 @@ export function cleanEntry(raw) { + return splitMeta(line).text + } + +-// A flat file name: case folds on some file systems, so names are lower case. ++// A flat file name: case folds on some file systems, so names are lower case. Since #2692 the name ++// is also the shared name key, so canonical equivalents are one topic and one file, and a ++// decomposed Latin topic (refused while U+0301 was not a letter) is accepted as its NFC form. + export function cleanTopic(raw) { +- const name = String(raw ?? '').trim().replace(/^\[\[|\]\]$/g, '').replace(/\.md$/i, '').trim().toLowerCase().replace(/\s+/g, '-') ++ const name = nameKey(raw).replace(/^\[\[|\]\]$/g, '').replace(/\.md$/i, '').replace(/\s+/g, '-') + return /^[\p{L}\p{N}][\p{L}\p{N}_-]{0,47}$/u.test(name) && name !== 'memory' ? name : undefined + } + @@ -312,7 +312,7 @@ const teamRule = (main, writer) => (main ? '- The team memory is shared by every Bot. As a group admin you write it with remember scope "team", for what the whole team needs.' : '- The team memory is shared by every Bot. Main Bots and group admins write it; when the whole team should know something, tell a Main Bot with message_bot.') @@ -5922,31 +5937,40 @@ diff --git a/src/host/memory.js b/src/host/memory.js const readText = (path) => { try { return readFileSync(path, 'utf8') -@@ -433,34 +433,60 @@ export function install(rt) { +@@ -433,34 +433,68 @@ export function install(rt) { return '' } } - const topicNames = (scope) => { -+ const topicNamesIn = (dir) => { ++ // A topic file's name is the topic, but not always the key: a file written before #2692 keeps the ++ // spelling it was written with (a decomposed Hangul syllable among them), so the topic is the key ++ // computed from that name and the name itself is still the file recall reads and remember ++ // appends to. When both spellings exist, the one already in key form wins. ++ const topicFilesIn = (dir) => { try { - return readdirSync(dirOf(scope)).filter(name => name.endsWith('.md') && name !== MAIN).map(name => name.slice(0, -3)).sort() -+ return readdirSync(dir).filter(name => name.endsWith('.md') && name !== MAIN).map(name => name.slice(0, -3)).sort() ++ const byTopic = new Map() ++ for (const file of readdirSync(dir).filter(name => name.endsWith('.md') && name !== MAIN).sort()) { ++ const topic = nameKey(file.slice(0, -3)) ++ if (!byTopic.has(topic) || file === `${topic}.md`) byTopic.set(topic, file) ++ } ++ return [...byTopic].map(([topic, file]) => ({ topic, file })) } catch { return [] } } + const topicNames = (scope) => { + const dir = scopeDir(scope) -+ return dir === undefined ? [] : topicNamesIn(dir) ++ return dir === undefined ? [] : topicFilesIn(dir).map(file => file.topic) + } // MEMORY.md first, then the topics by name. const loadScope = (scope) => { const dir = dirOf(scope) - return [undefined, ...topicNames(scope)].map((topic) => { -+ const open = scopeDir(scope) !== undefined -+ return [undefined, ...(open ? topicNamesIn(dir) : [])].map((topic) => { - const file = topic === undefined ? MAIN : `${topic}.md` +- const file = topic === undefined ? MAIN : `${topic}.md` - const content = readText(join(dir, file)) ++ const open = scopeDir(scope) !== undefined ++ return [{ topic: undefined, file: MAIN }, ...(open ? topicFilesIn(dir) : [])].map(({ topic, file }) => { + const content = open ? readText(join(dir, file)) : '' return { topic, file, path: join(dir, file), content, ...parseMemory(content) } }) @@ -6077,6 +6101,15 @@ diff --git a/src/host/memory.js b/src/host/memory.js } function addEntry({ scope, topic, text, meta, actor, label }) { +@@ -559,7 +559,7 @@ export function install(rt) { + const next = joinLines(insertEntry(base, line)) + checkSize(target, next, label) + let index +- if (topic !== undefined && !main.links.includes(topic)) { ++ if (topic !== undefined && !main.links.some(link => nameKey(link) === topic)) { + index = joinLines(addLink(main.lines.length > 0 ? main.lines : parseMemory(EMPTY_MAIN).lines, topic)) + checkSize(main, index, label) + } @@ -568,7 +568,7 @@ export function install(rt) { await journal({ op: 'add', scope: scopeKey(scope), file: target.file, ...actorOf(actor), after: line }) noteNews(scope, actor, 'add', line) @@ -6086,6 +6119,18 @@ diff --git a/src/host/memory.js b/src/host/memory.js }) } +@@ -614,7 +614,10 @@ export function install(rt) { + if (empty) { + await unlink(old.file.path).catch(error => { if (error?.code !== 'ENOENT') throw error }) + const main = files[0] +- if (main.links.includes(old.file.topic)) await writeText(main.path, joinLines(removeLink(main.lines, old.file.topic))) ++ // The link was written with whatever spelling the topic had then; remove that one, not a ++ // second entry the key would otherwise leave behind (#2692). ++ const link = main.links.find(one => nameKey(one) === old.file.topic) ++ if (link !== undefined) await writeText(main.path, joinLines(removeLink(main.lines, link))) + } else { + await writeText(old.file.path, joinLines(lines)) + } @@ -632,12 +632,13 @@ export function install(rt) { news.delete(botId) return queued(async () => { @@ -7906,26 +7951,26 @@ diff --git a/src/host/steward.js b/src/host/steward.js diff --git a/src/host/text.js b/src/host/text.js --- a/src/host/text.js +++ b/src/host/text.js -@@ -1,6 +1,10 @@ +@@ -1,6 +1,11 @@ // Pure text helpers shared by the host modules: message text, names, model refs, // cutting and estimating text, and the clock format of chat anchors. import { COLORS, EXTRA_COLORS, LEGACY_COLORS, OWN_CHAT_LINE_MAX } from './constants.js' +import { codePointEnd, codePointStart, cut, fits } from '../shared/text-boundary.js' ++import { nameKey } from '../shared/identity.js' +import { stripInvisible } from '../shared/mentions.js' + +export { cut, stripInvisible } // A color id saved under its legacy name reads as the current one. export const currentColor = color => (typeof color === 'string' && Object.hasOwn(LEGACY_COLORS, color) ? LEGACY_COLORS[color] : color) -@@ -17,17 +17,37 @@ export const textOf = content => (Array.isArray(content) ? content : []) +@@ -17,17 +17,36 @@ export const textOf = content => (Array.isArray(content) ? content : []) .join('\n') .trim() -export const normalize = value => String(value ?? '').trim().toLowerCase() -+// The identity key for a name (#2691): both canonical spellings (NFC and NFD) of one -+// visible name are one key, and so are the invisible characters `trim()` keeps. The NFC -+// pass runs again after lowercasing because a lower can compose a new sequence (U+0130). -+export const normalize = value => stripInvisible(String(value ?? '').normalize('NFC')).trim().toLowerCase().normalize('NFC') ++// The identity key for a name (#2691, #2692): one function in `shared/identity.js`, so the tree's ++// sibling names, a role label and a memory topic compare by the same rule as a Bot name does. ++export const normalize = nameKey export const unquote = text => String(text ?? '').trim().replace(/^["'“”「『《]+|["'“”」』》]+$/g, '').trim() @@ -12502,6 +12547,46 @@ diff --git a/test/memory.test.js b/test/memory.test.js test('remember saves one entry with its source, and forget removes it, both in the journal', async (t) => { const { tool, read, journal, stop } = start() +@@ -218,6 +218,39 @@ test('topics hold details, linked from MEMORY.md, and go when their last entry d + assert.equal(read('bots', 'b2', 'MEMORY.md'), '# Memory\n\n## Index\n') + }) + ++// #2692: a topic is one identity and one file name. Canonically equivalent spellings used to be ++// two topics writing two files, so one topic split into two memories. A decomposed Latin topic is ++// refused by the \p{L} rule, so the collision needs a script whose decomposition stays letters: ++// the Hangul syllable 가 (U+AC00) and U+1100 U+1161. ++test('#2692 a topic is one identity whichever canonical spelling it is written in', async (t) => { ++ const { tool, read, stop, home } = start() ++ t.after(() => stop()) ++ assert.equal(cleanTopic('\uac00'), cleanTopic('\u1100\u1161')) ++ assert.equal(cleanTopic('\u1100\u1161'), '\uac00', 'the file stem is the canonical form') ++ await tool('remember', { text: 'Flies from Hangzhou', topic: '\uac00' }, 'b2') ++ // the other spelling names the same topic, and reads the same file ++ assert.match(await tool('recall', { topic: '\u1100\u1161' }, 'b2'), /^Topic \[\[\uac00\]\] in your memory \(\uac00\.md: \d+ of 16000 characters\):\n# \uac00\n\n- Flies from Hangzhou/) ++ assert.match(await tool('remember', { text: 'Prefers window seats', topic: '\u1100\u1161' }, 'b2'), /in topic \[\[\uac00\]\] \(\uac00\.md: \d+ of 16000 characters\)/) ++ assert.deepEqual(readdirSync(join(home, 'memory', 'bots', 'b2')).sort(), ['MEMORY.md', '\uac00.md']) ++ assert.equal(read('bots', 'b2', 'MEMORY.md'), '# Memory\n\n## Index\n- [[\uac00]]\n') ++}) ++ ++// A topic file written before this change keeps the spelling it was written with. The key is ++// computed from the name, so the file is still the one recall reads and remember appends to. ++test('#2692 a topic saved under another spelling before this change is still the file it names', async (t) => { ++ const { tool, read, stop, home } = start() ++ t.after(() => stop()) ++ const dir = join(home, 'memory', 'bots', 'b2') ++ mkdirSync(dir, { recursive: true }) ++ writeFileSync(join(dir, 'MEMORY.md'), '# Memory\n\n## Index\n- [[\u1100\u1161]]\n') ++ writeFileSync(join(dir, '\u1100\u1161.md'), '# legs\n\n- Saved the old way [added: 2026-01-01]\n') ++ assert.match(await tool('recall', { topic: '\uac00' }, 'b2'), /^Topic \[\[\uac00\]\] in your memory \(\u1100\u1161\.md: \d+ of 16000 characters\):\n# legs/) ++ assert.match(await tool('remember', { text: 'Added after the fix', topic: '\uac00' }, 'b2'), /in topic \[\[\uac00\]\]/) ++ assert.deepEqual(readdirSync(dir).sort(), ['MEMORY.md', '\u1100\u1161.md'], 'no second file for the same topic') ++ assert.match(read('bots', 'b2', '\u1100\u1161.md'), /- Added after the fix \[/) ++ assert.equal(read('bots', 'b2', 'MEMORY.md'), '# Memory\n\n## Index\n- [[\u1100\u1161]]\n', 'the link it already had is not written twice') ++}) ++ + test('Main Bots and group admins write the team memory; only Main Bots touch another Bot\'s', async (t) => { + const { tool, read, stop } = start() + t.after(() => stop()) @@ -336,7 +336,7 @@ test('the memory panel shows every entry and a summary the Bot\'s model writes o const asked = (await call('memory-view', { botId: 'b2', summarize: true })).value assert.deepEqual(asked.entries.map(entry => [entry.scope, entry.topic, entry.text]), [['own', null, 'Likes tea']]) diff --git a/patches/ds-bot/0008-client-bundle.patch b/patches/ds-bot/0008-client-bundle.patch index d8f39366c..5995a1606 100644 --- a/patches/ds-bot/0008-client-bundle.patch +++ b/patches/ds-bot/0008-client-bundle.patch @@ -5752,7 +5752,7 @@ diff --git a/client.js b/client.js .bt-account{flex:none;width:36px;height:36px;border:0;padding:0;background:none;border-radius:50%;cursor:pointer;display:inline-flex} .bt-account:hover .bt-you,.bt-account[aria-expanded=true] .bt-you{background:color-mix(in srgb,var(--bt-ink) 9%,var(--bt-sidebar))} /* Popover keyframes move translate and scale, which compose with a transform the popover -@@ -2488,3021 +2488,6777 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} +@@ -2488,3021 +2488,6780 @@ body[data-ds-dark-theme] .bt-theme-chip i{background:var(--da)} @keyframes bt-rise{from{opacity:0;transform:translateY(-3px)}to{opacity:1;transform:none}} @media (prefers-reduced-motion:reduce){.bt-member,.bt-addlist{animation:none}} @@ -8478,9 +8478,7 @@ diff --git a/client.js b/client.js + } + if (reply.kind === "missed") { + return (0, import_react11.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} didn't answer in time", { name: bot.name })); - } -- const bot = people[index]; -- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); ++ } + if (reply.kind === "resumed") { + return (0, import_react11.createElement)("div", { key: `${reply.botId}-${index}`, className: "bt-event" }, t("{name} was cut off by a restart and picked its work back up", { name: bot.name })); + } @@ -8519,7 +8517,9 @@ diff --git a/client.js b/client.js + msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, + useUi + }); -+ } + } +- const bot = people[index]; +- return (0, import_react2.createElement)("span", { key: bot?.id ?? "you", style }, seat(bot, s)); + return (0, import_react11.createElement)(GroupMessage, { key: `${reply.botId}-${index}`, bot, text: reply.text, room: room2, roster, actions, msgKey: callId ? `${sessionId}:${callId}:${index}` : void 0, useUi, seq: reply.seq }); }) ); @@ -9932,6 +9932,9 @@ diff --git a/client.js b/client.js - const members = steps.length ? steps : [current]; - if (members.find((step) => stepReasoning(step) !== "")?.step !== current.step) return ""; - return members.filter((step) => !thinkingLive(step)).map(stepReasoning).filter(Boolean).join("\n\n"); ++// src/shared/identity.js ++var nameKey = (value) => stripInvisible(String(value ?? "").normalize("NFC")).trim().toLowerCase().normalize("NFC"); ++ +// src/shared/task-board.js +var COLUMNS = ["not-started", "in-progress", "done"]; +var PRIORITIES = ["urgent", "high", "normal", "low"]; @@ -9970,8 +9973,8 @@ diff --git a/client.js b/client.js + return (b?.updatedAt ?? 0) - (a?.updatedAt ?? 0); +} +function sameRole(a, b) { -+ const left = String(a ?? "").trim().toLowerCase(); -+ const right = String(b ?? "").trim().toLowerCase(); ++ const left = nameKey(a); ++ const right = nameKey(b); + return left !== "" && left === right; +} +function compareForQueue(a, b) { diff --git a/patches/ds-bot/files/src/shared/identity.js b/patches/ds-bot/files/src/shared/identity.js new file mode 100644 index 000000000..c87a6b29b --- /dev/null +++ b/patches/ds-bot/files/src/shared/identity.js @@ -0,0 +1,7 @@ +// The identity key for a visible name (#2691, #2692), in one place so the host, a shared module and +// a name that is also a file name cannot drift apart. Both canonical spellings (NFC and NFD) of one +// visible name are one key, and so are the invisible format characters `trim()` keeps. The NFC pass +// runs again after lowercasing because a lower can compose a new sequence (U+0130). +import { stripInvisible } from './mentions.js' + +export const nameKey = value => stripInvisible(String(value ?? '').normalize('NFC')).trim().toLowerCase().normalize('NFC') diff --git a/patches/ds-bot/files/src/shared/task-board.js b/patches/ds-bot/files/src/shared/task-board.js index b44e6abfa..bd2b23a72 100644 --- a/patches/ds-bot/files/src/shared/task-board.js +++ b/patches/ds-bot/files/src/shared/task-board.js @@ -1,6 +1,7 @@ // Board columns for the task records in src/host/tasks.js (#1058). // The state machine stays the record. A column is how the board reads it. // result-ready still waits for acceptance, so it stays In progress. +import { nameKey } from './identity.js' export const COLUMNS = ['not-started', 'in-progress', 'done'] export const PRIORITIES = ['urgent', 'high', 'normal', 'low'] @@ -54,9 +55,11 @@ export function compareForWork(a, b) { // A role label, matched the way a person reads it (#1247): free text, trimmed, case-insensitive. // The label is not a membership role (`admin` / `member`), so nothing here reads that field. +// Since #2692 the comparison is the shared name key, so either canonical spelling of one visible +// label is one role and a queued card reaches the carrier that spells it the other way. export function sameRole(a, b) { - const left = String(a ?? '').trim().toLowerCase() - const right = String(b ?? '').trim().toLowerCase() + const left = nameKey(a) + const right = nameKey(b) return left !== '' && left === right } diff --git a/patches/ds-bot/files/test/bot-tasks.test.js b/patches/ds-bot/files/test/bot-tasks.test.js index 359681051..aca4d651d 100644 --- a/patches/ds-bot/files/test/bot-tasks.test.js +++ b/patches/ds-bot/files/test/bot-tasks.test.js @@ -9,7 +9,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' import { apply } from '../index.js' -import { columnOf, occupiesSlot } from '../src/shared/task-board.js' +import { columnOf, occupiesSlot, sameRole } from '../src/shared/task-board.js' import { fakeContext } from './fake-ctx.js' const team = () => ({ @@ -681,3 +681,20 @@ test('only a claimed or working card occupies the member\'s WIP slot (#1744)', ( assert.equal(occupiesSlot({}), false) assert.equal(columnOf({ status: 'result-ready' }), 'in-progress', 'the board reading is unchanged') }) + +// #2692: a role label is one identity whichever canonical spelling it is written in, so a queued +// card reaches the member whose label spells the same role instead of staying in the pool forever. +test('#2692 a role label is one identity whichever canonical spelling it uses', () => { + const nfc = 'Caf\u00e9' + const nfd = 'Cafe\u0301' + assert.equal(sameRole(nfc, nfd), true) + assert.equal(sameRole(nfd, nfc), true) + // the folded spelling is the one the tree plugin hands out as a role label + assert.equal(sameRole(` ${nfd} `, nfc), true) + // the invisible format characters trim() keeps are part of the same identity + assert.equal(sameRole(`${nfd}\u200b`, nfc), true) + // a different role still is a different role, and an empty label still matches nothing + assert.equal(sameRole(nfd, 'Caf\u00e9 writer'), false) + assert.equal(sameRole('', ''), false) + assert.equal(sameRole(undefined, nfc), false) +}) diff --git a/plugins/tree/src/identity.js b/plugins/tree/src/identity.js new file mode 100644 index 000000000..955ea7c1d --- /dev/null +++ b/plugins/tree/src/identity.js @@ -0,0 +1,6 @@ +// The identity key for a Bot name, the same rule as core/src/shared/identity.js (#2692). This +// plugin ships as its own package, so it cannot import the core module; test/identity.test.js holds +// the two copies to the same answers. Both canonical spellings (NFC and NFD) of one visible name +// are one key, and so are the invisible format characters `trim()` keeps. The NFC pass runs again +// after lowercasing because a lower can compose a new sequence (U+0130). +export const nameKey = value => String(value ?? '').normalize('NFC').replace(/\p{Cf}/gu, '').trim().toLowerCase().normalize('NFC') diff --git a/plugins/tree/src/limits.js b/plugins/tree/src/limits.js index bb67ab0eb..49d7dcc83 100644 --- a/plugins/tree/src/limits.js +++ b/plugins/tree/src/limits.js @@ -1,4 +1,5 @@ import { readFileSync } from 'node:fs' +import { nameKey } from './identity.js' const FILE = JSON.parse(readFileSync(new URL('../limits.json', import.meta.url), 'utf8')) export const DEFAULT_LIMITS = Object.freeze({ maxDepth: FILE.maxDepth ?? 5, maxChildren: FILE.maxChildren ?? 8 }) @@ -11,13 +12,13 @@ export function resolveLimits(config = {}) { export const MAX_NAME_LENGTH = 40 // ds-bot roster truncates names to 40 characters -const norm = s => String(s ?? '').trim().toLowerCase() - /** Returns null when allowed, else the rejection reason. */ export function checkCreate({ parentName, parentDepth, siblings, name, limits }) { if (!String(name ?? '').trim()) return 'name required' if (String(name).trim().length > MAX_NAME_LENGTH) return `name must be at most ${MAX_NAME_LENGTH} characters` - if (siblings.some(s => norm(s.name) === norm(name))) return `a child named "${String(name).trim()}" already exists under ${parentName}; delegate to it instead of creating a duplicate` + // #2692: a sibling name is compared by the shared name key, so either canonical spelling of one + // visible name is one sibling instead of two children that render the same. + if (siblings.some(s => nameKey(s.name) === nameKey(name))) return `a child named "${String(name).trim()}" already exists under ${parentName}; delegate to it instead of creating a duplicate` if (siblings.length >= limits.maxChildren) return `limit reached: ${parentName} already has ${siblings.length} children (maxChildren=${limits.maxChildren})` if (parentDepth + 1 > limits.maxDepth) return `limit reached: a child of ${parentName} would be at depth ${parentDepth + 1} (maxDepth=${limits.maxDepth})` return null diff --git a/plugins/tree/src/tree.js b/plugins/tree/src/tree.js index 2b05b0155..c969a31e9 100644 --- a/plugins/tree/src/tree.js +++ b/plugins/tree/src/tree.js @@ -8,6 +8,7 @@ // The root is ds-bot's Main Bot (Chief, BotView.isMain), adopted with treeRole 'root'. A separate // root bot is created only when the team has no Main Bot at all. import { checkCreate } from './limits.js' +import { nameKey } from './identity.js' import { systemPrompt, withTreeBlock, hasTreeBlock } from './prompt.js' import { forestOf } from './forest.js' import { createOrganizer } from './organize.js' @@ -228,14 +229,14 @@ export function createTree(fb, { limits, rootName = 'Root', rootBotId, adoptExis return { id, name: clean, depth: depthOf(nodes, parentId) + 1 } } - /** Resolves a bot by id or name (trimmed, case-insensitive). Throws when missing or ambiguous. */ + /** Resolves a bot by id or name (by the shared name key: trimmed, case-insensitive, either canonical spelling). Throws when missing or ambiguous. */ function findBotByName(ref) { const raw = String(ref ?? '').trim() if (!raw) throw new Error('bot name required') const byId = fb.listBots().find(b => b.id === raw) if (byId) return byId - const clean = raw.toLowerCase() - const hits = fb.listBots().filter(b => String(b.name ?? '').trim().toLowerCase() === clean) + const clean = nameKey(raw) + const hits = fb.listBots().filter(b => nameKey(b.name) === clean) if (hits.length === 0) throw new Error(`no bot named "${raw}"`) if (hits.length > 1) throw new Error(`more than one bot is named "${raw}"`) return hits[0] @@ -262,7 +263,7 @@ export function createTree(fb, { limits, rootName = 'Root', rootBotId, adoptExis } const siblings = childrenOf(nodes, parent.id).filter(s => s.id !== bot.id) if (siblings.length >= limits.maxChildren) throw new Error(`limit reached: ${parent.name} already has ${siblings.length} children (maxChildren=${limits.maxChildren})`) - if (siblings.some(s => String(s.name).trim().toLowerCase() === String(bot.name).trim().toLowerCase())) { + if (siblings.some(s => nameKey(s.name) === nameKey(bot.name))) { throw new Error(`a child named "${bot.name}" already exists under ${parent.name}`) } const bottom = depthOf(nodes, parent.id) + 1 + heightOf(nodes, bot.id) diff --git a/plugins/tree/test/identity.test.js b/plugins/tree/test/identity.test.js new file mode 100644 index 000000000..d974a88b5 --- /dev/null +++ b/plugins/tree/test/identity.test.js @@ -0,0 +1,23 @@ +// #2692: the tree ships as its own package, so it cannot import core/src/shared/identity.js. Its +// copy of the name key and the core's must answer the same for every spelling, or one sibling name +// would be one identity inside the tree and another in the roster it creates the child through. +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { nameKey } from '../src/identity.js' +import { nameKey as coreKey } from '../../../core/src/shared/identity.js' + +// The two spellings of one visible name, a lower that composes (U+0130), and the format characters +// `trim()` keeps: every one of them has to key the same in both copies. +const CASES = [ + 'Caf\u00e9', 'Cafe\u0301', 'CAF\u00c9', ' Cafe\u0301 ', 'Caf\u00e9\u200b', '\u200bCaf\u00e9', + '\u0130lker', 'i\u0307lker', '\uac00', '\u1100\u1161', '\u1100\u1161\u11a8', '\uac01', + '', ' ', '\ufeffDev', 'Dev', +] + +test('#2692 the plugin name key and core/src/shared/identity.js agree', () => { + for (const value of CASES) assert.equal(nameKey(value), coreKey(value), JSON.stringify(value)) + // The cases that must be one identity are one, and the ones that must not are not. + assert.equal(nameKey('Cafe\u0301'), nameKey('Caf\u00e9')) + assert.equal(nameKey('\u1100\u1161'), nameKey('\uac00')) + assert.notEqual(nameKey('Dev'), nameKey('Ops')) +}) diff --git a/plugins/tree/test/tree.test.js b/plugins/tree/test/tree.test.js index c691593db..c454f3219 100644 --- a/plugins/tree/test/tree.test.js +++ b/plugins/tree/test/tree.test.js @@ -44,6 +44,29 @@ test('limits: defaults, config override, rules', () => { assert.match(checkCreate({ parentName: 'R', parentDepth: 1, siblings: [], name: ' ', limits: L }), /name required/) }) +// #2692: a sibling name is one identity whichever canonical spelling it is written in. A model can +// send the decomposed form, so without the name key two children render as the same "Café" while +// create_child_bot admits both, and find_bot_by_name then resolves the visible name to one of them. +test('#2692 a sibling name is one identity whichever canonical spelling it uses', async () => { + const m = await setup() + const nfc = 'Caf\u00e9' + const nfd = 'Cafe\u0301' + const cafe = await make(m, m.root, nfc) + assert.equal(m.bots.get(cafe).name, nfc) + assert.equal(m.tree.findBotByName(nfd).id, cafe, 'the other canonical spelling is the same Bot') + // the duplicate guard: one child under the root, either spelling is the same sibling name + assert.match((await m.call(m.root, 'create_child_bot', { name: nfd, role_description: 'dup' })).error, /a child named "Cafe\u0301" already exists/) + // the invisible format characters trim() keeps are part of the same identity + assert.match((await m.call(m.root, 'create_child_bot', { name: `${nfc}\u200b`, role_description: 'dup' })).error, /already exists/) + assert.equal(m.tree.childrenOf(m.tree.snapshot(), m.root).length, 1, 'one child, not three') + + // move_bot refuses a second sibling that spells the same name: the same guard on the other path + const other = await make(m, m.root, 'Ops') + const twin = await make(m, other, nfd) + assert.throws(() => m.tree.moveBot(twin, m.root), /a child named "Cafe\u0301" already exists/) + assert.equal(f(m, twin, 'parent'), other, 'the refused move left it where it was') +}) + test('tree starts with only a root; ensureRoot is idempotent', async () => { const m = await setup() assert.equal(m.bots.size, 1)