From 146fb1581ac7881af5bfaa14044968ef954bd22e Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Sun, 1 Nov 2026 16:49:03 +0800 Subject: [PATCH] host: five Project-panel bounds cut on a code point, not a UTF-16 unit (#2005) The assistant digest's 77-unit card title, the board's 200-unit one-line value and decision title, and the dossier's capped fields now go through cut(). The dossier's 4,000-unit memory head is bounded in memoryDigest, where it is read: a cut at the dossier alone could only turn half a pair into U+FFFD. --- .../2005-project-panel-code-points.fixed.md | 1 + core/src/host/memory.js | 4 +- core/src/host/project-assistant.js | 6 ++- core/src/host/project-board.js | 8 +++- core/src/host/project-team.js | 6 ++- core/test/project-assistant.test.js | 13 ++++++ core/test/project-board.test.js | 37 ++++++++++++++++ core/test/project-team.test.js | 44 +++++++++++++++++++ docs/subsystems/core-host.md | 2 +- patches/ds-bot/0001-core-fixes.patch | 6 ++- .../files/src/host/project-assistant.js | 6 ++- .../ds-bot/files/src/host/project-board.js | 8 +++- patches/ds-bot/files/src/host/project-team.js | 6 ++- .../files/test/project-assistant.test.js | 13 ++++++ .../ds-bot/files/test/project-board.test.js | 37 ++++++++++++++++ .../ds-bot/files/test/project-team.test.js | 44 +++++++++++++++++++ 16 files changed, 225 insertions(+), 16 deletions(-) create mode 100644 changelog.d/2005-project-panel-code-points.fixed.md diff --git a/changelog.d/2005-project-panel-code-points.fixed.md b/changelog.d/2005-project-panel-code-points.fixed.md new file mode 100644 index 000000000..c267178ac --- /dev/null +++ b/changelog.d/2005-project-panel-code-points.fixed.md @@ -0,0 +1 @@ +Five Project-panel bounds now cut on a code point boundary, so a dossier or a board card no longer carries half an emoji: the assistant digest's 77-unit card title, the board's 200-unit one-line reason and decision title, and the dossier's capped name, role, persona, model and color, plus its 4,000-unit memory head — that last one is bounded where the memory is read, so the character is dropped whole instead of reaching the panel as U+FFFD ([#2005](https://github.com/ThinkFlowLab/FlyBot/issues/2005)). diff --git a/core/src/host/memory.js b/core/src/host/memory.js index ca4477f4c..640bd6b57 100644 --- a/core/src/host/memory.js +++ b/core/src/host/memory.js @@ -1498,7 +1498,9 @@ export function install(rt) { const head = Number.isFinite(limits.head) ? limits.head : 4000 const topics = Number.isFinite(limits.topics) ? limits.topics : 64 return { - head: readText(join(dirOf(scope), MAIN)).slice(0, head), + // The head is the dossier's text, so its bound lands on a code point (#2005): a `slice` + // here handed the panel the first half of an emoji, and no later bound can put it back. + head: cut(readText(join(dirOf(scope), MAIN)), head).text, topics: topicNames(scope).slice(0, topics), } } diff --git a/core/src/host/project-assistant.js b/core/src/host/project-assistant.js index 6a37f5867..d1ecee73a 100644 --- a/core/src/host/project-assistant.js +++ b/core/src/host/project-assistant.js @@ -17,7 +17,7 @@ import { defineTool } from './tool-schema.js' import { MAX_ENTRY } from './constants.js' import { listProjectState } from './project-state.js' -import { codePoints } from './text.js' +import { codePoints, cut } from './text.js' import { output } from './tools.js' import { inQuietHours, clockIn, quietHoursOf, readQuietHours } from '../shared/quiet-hours.js' @@ -129,7 +129,9 @@ const askOf = (row) => { const title = text(row.title).replace(/\s+/g, ' ') const where = text(row.column) === '' ? 'open' : `still ${text(row.column)}` if (title === '') return `${waitOf(row)} — ${where}` - const short = title.length > 80 ? `${title.slice(0, 77)}…` : title + // One line still, and the 77-unit bound lands on a code point (#2005): a cut inside a pair + // would send the digest half an emoji. + const short = title.length > 80 ? `${cut(title, 77).text}…` : title return `${waitOf(row)} — ${short} (${where})` } diff --git a/core/src/host/project-board.js b/core/src/host/project-board.js index 05c769ddc..8b259512a 100644 --- a/core/src/host/project-board.js +++ b/core/src/host/project-board.js @@ -20,6 +20,7 @@ import { open, readFile, stat } from 'node:fs/promises' import { join } from 'node:path' import { columnOf } from '../shared/task-board.js' import { DECISIONS_DIR, listProjectState, sha256 } from './project-state.js' +import { cut } from './text.js' import { advanceRefusal } from './meetings.js' import { readMetrics } from './project-metrics.js' @@ -102,7 +103,9 @@ const text = value => (typeof value === 'string' ? value.trim() : '') // The column a finished card reads (#1855): the archive key, never a row. const DONE_COLUMN = 'done' const ONE_LINE = 200 -const oneLine = value => (typeof value === 'string' ? value.replace(/\s+/g, ' ').trim().slice(0, ONE_LINE) : '') +// #2005: the bound lands on a code point. A `slice` here put the first half of an emoji into the +// queue reason the panel draws, which reads as a replacement character. +const oneLine = value => (typeof value === 'string' ? cut(value.replace(/\s+/g, ' ').trim(), ONE_LINE).text : '') const stampOf = value => (Number.isFinite(value) ? value : null) // Waiting: an open card a role may claim and no member has. `waitedMs` is measured from the card's @@ -160,7 +163,8 @@ async function titleOf(dir, path) { for (const raw of buffer.subarray(0, bytesRead).toString('utf8').split(/\r?\n/)) { const line = raw.trim() if (line === '') continue - return line.replace(/^#+\s*/, '').slice(0, TITLE_MAX) + // The same code-point rule as `oneLine` (#2005): a decision heading may end in an emoji. + return cut(line.replace(/^#+\s*/, ''), TITLE_MAX).text } return '' } catch { diff --git a/core/src/host/project-team.js b/core/src/host/project-team.js index 5fe3ef55c..c97459d24 100644 --- a/core/src/host/project-team.js +++ b/core/src/host/project-team.js @@ -19,7 +19,9 @@ import { cut } from './text.js' export const DOSSIER_LIMITS = { persona: 4000, memoryHead: 4000, topics: 64, skills: 64, rooms: 32, name: 200, role: 120, model: 200, color: 64 } const text = value => (typeof value === 'string' ? value.trim() : '') -const capped = (value, limit) => text(value).slice(0, limit) +// #2005: every capped field lands on a code point — a `slice` bound could end on the first half +// of an emoji, which the panel draws as a replacement character. +const capped = (value, limit) => cut(text(value), limit).text export function install(rt) { // The attached names that are skills. A pack is expanded to its skill children; a capability the @@ -64,7 +66,7 @@ export function install(rt) { color: capped(bot.color, DOSSIER_LIMITS.color), createdAt: Number.isFinite(bot.createdAt) ? bot.createdAt : null, memory: { - head: (typeof digest.head === 'string' ? digest.head : '').slice(0, DOSSIER_LIMITS.memoryHead), + head: cut(typeof digest.head === 'string' ? digest.head : '', DOSSIER_LIMITS.memoryHead).text, topics: (Array.isArray(digest.topics) ? digest.topics : []).map(name => text(name)).filter(name => name !== '').slice(0, DOSSIER_LIMITS.topics), }, skills: skillNames(bot), diff --git a/core/test/project-assistant.test.js b/core/test/project-assistant.test.js index e7911c882..1658ab43a 100644 --- a/core/test/project-assistant.test.js +++ b/core/test/project-assistant.test.js @@ -539,6 +539,19 @@ test('#P-195: only the cards that wait on the reader reach the personal block', assert.match(shared, /t-working — Worker/, 'the shared section still lists it') }) +// #2005: the personal block shortens a long card title to 77 units to keep the line readable, and +// that bound must land on a code point. A title whose 78th UTF-16 unit is the first half of an +// emoji used to carry that half into the digest text, which a reader then sees as a replacement +// character. +test('#2005: a long card title in the digest is shortened at a code point, never inside an emoji', () => { + const row = { id: 't-cut', source: 'bot', column: 'in-progress', assignee: 'Worker', ownerId: 'owner-1', updatedAt: 1, title: `${'a'.repeat(76)}😀${'b'.repeat(10)}`, status: 'blocked' } + const built = buildDigest({ project: { id: 'p', name: 'Cut' }, rows: [row], owner: 'owner-1', actionable: actionableFor([row], 'owner-1'), at: AT }) + const personal = built.text.slice(built.text.indexOf("Needs owner-1's decision:")) + const line = personal.split('\n').find(entry => entry.startsWith('- t-cut')) + assert.equal(line.isWellFormed(), true, 'the digest line is well formed, never a lone high surrogate') + assert.equal(line, `- t-cut — needs your call — ${'a'.repeat(76)}… (still in-progress)`, 'the 78th unit was the first half of the pair, so the pair is dropped whole') +}) + test('opting out stops generation and pauses the 09:00 task, and re-enabling restores both', async () => { const { rt, service, delivered, stop } = await start() try { diff --git a/core/test/project-board.test.js b/core/test/project-board.test.js index d3f4b4e23..b6feb9f6b 100644 --- a/core/test/project-board.test.js +++ b/core/test/project-board.test.js @@ -419,6 +419,29 @@ test('#1767: a Project without a decisions folder answers an empty list with the } }) +// #2005: a decision file's title is its first heading, bounded at 200 units. A heading whose 200th +// unit is the first half of an emoji used to reach the board as an unpaired surrogate, so the +// Decisions section carried half a character. +test('#2005: a decision title is bounded at a code point, never inside an emoji', async () => { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-board-')) + const dir = mkdtempSync(join(tmpdir(), 'ds-bot-title-cut-')) + mkdirSync(join(dir, '.git')) + mkdirSync(join(dir, 'decisions')) + writeFileSync(join(dir, 'decisions', '2026-10-30-cut.md'), `# ${'a'.repeat(199)}😀\n`) + const team = recordSeed() + team.projects['project-p1'].workDir = dir + const { rt, stop } = await start(home, team) + try { + const board = await rt.projectBoard('project-p1') + const [file] = board.decisions.files + assert.equal(file.title.isWellFormed(), true, 'the title is well formed, never a lone high surrogate') + assert.equal(file.title, 'a'.repeat(199), 'the 200th unit was the first half of the pair, so the pair is dropped whole') + } finally { + await stop() + rmSync(dir, { recursive: true, force: true }) + } +}) + // #1768: the queue, the leases and the blocked cards beside the columns. The seed adds one card of // each shape on Project 1 and one on Project 2, so every group can be asked what it must not show. const queueSeed = () => { @@ -473,6 +496,20 @@ test('#1768: a blocked card carries its own reason and another Project is in non } finally { await stop() } }) +// #2005: every one-line the board derives (`oneLine`) is bounded at 200 UTF-16 units, so a line +// whose 200th unit is the first half of an emoji used to reach the client as an unpaired surrogate +// — the queue reason is the case that reads a field another home supplied. +test('#2005: a blocked card reason is bounded at a code point, never inside an emoji', async () => { + const { rt, stop } = await start(undefined, queueSeed()) + try { + rt.state.tasks['task-blocked'].inputRequest.question = `${'a'.repeat(199)}😀tail` + const queues = await rt.projectBoardQueues('project-p1') + const reason = queues.blocked.find(item => item.id === 'task-blocked').reason + assert.equal(reason.isWellFormed(), true, 'the reason is well formed, never a lone high surrogate') + assert.equal(reason, 'a'.repeat(199), 'the 200th unit was the first half of the pair, so the pair is dropped whole') + } finally { await stop() } +}) + test('#1768: an unreadable queue answers unknown, never zero', async () => { const { rt, stop } = await start(undefined, queueSeed()) try { diff --git a/core/test/project-team.test.js b/core/test/project-team.test.js index 7c7ada555..38f9c3a6a 100644 --- a/core/test/project-team.test.js +++ b/core/test/project-team.test.js @@ -11,6 +11,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' import { createRuntime, provideFlyBot } from '../index.js' +import { install } from '../src/host/project-team.js' import { fakeContext } from './fake-ctx.js' const LONG = 'x'.repeat(5000) @@ -188,3 +189,46 @@ test('project-team cuts a room name from another home at 200 without splitting a assert.ok(name.startsWith(kept), 'a code-point prefix of the group name') assert.ok(kept.length <= 200, 'the budget of 200 UTF-16 units is unchanged') }) + +// #2005: `capped()` bounds the name, role, persona, model and color of a dossier row, and every +// one of those bounds must land on a code point. A bot name whose 200th unit is the first half of +// an emoji used to reach the panel as an unpaired surrogate. +test('#2005: project-team caps a bot name at a code point, never inside an emoji', async (t) => { + const { callAs, rt, stop } = await start() + t.after(stop) + rt.state.bots['bot-chief'].name = `${'a'.repeat(199)}😀` + const rows = (await callAs('project-team', { id: 'project-1' })).value.rows + assert.equal(rows[0].name.isWellFormed(), true, 'the name is well formed, never a lone high surrogate') + assert.equal(rows[0].name, 'a'.repeat(199), 'the 200th unit was the first half of the pair, so the pair is dropped whole') +}) + +// #2005: the dossier's memory head is bounded at 4,000 units, and that bound must land on a code +// point too; a memory file whose 4,000th unit is the first half of an emoji used to reach the +// panel as half a character. +test('#2005: project-team cuts the memory head at a code point, never inside an emoji', async (t) => { + const { callAs, home, stop } = await start() + t.after(stop) + memory(home, 'bot-chief', `${'a'.repeat(3999)}😀`) + const rows = (await callAs('project-team', { id: 'project-1' })).value.rows + assert.equal(rows[0].memory.head.isWellFormed(), true, 'the head is well formed, never a lone high surrogate') + assert.equal(rows[0].memory.head, 'a'.repeat(3999), 'the 4,000th unit was the first half of the pair, so the pair is dropped whole') +}) + +// #2005: the head is bounded twice on purpose — once where `memoryDigest` reads it, and once in the +// dossier row itself, which is where the module promises that every string it answers is capped. The +// second bound cannot be reached through the reader (a 4,000-unit head is already well formed), so it +// is pinned by a direct call that hands the module a 4,001-unit head itself. +test('#2005: project-team cuts a head handed to it directly at a code point', () => { + const head = `${'a'.repeat(3999)}😀` + const rt = { + state: { rooms: {} }, + readProject: () => ({ id: 'project-1', chief: { botId: 'bot-chief' }, assistant: { botId: '' }, maintainers: [], members: [] }), + botOf: id => (id === 'bot-chief' ? { id, name: 'Chief', role: 'Chief', instructions: '', model: 'deepseek-chat', color: '#112233', capabilities: [], createdAt: 1 } : null), + memoryDigest: () => ({ head, topics: [] }), + } + install(rt) + const row = rt.projectTeam('project-1').rows[0] + assert.equal(head.length, 4001, 'the caller handed the dossier 4,001 units') + assert.equal(row.memory.head.isWellFormed(), true, 'the head is well formed, never a lone high surrogate') + assert.equal(row.memory.head, 'a'.repeat(3999), 'the pair at the bound is dropped whole instead of keeping its first half') +}) diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 68f9bef24..bc63be899 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-unit context `clip` cuts through `cut`, and its 3,000-unit two-ended `ends` takes the head through `cut` and moves the tail's start back with `codePointStart`, giving the head back the unit a stepped-back tail keeps, so the prompt and the log hold whole characters within the same budget; 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 300-unit `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, and the 300 does not move ([#2002](https://github.com/ThinkFlowLab/FlyBot/issues/2002)). | +| `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-unit context `clip` cuts through `cut`, and its 3,000-unit two-ended `ends` takes the head through `cut` and moves the tail's start back with `codePointStart`, giving the head back the unit a stepped-back tail keeps, so the prompt and the log hold whole characters within the same budget; 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 300-unit `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, and the 300 does not move ([#2002](https://github.com/ThinkFlowLab/FlyBot/issues/2002)). 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)). | | `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/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. | | `core/src/host/face-mentions.js` | Who "@" calls in a face-to-face room (#287): every alias of a participant (`faceAliases`, kept in `shared/remote-label.js` so the bubble chips the same names, #305; bare name, the picker label `'s Chief` / ` · 's Bot`, ` · `, handle), local and friend's Bots told apart. `faceMentions().handles` is the handle of every Bot a line names (the explicit mention list a sender attaches, #293) and `mentionedByHandle` resolves a received list to my Bots (`known` counts handles the room has). | diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 503d4fd78..224796e98 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -5098,7 +5098,7 @@ diff --git a/src/host/memory.js b/src/host/memory.js } } -@@ -1378,8 +1378,42 @@ export function install(rt) { +@@ -1378,8 +1378,44 @@ export function install(rt) { throw new Error(`Unknown endpoint ${endpoint}`) } @@ -5130,7 +5130,9 @@ diff --git a/src/host/memory.js b/src/host/memory.js + const head = Number.isFinite(limits.head) ? limits.head : 4000 + const topics = Number.isFinite(limits.topics) ? limits.topics : 64 + return { -+ head: readText(join(dirOf(scope), MAIN)).slice(0, head), ++ // The head is the dossier's text, so its bound lands on a code point (#2005): a `slice` ++ // here handed the panel the first half of an emoji, and no later bound can put it back. ++ head: cut(readText(join(dirOf(scope), MAIN)), head).text, + topics: topicNames(scope).slice(0, topics), + } + } diff --git a/patches/ds-bot/files/src/host/project-assistant.js b/patches/ds-bot/files/src/host/project-assistant.js index 6a37f5867..d1ecee73a 100644 --- a/patches/ds-bot/files/src/host/project-assistant.js +++ b/patches/ds-bot/files/src/host/project-assistant.js @@ -17,7 +17,7 @@ import { defineTool } from './tool-schema.js' import { MAX_ENTRY } from './constants.js' import { listProjectState } from './project-state.js' -import { codePoints } from './text.js' +import { codePoints, cut } from './text.js' import { output } from './tools.js' import { inQuietHours, clockIn, quietHoursOf, readQuietHours } from '../shared/quiet-hours.js' @@ -129,7 +129,9 @@ const askOf = (row) => { const title = text(row.title).replace(/\s+/g, ' ') const where = text(row.column) === '' ? 'open' : `still ${text(row.column)}` if (title === '') return `${waitOf(row)} — ${where}` - const short = title.length > 80 ? `${title.slice(0, 77)}…` : title + // One line still, and the 77-unit bound lands on a code point (#2005): a cut inside a pair + // would send the digest half an emoji. + const short = title.length > 80 ? `${cut(title, 77).text}…` : title return `${waitOf(row)} — ${short} (${where})` } diff --git a/patches/ds-bot/files/src/host/project-board.js b/patches/ds-bot/files/src/host/project-board.js index 05c769ddc..8b259512a 100644 --- a/patches/ds-bot/files/src/host/project-board.js +++ b/patches/ds-bot/files/src/host/project-board.js @@ -20,6 +20,7 @@ import { open, readFile, stat } from 'node:fs/promises' import { join } from 'node:path' import { columnOf } from '../shared/task-board.js' import { DECISIONS_DIR, listProjectState, sha256 } from './project-state.js' +import { cut } from './text.js' import { advanceRefusal } from './meetings.js' import { readMetrics } from './project-metrics.js' @@ -102,7 +103,9 @@ const text = value => (typeof value === 'string' ? value.trim() : '') // The column a finished card reads (#1855): the archive key, never a row. const DONE_COLUMN = 'done' const ONE_LINE = 200 -const oneLine = value => (typeof value === 'string' ? value.replace(/\s+/g, ' ').trim().slice(0, ONE_LINE) : '') +// #2005: the bound lands on a code point. A `slice` here put the first half of an emoji into the +// queue reason the panel draws, which reads as a replacement character. +const oneLine = value => (typeof value === 'string' ? cut(value.replace(/\s+/g, ' ').trim(), ONE_LINE).text : '') const stampOf = value => (Number.isFinite(value) ? value : null) // Waiting: an open card a role may claim and no member has. `waitedMs` is measured from the card's @@ -160,7 +163,8 @@ async function titleOf(dir, path) { for (const raw of buffer.subarray(0, bytesRead).toString('utf8').split(/\r?\n/)) { const line = raw.trim() if (line === '') continue - return line.replace(/^#+\s*/, '').slice(0, TITLE_MAX) + // The same code-point rule as `oneLine` (#2005): a decision heading may end in an emoji. + return cut(line.replace(/^#+\s*/, ''), TITLE_MAX).text } return '' } catch { diff --git a/patches/ds-bot/files/src/host/project-team.js b/patches/ds-bot/files/src/host/project-team.js index 5fe3ef55c..c97459d24 100644 --- a/patches/ds-bot/files/src/host/project-team.js +++ b/patches/ds-bot/files/src/host/project-team.js @@ -19,7 +19,9 @@ import { cut } from './text.js' export const DOSSIER_LIMITS = { persona: 4000, memoryHead: 4000, topics: 64, skills: 64, rooms: 32, name: 200, role: 120, model: 200, color: 64 } const text = value => (typeof value === 'string' ? value.trim() : '') -const capped = (value, limit) => text(value).slice(0, limit) +// #2005: every capped field lands on a code point — a `slice` bound could end on the first half +// of an emoji, which the panel draws as a replacement character. +const capped = (value, limit) => cut(text(value), limit).text export function install(rt) { // The attached names that are skills. A pack is expanded to its skill children; a capability the @@ -64,7 +66,7 @@ export function install(rt) { color: capped(bot.color, DOSSIER_LIMITS.color), createdAt: Number.isFinite(bot.createdAt) ? bot.createdAt : null, memory: { - head: (typeof digest.head === 'string' ? digest.head : '').slice(0, DOSSIER_LIMITS.memoryHead), + head: cut(typeof digest.head === 'string' ? digest.head : '', DOSSIER_LIMITS.memoryHead).text, topics: (Array.isArray(digest.topics) ? digest.topics : []).map(name => text(name)).filter(name => name !== '').slice(0, DOSSIER_LIMITS.topics), }, skills: skillNames(bot), diff --git a/patches/ds-bot/files/test/project-assistant.test.js b/patches/ds-bot/files/test/project-assistant.test.js index e7911c882..1658ab43a 100644 --- a/patches/ds-bot/files/test/project-assistant.test.js +++ b/patches/ds-bot/files/test/project-assistant.test.js @@ -539,6 +539,19 @@ test('#P-195: only the cards that wait on the reader reach the personal block', assert.match(shared, /t-working — Worker/, 'the shared section still lists it') }) +// #2005: the personal block shortens a long card title to 77 units to keep the line readable, and +// that bound must land on a code point. A title whose 78th UTF-16 unit is the first half of an +// emoji used to carry that half into the digest text, which a reader then sees as a replacement +// character. +test('#2005: a long card title in the digest is shortened at a code point, never inside an emoji', () => { + const row = { id: 't-cut', source: 'bot', column: 'in-progress', assignee: 'Worker', ownerId: 'owner-1', updatedAt: 1, title: `${'a'.repeat(76)}😀${'b'.repeat(10)}`, status: 'blocked' } + const built = buildDigest({ project: { id: 'p', name: 'Cut' }, rows: [row], owner: 'owner-1', actionable: actionableFor([row], 'owner-1'), at: AT }) + const personal = built.text.slice(built.text.indexOf("Needs owner-1's decision:")) + const line = personal.split('\n').find(entry => entry.startsWith('- t-cut')) + assert.equal(line.isWellFormed(), true, 'the digest line is well formed, never a lone high surrogate') + assert.equal(line, `- t-cut — needs your call — ${'a'.repeat(76)}… (still in-progress)`, 'the 78th unit was the first half of the pair, so the pair is dropped whole') +}) + test('opting out stops generation and pauses the 09:00 task, and re-enabling restores both', async () => { const { rt, service, delivered, stop } = await start() try { diff --git a/patches/ds-bot/files/test/project-board.test.js b/patches/ds-bot/files/test/project-board.test.js index d3f4b4e23..b6feb9f6b 100644 --- a/patches/ds-bot/files/test/project-board.test.js +++ b/patches/ds-bot/files/test/project-board.test.js @@ -419,6 +419,29 @@ test('#1767: a Project without a decisions folder answers an empty list with the } }) +// #2005: a decision file's title is its first heading, bounded at 200 units. A heading whose 200th +// unit is the first half of an emoji used to reach the board as an unpaired surrogate, so the +// Decisions section carried half a character. +test('#2005: a decision title is bounded at a code point, never inside an emoji', async () => { + const home = mkdtempSync(join(tmpdir(), 'ds-bot-board-')) + const dir = mkdtempSync(join(tmpdir(), 'ds-bot-title-cut-')) + mkdirSync(join(dir, '.git')) + mkdirSync(join(dir, 'decisions')) + writeFileSync(join(dir, 'decisions', '2026-10-30-cut.md'), `# ${'a'.repeat(199)}😀\n`) + const team = recordSeed() + team.projects['project-p1'].workDir = dir + const { rt, stop } = await start(home, team) + try { + const board = await rt.projectBoard('project-p1') + const [file] = board.decisions.files + assert.equal(file.title.isWellFormed(), true, 'the title is well formed, never a lone high surrogate') + assert.equal(file.title, 'a'.repeat(199), 'the 200th unit was the first half of the pair, so the pair is dropped whole') + } finally { + await stop() + rmSync(dir, { recursive: true, force: true }) + } +}) + // #1768: the queue, the leases and the blocked cards beside the columns. The seed adds one card of // each shape on Project 1 and one on Project 2, so every group can be asked what it must not show. const queueSeed = () => { @@ -473,6 +496,20 @@ test('#1768: a blocked card carries its own reason and another Project is in non } finally { await stop() } }) +// #2005: every one-line the board derives (`oneLine`) is bounded at 200 UTF-16 units, so a line +// whose 200th unit is the first half of an emoji used to reach the client as an unpaired surrogate +// — the queue reason is the case that reads a field another home supplied. +test('#2005: a blocked card reason is bounded at a code point, never inside an emoji', async () => { + const { rt, stop } = await start(undefined, queueSeed()) + try { + rt.state.tasks['task-blocked'].inputRequest.question = `${'a'.repeat(199)}😀tail` + const queues = await rt.projectBoardQueues('project-p1') + const reason = queues.blocked.find(item => item.id === 'task-blocked').reason + assert.equal(reason.isWellFormed(), true, 'the reason is well formed, never a lone high surrogate') + assert.equal(reason, 'a'.repeat(199), 'the 200th unit was the first half of the pair, so the pair is dropped whole') + } finally { await stop() } +}) + test('#1768: an unreadable queue answers unknown, never zero', async () => { const { rt, stop } = await start(undefined, queueSeed()) try { diff --git a/patches/ds-bot/files/test/project-team.test.js b/patches/ds-bot/files/test/project-team.test.js index 7c7ada555..38f9c3a6a 100644 --- a/patches/ds-bot/files/test/project-team.test.js +++ b/patches/ds-bot/files/test/project-team.test.js @@ -11,6 +11,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' import { createRuntime, provideFlyBot } from '../index.js' +import { install } from '../src/host/project-team.js' import { fakeContext } from './fake-ctx.js' const LONG = 'x'.repeat(5000) @@ -188,3 +189,46 @@ test('project-team cuts a room name from another home at 200 without splitting a assert.ok(name.startsWith(kept), 'a code-point prefix of the group name') assert.ok(kept.length <= 200, 'the budget of 200 UTF-16 units is unchanged') }) + +// #2005: `capped()` bounds the name, role, persona, model and color of a dossier row, and every +// one of those bounds must land on a code point. A bot name whose 200th unit is the first half of +// an emoji used to reach the panel as an unpaired surrogate. +test('#2005: project-team caps a bot name at a code point, never inside an emoji', async (t) => { + const { callAs, rt, stop } = await start() + t.after(stop) + rt.state.bots['bot-chief'].name = `${'a'.repeat(199)}😀` + const rows = (await callAs('project-team', { id: 'project-1' })).value.rows + assert.equal(rows[0].name.isWellFormed(), true, 'the name is well formed, never a lone high surrogate') + assert.equal(rows[0].name, 'a'.repeat(199), 'the 200th unit was the first half of the pair, so the pair is dropped whole') +}) + +// #2005: the dossier's memory head is bounded at 4,000 units, and that bound must land on a code +// point too; a memory file whose 4,000th unit is the first half of an emoji used to reach the +// panel as half a character. +test('#2005: project-team cuts the memory head at a code point, never inside an emoji', async (t) => { + const { callAs, home, stop } = await start() + t.after(stop) + memory(home, 'bot-chief', `${'a'.repeat(3999)}😀`) + const rows = (await callAs('project-team', { id: 'project-1' })).value.rows + assert.equal(rows[0].memory.head.isWellFormed(), true, 'the head is well formed, never a lone high surrogate') + assert.equal(rows[0].memory.head, 'a'.repeat(3999), 'the 4,000th unit was the first half of the pair, so the pair is dropped whole') +}) + +// #2005: the head is bounded twice on purpose — once where `memoryDigest` reads it, and once in the +// dossier row itself, which is where the module promises that every string it answers is capped. The +// second bound cannot be reached through the reader (a 4,000-unit head is already well formed), so it +// is pinned by a direct call that hands the module a 4,001-unit head itself. +test('#2005: project-team cuts a head handed to it directly at a code point', () => { + const head = `${'a'.repeat(3999)}😀` + const rt = { + state: { rooms: {} }, + readProject: () => ({ id: 'project-1', chief: { botId: 'bot-chief' }, assistant: { botId: '' }, maintainers: [], members: [] }), + botOf: id => (id === 'bot-chief' ? { id, name: 'Chief', role: 'Chief', instructions: '', model: 'deepseek-chat', color: '#112233', capabilities: [], createdAt: 1 } : null), + memoryDigest: () => ({ head, topics: [] }), + } + install(rt) + const row = rt.projectTeam('project-1').rows[0] + assert.equal(head.length, 4001, 'the caller handed the dossier 4,001 units') + assert.equal(row.memory.head.isWellFormed(), true, 'the head is well formed, never a lone high surrogate') + assert.equal(row.memory.head, 'a'.repeat(3999), 'the pair at the bound is dropped whole instead of keeping its first half') +})