From deb2dab0682143c32a32b981b621242a45d2da39 Mon Sep 17 00:00:00 2001 From: Day Dreamer <87640074+HugoXK@users.noreply.github.com> Date: Sat, 31 Oct 2026 19:29:32 +0800 Subject: [PATCH] fix(host): cut peer-name bounds on code point boundaries (#1923) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four display-name bounds still sliced UTF-16 units: cleanPublishedName (60), the group member line's displayName and ownerName (60/40), reach's `label` (60) and room-share's local `clip`. A name whose cap falls inside a surrogate pair therefore reached a UTF-8 encoder as U+FFFD β€” in the stored remote roster, the model's member line, the room labels and the envelope another owner receives. Route all four through cut() from shared/text-boundary.js. The numeric caps do not change. --- changelog.d/1923-published-name-cut.fixed.md | 1 + core/src/host/hooks.js | 3 +- core/src/host/reach.js | 3 +- core/src/host/tools.js | 3 +- core/src/shared/room-share.js | 4 +- core/test/flybot.test.js | 65 +++++++++++++++++++ core/test/reach-policy.test.js | 19 +++++- core/test/remote-room.test.js | 27 ++++++++ docs/subsystems/core-host.md | 2 +- docs/subsystems/plugin-runtime.md | 2 +- patches/ds-bot/0002-flybot-api-host.patch | 5 +- patches/ds-bot/files/src/host/hooks.js | 3 +- patches/ds-bot/files/src/host/reach.js | 3 +- patches/ds-bot/files/src/shared/room-share.js | 4 +- patches/ds-bot/files/test/flybot.test.js | 65 +++++++++++++++++++ .../ds-bot/files/test/reach-policy.test.js | 19 +++++- patches/ds-bot/files/test/remote-room.test.js | 27 ++++++++ 17 files changed, 242 insertions(+), 13 deletions(-) create mode 100644 changelog.d/1923-published-name-cut.fixed.md diff --git a/changelog.d/1923-published-name-cut.fixed.md b/changelog.d/1923-published-name-cut.fixed.md new file mode 100644 index 000000000..1e6505ad7 --- /dev/null +++ b/changelog.d/1923-published-name-cut.fixed.md @@ -0,0 +1 @@ +A friend's Bot name no longer shows half of an emoji ([#1923](https://github.com/ThinkFlowLab/FlyBot/issues/1923)): the published name stored from the roster, the group member line, the room label `othersInRoom` returns and the envelope's name, handle, ownerName and roster fields now cut on a code point boundary, so such a value that straddles its 60 (or 40) cap keeps the whole character instead of U+FFFD. The envelope's message body and its fitTransport shrink loop are untouched. diff --git a/core/src/host/hooks.js b/core/src/host/hooks.js index 96f933d5b..3b2300239 100644 --- a/core/src/host/hooks.js +++ b/core/src/host/hooks.js @@ -24,6 +24,7 @@ import { createPluginModel } from './plugin-model.js' import { defineTool } from './tool-schema.js' import { retiredBot } from './store.js' import { codePoints } from './text.js' +import { cut } from '../shared/text-boundary.js' import { EPHEMERAL_DENIED_TOOLS } from './tools.js' export const FLYBOT_API_VERSION = 1 @@ -44,7 +45,7 @@ const clone = value => (value === undefined ? undefined : JSON.parse(JSON.string const same = (a, b) => JSON.stringify(a) === JSON.stringify(b) // Peer-controlled labels (display name, home, owner): strip @ so they cannot look // like a mention of another Bot, strip controls/newlines, keep a 60-char cap (#75). -export const cleanPublishedName = (value) => String(value ?? '').replace(/@/g, '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 60) +export const cleanPublishedName = (value) => cut(String(value ?? '').replace(/@/g, '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ').replace(/\s+/g, ' ').trim(), 60).text // A remote address: { kind: 'remote', handle, home? } or a '@handle' string. Remote Bots // are never in bot.list(); their handle is '@.' (home '@') or a plain // per-bot identity. diff --git a/core/src/host/reach.js b/core/src/host/reach.js index 8814d2894..d6e81832f 100644 --- a/core/src/host/reach.js +++ b/core/src/host/reach.js @@ -27,6 +27,7 @@ import { ownEntry } from './store.js' // no sender the owner or the system: allowed import { currentParticipants, homesOf } from './room-membership.js' +import { cut } from '../shared/text-boundary.js' export const POLICY = 'owner-policy' @@ -154,7 +155,7 @@ export function mayShareRoom(state, room, botId) { return false } -const label = value => String(value ?? '').replace(/\p{Cc}/gu, ' ').trim().slice(0, 60) +const label = value => cut(String(value ?? '').replace(/\p{Cc}/gu, ' ').trim(), 60).text // The people and Bots in one room that are not the Bot's owner's own (a friend's Bot, a person who // is not its owner, a Bot of another owner, a room joined by code), as short labels. diff --git a/core/src/host/tools.js b/core/src/host/tools.js index a807dc7f1..7f384ca30 100644 --- a/core/src/host/tools.js +++ b/core/src/host/tools.js @@ -10,6 +10,7 @@ import { currentParticipants } from './room-membership.js' import { questionReply, replyText } from './parts.js' import { OTHER, settled } from './turn-origin.js' import { cleanName } from './delivery.js' +import { cut } from '../shared/text-boundary.js' import { retiredBot } from './store.js' // A run Bot (ephemeral) is not a team member: every tool below has an effect that reaches past @@ -81,7 +82,7 @@ export function install(rt) { const members = room.members.map(rt.botOf).filter(Boolean) .map(bot => `${bot.name}${bot.id === room.admin ? ' (admin)' : ''}${bot.id === self ? ' (you)' : ''}`) const remotes = (room.participants ?? []).filter(p => p.remote && p.leftSeq === undefined) - .map(p => `${String(p.displayName ?? p.handle).replace(/[\u0000-\u001f\u007f-\u009f[\]"<>`]/g, '').slice(0, 60)} (${String(p.ownerName ?? p.ownerHome ?? 'friend').replace(/[\u0000-\u001f\u007f-\u009f[\]"<>`]/g, '').slice(0, 40)}'s Bot)`) + .map(p => `${cut(String(p.displayName ?? p.handle).replace(/[\u0000-\u001f\u007f-\u009f[\]"<>`]/g, ''), 60).text} (${cut(String(p.ownerName ?? p.ownerHome ?? 'friend').replace(/[\u0000-\u001f\u007f-\u009f[\]"<>`]/g, ''), 40).text}'s Bot)`) const parts = [ `members: ${[...members, ...remotes].join(', ')}`, room.mode === 'admin' && room.admin ? 'plain messages go to the admin first' : 'every member replies in turn', diff --git a/core/src/shared/room-share.js b/core/src/shared/room-share.js index 4f654f4cd..b7959153c 100644 --- a/core/src/shared/room-share.js +++ b/core/src/shared/room-share.js @@ -2,6 +2,8 @@ // Bot Social has no FlyBot-group record: the owner's FlyBot parses this on inbox receive // and projects an inbound-share room. Keep the marker unique so ordinary chat never matches. +import { cut } from './text-boundary.js' + export const ROOM_SHARE_MARK = 'FLYBOT-ROOM-SHARE' // #852: `ask` is the invitee knocking at a local room's link (or accepting it with `accept: true`); @@ -11,7 +13,7 @@ const OPS = new Set(['join', 'leave', 'line', 'ask', 'invite']) const TICKET_SHAPE = /^[A-Za-z0-9_-]{16,64}$/ function clip(value, max = 60) { - return String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').trim().slice(0, max) + return cut(String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').trim(), max).text } // #904: a room's roster entry. A Bot inside a real room is a handful, not a crowd; the cap keeps one diff --git a/core/test/flybot.test.js b/core/test/flybot.test.js index 508581262..1618bb015 100644 --- a/core/test/flybot.test.js +++ b/core/test/flybot.test.js @@ -815,6 +815,71 @@ test('cleanPublishedName strips @, controls and newlines, and caps at 60 (#75)', assert.equal(cleanPublishedName('Owner\u0000@chief.home'), 'Owner chief.home') assert.equal(cleanPublishedName('@@@'), '') assert.equal(cleanPublishedName('x'.repeat(80)), 'x'.repeat(60)) + // #1923: the 60 cap is a UTF-16 cut, and `'a'.repeat(59) + 'πŸ˜€tail'` used to split the emoji. + const straddle = 'a'.repeat(59) + 'πŸ˜€tail' + const kept = cleanPublishedName(straddle) + assert.equal(kept.isWellFormed(), true, `the kept name is not well formed: ${JSON.stringify(kept.slice(-2))}`) + assert.equal(kept, 'a'.repeat(59), 'the straddling emoji is dropped whole, never split') + assert.ok(kept.length <= 60, `the cap still holds: ${kept.length}`) + assert.ok(Buffer.from(kept, 'utf8').equals(Buffer.from(straddle, 'utf8').subarray(0, Buffer.byteLength(kept))), 'the kept name is a prefix of the input, byte for byte') +}) + +// #1923: the same bound at the record boundary the name is stored through (hooks.js:947-949). +test('a published peer name, homeName and owner keep whole characters at the 60 bound (#1923)', async (t) => { + const { ctx, fb, stop, grantTo } = await start() + t.after(stop) + await grantTo('a', '@eve.friend.home') + ctx.llm.listProviders = () => [{ id: 'flybot-remote', name: 'Friends\' Bots' }] + ctx.llm.listModels = async () => [{ id: '@eve.friend.home', name: 'Eve' }] + ctx.sessionController.create = async () => ({ sessionId: 'remote-1' }) + const straddle = 'a'.repeat(59) + 'πŸ˜€tail' + const ownerStraddle = 'o'.repeat(59) + 'πŸ˜€tail' + const social = fb.scope('social') + social.remote.publish([{ handle: '@eve.friend.home', name: straddle, home: '@friend.home', homeName: straddle, owner: ownerStraddle }]) + const [member] = social.remote.list() + // All three share cleanPublishedName's 60 bound; tools.js applies the tighter 40 to the owner name it shows. + const cases = [['name', member.name, straddle, 60], ['homeName', member.homeName, straddle, 60], ['owner', member.owner, ownerStraddle, 60]] + for (const [field, kept, source, max] of cases) { + assert.equal(typeof kept, 'string', `${field} is stored: ${JSON.stringify(member)}`) + assert.equal(kept.isWellFormed(), true, `${field} is not well formed: ${JSON.stringify(kept.slice(-2))}`) + assert.ok(kept.length <= max, `${field} keeps the ${max} cap: ${kept.length}`) + assert.ok(kept.length > max - 10, `${field} does not drop the whole name for one character: ${kept.length}`) + assert.equal(kept.includes('πŸ˜€'), false, `${field} drops the straddling emoji whole`) + assert.ok(Buffer.from(kept, 'utf8').equals(Buffer.from(source, 'utf8').subarray(0, Buffer.byteLength(kept))), `${field} is a prefix of the published name, byte for byte`) + } +}) + +// #1923: the group member line (tools.js:84) carries a peer display name at 60 and an owner name at 40. +test('list_bots keeps whole characters in a peer display name and owner name (#1923)', async (t) => { + const displayName = 'a'.repeat(59) + 'πŸ˜€tail' + const ownerName = 'o'.repeat(39) + 'πŸ˜€tail' + const { tool, stop } = await start({ + rooms: { + g: { + id: 'g', name: 'Group', members: ['a'], + participants: [{ kind: 'bot', remote: true, id: '@peer.bob.home', handle: '@peer.bob.home', displayName, ownerName }], + }, + }, + }) + t.after(stop) + const listed = await tool('list_bots', {}, 'a') + assert.equal(listed.isWellFormed(), true, `the member line is not well formed: ${JSON.stringify(listed.slice(0, 80))}`) + const line = listed.split('\n').find(l => l.includes('members: ') && l.includes("'s Bot)")) + assert.ok(line, `the room line is listed: ${JSON.stringify(listed)}`) + // The peer entry is last on the member line: ` ('s Bot)`. + const members = line.split('members: ')[1].split(';')[0] + const peer = members.slice(members.lastIndexOf(', ') + 2) + const atOwner = peer.lastIndexOf(' (') + const keptDisplay = peer.slice(0, atOwner) + const keptOwner = peer.slice(atOwner + 2, peer.lastIndexOf('\'s Bot)')) + assert.equal(keptDisplay.isWellFormed(), true, `the display name is not well formed: ${JSON.stringify(keptDisplay)}`) + assert.equal(keptDisplay.includes('πŸ˜€'), false, 'the display name drops the straddling emoji whole') + assert.ok(keptDisplay.length <= 60 && keptDisplay.length > 50, `the display name keeps the 60 bound: ${keptDisplay.length}`) + assert.ok(Buffer.from(keptDisplay, 'utf8').equals(Buffer.from(displayName, 'utf8').subarray(0, Buffer.byteLength(keptDisplay))), 'the display name is a prefix of what the peer published, byte for byte') + assert.equal(keptOwner.isWellFormed(), true, `the owner name is not well formed: ${JSON.stringify(keptOwner)}`) + assert.equal(keptOwner.includes('πŸ˜€'), false, 'the owner name drops the straddling emoji whole') + assert.ok(keptOwner.length <= 40 && keptOwner.length > 30, `the owner name keeps the 40 bound: ${keptOwner.length}`) + assert.ok(Buffer.from(keptOwner, 'utf8').equals(Buffer.from(ownerName, 'utf8').subarray(0, Buffer.byteLength(keptOwner))), 'the owner name is a prefix of what the peer published, byte for byte') }) test('cleanRemote sanitizes peer name, homeName and owner at the record boundary (#75)', async (t) => { diff --git a/core/test/reach-policy.test.js b/core/test/reach-policy.test.js index ec9fee7d6..f8effc48b 100644 --- a/core/test/reach-policy.test.js +++ b/core/test/reach-policy.test.js @@ -6,7 +6,7 @@ import { join } from 'node:path' import { test } from 'node:test' import { createRuntime, provideFlyBot } from '../index.js' import { fakeContext } from './fake-ctx.js' -import { hasAnyGrant, hasGrant, reach } from '../src/host/reach.js' +import { hasAnyGrant, hasGrant, othersInRoom, reach } from '../src/host/reach.js' const bot = (id, ownerId, extra = {}) => ({ id, sessionId: id, name: id.toUpperCase(), instructions: '', createdAt: 1, ownerId, ...extra }) @@ -375,3 +375,20 @@ test('replacing the designated Chief does not grant the new one', async (t) => { assert.equal(fb.bot.hasAnyGrant('a'), false) assert.deepEqual(events, []) }) + +// #1923: `othersInRoom` labels a friend's Bot for the model, so the 60 bound must not split a character. +test('a peer label for a room is cut on a code point boundary (#1923)', () => { + const straddle = 'a'.repeat(59) + 'πŸ˜€tail' + const state = team() + state.rooms = { + r: { + id: 'r', members: ['a'], + participants: [{ kind: 'bot', remote: true, id: '@peer.bob.home', handle: '@peer.bob.home', displayName: straddle }], + }, + } + const [label] = othersInRoom(state, state.rooms.r, state.bots.a) + assert.equal(label.isWellFormed(), true, `the label is not well formed: ${JSON.stringify(label.slice(-2))}`) + assert.equal(label.includes('πŸ˜€'), false, 'the straddling emoji is dropped whole') + assert.ok(label.length <= 60 && label.length > 50, `the label keeps the 60 bound: ${label.length}`) + assert.ok(Buffer.from(label, 'utf8').equals(Buffer.from(straddle, 'utf8').subarray(0, Buffer.byteLength(label))), 'the label is a prefix of the published name, byte for byte') +}) diff --git a/core/test/remote-room.test.js b/core/test/remote-room.test.js index 5125be246..7eea3ff08 100644 --- a/core/test/remote-room.test.js +++ b/core/test/remote-room.test.js @@ -863,6 +863,33 @@ test('room-share envelope round-trips join, line and leave', () => { assert.equal(parseRoomShare('hello there'), undefined) }) +// #1923: these fields cross an owner boundary, so the envelope's own bounds must never split a character. +test('a room-share envelope clips peer names on a code point boundary (#1923)', () => { + const name = 'a'.repeat(59) + 'πŸ˜€tail' + const handle = '@' + 'b'.repeat(118) + 'πŸ˜€tail' + const packed = encodeRoomShare({ + op: 'join', roomId: 'war', name, inviterName: name, + inviterHome: '@alice.home', inviterHandle: '@chief.alice.home', invitedHandle: '@coder.bob.home', seq: 1, + roster: [{ handle, name }], + }) + const parsed = parseRoomShare(packed) + assert.equal(parsed.op, 'join') + const cases = [ + ['name', parsed.name, name, 60], + ['inviterName', parsed.inviterName, name, 60], + ['roster[0].handle', parsed.roster[0].handle, handle, 120], + ['roster[0].name', parsed.roster[0].name, name, 60], + ] + for (const [field, kept, source, max] of cases) { + assert.equal(typeof kept, 'string', `${field} is in the envelope: ${JSON.stringify(parsed)}`) + assert.equal(kept.isWellFormed(), true, `${field} is not well formed: ${JSON.stringify(kept.slice(-2))}`) + assert.equal(kept.includes('πŸ˜€'), false, `${field} drops the straddling emoji whole`) + assert.ok(kept.length <= max, `${field} keeps the ${max} bound: ${kept.length}`) + assert.ok(kept.length > max - 10, `${field} does not drop the whole field for one character: ${kept.length}`) + assert.ok(Buffer.from(kept, 'utf8').equals(Buffer.from(source, 'utf8').subarray(0, Buffer.byteLength(kept))), `${field} is a prefix of what the sender meant, byte for byte`) + } +}) + test('applyInboundRoomShare projects a join with no chat line, then a leave hides it', async (t) => { const { rt } = host(t, TEAM) await rt.load() diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index ff5015c6a..779e05b1b 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. | +| `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)). | | `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/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index 64afeee7b..a8c601a58 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -56,7 +56,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots 5. Reach (#90): the core's owner policy (`reach.js`) is not a middleware. `hooks.js` runs it before the `bot.send` chain, on approval release and on every deliver that carries a sender or a remote origin; a plugin's `allow` or `bypass` never skips it, and plugins see its denial as `{ action: 'deny', by: 'owner-policy' }`. Plugins read grants through `flybot.bot.hasGrant` / `hasAnyGrant`, `BotView.granted`, `flybot.grants` and the `bot.grants` event. A group's mention receipt (`mentionReceipt`) turns a not-shared refusal (`cannot message their Bots`, #273) into its short invite-or-add hint. 6. `flybot.roomApprovals.setProvider` lets a plugin (social) attach pending outbound holds to a shared-room view. It does not enable team mode. An `allow` verdict may say `autoSent: true` (with `recipient`) when it skips a hold only because the owner turned Auto-send on; the host then marks an auto-reply's turn Auto-sent (#417). It confers nothing. A remote turn that died is not that hold (#1068): `replyRemote` sends the host's fixed sentence with `via: 'turn-failed'` and `meta.notice: 'turn-failed'` (`failure: true`), and the social plugin allows only those two sentences. A model reply stays `auto-reply`. A `hold` verdict may carry `recipient`, who gets the message as the owner knows them (a person's name, trimmed, at most 120 characters); the record keeps it as `recipientName` and the cards name it (#418). A Bot's held sends are also visible to Bot coordination: `list_bots` tags the waiting Bot with `N to approve` (a count only, `src/host/tools.js`, #587). A `flybot.approvals` entry with `inline: true` and a `policy` is not an overlay card: it draws the decision on a held reply's own bubble in the chat (`held-reply.js`, props `{ held, decide, call, actions }`), and the overlay skips it. Approval claims (`claimApprovals`, `chatOpened`) live in `approval-claims.js` and are re-exported by `flybot-slots.js`. -7. `cleanRemote` is the record boundary for a friend's granted Bot: `name`, `homeName` and `owner` strip `@` (so they cannot look like a mention of another Bot in `nameMentions` chips/previews) and control characters/newlines, then cap at 60 characters. Handles stay on the `handle` field. Optional look fields (`color`, `avatar` object, `avatarUpdatedAt`) pass through when present so remote chats, group lists, and headers can render a peer's chosen look; missing look stays handle-seeded. Those labels then flow into delivery `fromName` (still `cleanName`'d and the body fenced) and into `list_bots` friends lines via the remote-chat roster. Group room logs use local team names, not peer labels. +7. `cleanRemote` is the record boundary for a friend's granted Bot: `name`, `homeName` and `owner` strip `@` (so they cannot look like a mention of another Bot in `nameMentions` chips/previews) and control characters/newlines, then cap at 60 characters through `cut` from `shared/text-boundary.js` (#1923), so a name whose cap falls inside a surrogate pair keeps the whole character instead of reaching the roster as U+FFFD. Handles stay on the `handle` field. Optional look fields (`color`, `avatar` object, `avatarUpdatedAt`) pass through when present so remote chats, group lists, and headers can render a peer's chosen look; missing look stays handle-seeded. Those labels then flow into delivery `fromName` (still `cleanName`'d and the body fenced) and into `list_bots` friends lines via the remote-chat roster. Group room logs use local team names, not peer labels. 8. A user's @ of a friend's Bot in a group is answered in that group (#825): the line reaches the Bot as a room line and its reply comes back as one, which is why `mentionReceipt` (`hooks.js`, relay role `mention`) posts only what went wrong β€” why the send was denied, held, or failed β€” and no "sent" line at all. The receipt's role stays outside the room round trigger (`groups.js` `opens`), so it never opens a round; peer names go through `cleanName`. A human line that names a friend's Bot already in that room is **not** 1:1-relayed as the group's admin (Chief): the room message is the delivery, so Chief's missing grant does not block it (#143). A Chief-originated `bot.send` to an ungranted friend Bot is still deny. A remaining grant deny on a mention that is *not* in the room says to invite the Bot or add it to the group. 9. `flybot.rooms` hosts a Bot Social face-to-face matching room as a native room (`upsertFace` / `applyFaceLine` / `ofFace` / `dropFace` / `onFaceLine` (`fn(room, line, { mentions })`: for the owner's typed line, the handles its `@` names, #293) / `postNotice` / `postHuman` (quiet; it reads the friend's line for `@` of a local Bot by label or handle and passes `callIds`, so only those Bots answer, #287; an optional `mentions` handle list from an attested line is resolved first, #293; `attested: true`, passed only for a line the server stamps `typed_by_owner`, lets a plain or `@all` line start this home's Bots in one capped pass, #295; a plain line is answered by the Bots that judge it theirs, `@all` is not routed, #299) / `postRemote`; `postHuman` draws another member's typed line as a chat bubble from that person, #180). Slot `flybot.room.overview` is a block at the top of a group's overview: while joining, a tile grid (avatar above name + owner) with **Leave** and **Enter chat** on one row; once open, compact **Leave** and Auto-send β€” not Open/Start/Leave together. Slot `flybot.room.members` adds rows at the foot of a local group's Members card (not a face or shared room), with `host(endpoint, payload)` for core endpoints (#876). Slot `flybot.new-chat.action` adds a row to the sidebar **+** short action menu (after New group chat; `label` / `hint` go through the same `t()` map). Still not team mode. `upsertFace` also takes `lead` (`{ handle, picked, canPick, supported }`, #476) and a member `role` label. 10. Held sends are owner-bound (#52): a hold stamps server-derived provenance on the record β€” `requestId`, a sha256 `version` over the request content, `origin` (`via`/`hop`/audience) and the sender Bot's `ownerId`; message claims (`meta`, text) never confer authority. `flybot/decide` passes authz for any signed-in actor and hooks binds the decision to the sender Bot's owner's session; `release` revalidates and marks records `stale` (changed request, deleted sender/target Bot, a target Bot retired while the hold waited β€” `the target Bot was retired` β€” changed owner β€” `deleteBot` invalidates eagerly; a demoted Chief's sender-side holds are invalidated eagerly on `setMain`, not inside `staleReason`). `release()` re-checks immediately before dispatch so an in-flight approve cannot go out after a concurrent invalidation. Unknown `invalidateFor` field values throw. The pending list is filtered per actor under member enforcement. The default approval card shows `senderName`/`targetName` and a delegated marker (`Approval.origin`). diff --git a/patches/ds-bot/0002-flybot-api-host.patch b/patches/ds-bot/0002-flybot-api-host.patch index fd32e5d5f..b9f341b45 100644 --- a/patches/ds-bot/0002-flybot-api-host.patch +++ b/patches/ds-bot/0002-flybot-api-host.patch @@ -1558,7 +1558,7 @@ diff --git a/src/host/store.js b/src/host/store.js diff --git a/src/host/tools.js b/src/host/tools.js --- a/src/host/tools.js +++ b/src/host/tools.js -@@ -1,9 +1,47 @@ +@@ -1,9 +1,48 @@ // Team tools: the roster, bot-to-bot messages, group chat settings and posts, and the // question card. Group rounds and reading chats are in groups.js. import { randomUUID } from 'node:crypto' @@ -1573,6 +1573,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js +import { questionReply, replyText } from './parts.js' +import { OTHER, settled } from './turn-origin.js' +import { cleanName } from './delivery.js' ++import { cut } from '../shared/text-boundary.js' +import { retiredBot } from './store.js' + +// A run Bot (ephemeral) is not a team member: every tool below has an effect that reaches past @@ -1635,7 +1636,7 @@ diff --git a/src/host/tools.js b/src/host/tools.js const members = room.members.map(rt.botOf).filter(Boolean) .map(bot => `${bot.name}${bot.id === room.admin ? ' (admin)' : ''}${bot.id === self ? ' (you)' : ''}`) + const remotes = (room.participants ?? []).filter(p => p.remote && p.leftSeq === undefined) -+ .map(p => `${String(p.displayName ?? p.handle).replace(/[\u0000-\u001f\u007f-\u009f[\]"<>`]/g, '').slice(0, 60)} (${String(p.ownerName ?? p.ownerHome ?? 'friend').replace(/[\u0000-\u001f\u007f-\u009f[\]"<>`]/g, '').slice(0, 40)}'s Bot)`) ++ .map(p => `${cut(String(p.displayName ?? p.handle).replace(/[\u0000-\u001f\u007f-\u009f[\]"<>`]/g, ''), 60).text} (${cut(String(p.ownerName ?? p.ownerHome ?? 'friend').replace(/[\u0000-\u001f\u007f-\u009f[\]"<>`]/g, ''), 40).text}'s Bot)`) const parts = [ - `members: ${members.join(', ')}`, + `members: ${[...members, ...remotes].join(', ')}`, diff --git a/patches/ds-bot/files/src/host/hooks.js b/patches/ds-bot/files/src/host/hooks.js index 96f933d5b..3b2300239 100644 --- a/patches/ds-bot/files/src/host/hooks.js +++ b/patches/ds-bot/files/src/host/hooks.js @@ -24,6 +24,7 @@ import { createPluginModel } from './plugin-model.js' import { defineTool } from './tool-schema.js' import { retiredBot } from './store.js' import { codePoints } from './text.js' +import { cut } from '../shared/text-boundary.js' import { EPHEMERAL_DENIED_TOOLS } from './tools.js' export const FLYBOT_API_VERSION = 1 @@ -44,7 +45,7 @@ const clone = value => (value === undefined ? undefined : JSON.parse(JSON.string const same = (a, b) => JSON.stringify(a) === JSON.stringify(b) // Peer-controlled labels (display name, home, owner): strip @ so they cannot look // like a mention of another Bot, strip controls/newlines, keep a 60-char cap (#75). -export const cleanPublishedName = (value) => String(value ?? '').replace(/@/g, '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 60) +export const cleanPublishedName = (value) => cut(String(value ?? '').replace(/@/g, '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ').replace(/\s+/g, ' ').trim(), 60).text // A remote address: { kind: 'remote', handle, home? } or a '@handle' string. Remote Bots // are never in bot.list(); their handle is '@.' (home '@') or a plain // per-bot identity. diff --git a/patches/ds-bot/files/src/host/reach.js b/patches/ds-bot/files/src/host/reach.js index 8814d2894..d6e81832f 100644 --- a/patches/ds-bot/files/src/host/reach.js +++ b/patches/ds-bot/files/src/host/reach.js @@ -27,6 +27,7 @@ import { ownEntry } from './store.js' // no sender the owner or the system: allowed import { currentParticipants, homesOf } from './room-membership.js' +import { cut } from '../shared/text-boundary.js' export const POLICY = 'owner-policy' @@ -154,7 +155,7 @@ export function mayShareRoom(state, room, botId) { return false } -const label = value => String(value ?? '').replace(/\p{Cc}/gu, ' ').trim().slice(0, 60) +const label = value => cut(String(value ?? '').replace(/\p{Cc}/gu, ' ').trim(), 60).text // The people and Bots in one room that are not the Bot's owner's own (a friend's Bot, a person who // is not its owner, a Bot of another owner, a room joined by code), as short labels. diff --git a/patches/ds-bot/files/src/shared/room-share.js b/patches/ds-bot/files/src/shared/room-share.js index 4f654f4cd..b7959153c 100644 --- a/patches/ds-bot/files/src/shared/room-share.js +++ b/patches/ds-bot/files/src/shared/room-share.js @@ -2,6 +2,8 @@ // Bot Social has no FlyBot-group record: the owner's FlyBot parses this on inbox receive // and projects an inbound-share room. Keep the marker unique so ordinary chat never matches. +import { cut } from './text-boundary.js' + export const ROOM_SHARE_MARK = 'FLYBOT-ROOM-SHARE' // #852: `ask` is the invitee knocking at a local room's link (or accepting it with `accept: true`); @@ -11,7 +13,7 @@ const OPS = new Set(['join', 'leave', 'line', 'ask', 'invite']) const TICKET_SHAPE = /^[A-Za-z0-9_-]{16,64}$/ function clip(value, max = 60) { - return String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').trim().slice(0, max) + return cut(String(value ?? '').replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').trim(), max).text } // #904: a room's roster entry. A Bot inside a real room is a handful, not a crowd; the cap keeps one diff --git a/patches/ds-bot/files/test/flybot.test.js b/patches/ds-bot/files/test/flybot.test.js index 508581262..1618bb015 100644 --- a/patches/ds-bot/files/test/flybot.test.js +++ b/patches/ds-bot/files/test/flybot.test.js @@ -815,6 +815,71 @@ test('cleanPublishedName strips @, controls and newlines, and caps at 60 (#75)', assert.equal(cleanPublishedName('Owner\u0000@chief.home'), 'Owner chief.home') assert.equal(cleanPublishedName('@@@'), '') assert.equal(cleanPublishedName('x'.repeat(80)), 'x'.repeat(60)) + // #1923: the 60 cap is a UTF-16 cut, and `'a'.repeat(59) + 'πŸ˜€tail'` used to split the emoji. + const straddle = 'a'.repeat(59) + 'πŸ˜€tail' + const kept = cleanPublishedName(straddle) + assert.equal(kept.isWellFormed(), true, `the kept name is not well formed: ${JSON.stringify(kept.slice(-2))}`) + assert.equal(kept, 'a'.repeat(59), 'the straddling emoji is dropped whole, never split') + assert.ok(kept.length <= 60, `the cap still holds: ${kept.length}`) + assert.ok(Buffer.from(kept, 'utf8').equals(Buffer.from(straddle, 'utf8').subarray(0, Buffer.byteLength(kept))), 'the kept name is a prefix of the input, byte for byte') +}) + +// #1923: the same bound at the record boundary the name is stored through (hooks.js:947-949). +test('a published peer name, homeName and owner keep whole characters at the 60 bound (#1923)', async (t) => { + const { ctx, fb, stop, grantTo } = await start() + t.after(stop) + await grantTo('a', '@eve.friend.home') + ctx.llm.listProviders = () => [{ id: 'flybot-remote', name: 'Friends\' Bots' }] + ctx.llm.listModels = async () => [{ id: '@eve.friend.home', name: 'Eve' }] + ctx.sessionController.create = async () => ({ sessionId: 'remote-1' }) + const straddle = 'a'.repeat(59) + 'πŸ˜€tail' + const ownerStraddle = 'o'.repeat(59) + 'πŸ˜€tail' + const social = fb.scope('social') + social.remote.publish([{ handle: '@eve.friend.home', name: straddle, home: '@friend.home', homeName: straddle, owner: ownerStraddle }]) + const [member] = social.remote.list() + // All three share cleanPublishedName's 60 bound; tools.js applies the tighter 40 to the owner name it shows. + const cases = [['name', member.name, straddle, 60], ['homeName', member.homeName, straddle, 60], ['owner', member.owner, ownerStraddle, 60]] + for (const [field, kept, source, max] of cases) { + assert.equal(typeof kept, 'string', `${field} is stored: ${JSON.stringify(member)}`) + assert.equal(kept.isWellFormed(), true, `${field} is not well formed: ${JSON.stringify(kept.slice(-2))}`) + assert.ok(kept.length <= max, `${field} keeps the ${max} cap: ${kept.length}`) + assert.ok(kept.length > max - 10, `${field} does not drop the whole name for one character: ${kept.length}`) + assert.equal(kept.includes('πŸ˜€'), false, `${field} drops the straddling emoji whole`) + assert.ok(Buffer.from(kept, 'utf8').equals(Buffer.from(source, 'utf8').subarray(0, Buffer.byteLength(kept))), `${field} is a prefix of the published name, byte for byte`) + } +}) + +// #1923: the group member line (tools.js:84) carries a peer display name at 60 and an owner name at 40. +test('list_bots keeps whole characters in a peer display name and owner name (#1923)', async (t) => { + const displayName = 'a'.repeat(59) + 'πŸ˜€tail' + const ownerName = 'o'.repeat(39) + 'πŸ˜€tail' + const { tool, stop } = await start({ + rooms: { + g: { + id: 'g', name: 'Group', members: ['a'], + participants: [{ kind: 'bot', remote: true, id: '@peer.bob.home', handle: '@peer.bob.home', displayName, ownerName }], + }, + }, + }) + t.after(stop) + const listed = await tool('list_bots', {}, 'a') + assert.equal(listed.isWellFormed(), true, `the member line is not well formed: ${JSON.stringify(listed.slice(0, 80))}`) + const line = listed.split('\n').find(l => l.includes('members: ') && l.includes("'s Bot)")) + assert.ok(line, `the room line is listed: ${JSON.stringify(listed)}`) + // The peer entry is last on the member line: ` ('s Bot)`. + const members = line.split('members: ')[1].split(';')[0] + const peer = members.slice(members.lastIndexOf(', ') + 2) + const atOwner = peer.lastIndexOf(' (') + const keptDisplay = peer.slice(0, atOwner) + const keptOwner = peer.slice(atOwner + 2, peer.lastIndexOf('\'s Bot)')) + assert.equal(keptDisplay.isWellFormed(), true, `the display name is not well formed: ${JSON.stringify(keptDisplay)}`) + assert.equal(keptDisplay.includes('πŸ˜€'), false, 'the display name drops the straddling emoji whole') + assert.ok(keptDisplay.length <= 60 && keptDisplay.length > 50, `the display name keeps the 60 bound: ${keptDisplay.length}`) + assert.ok(Buffer.from(keptDisplay, 'utf8').equals(Buffer.from(displayName, 'utf8').subarray(0, Buffer.byteLength(keptDisplay))), 'the display name is a prefix of what the peer published, byte for byte') + assert.equal(keptOwner.isWellFormed(), true, `the owner name is not well formed: ${JSON.stringify(keptOwner)}`) + assert.equal(keptOwner.includes('πŸ˜€'), false, 'the owner name drops the straddling emoji whole') + assert.ok(keptOwner.length <= 40 && keptOwner.length > 30, `the owner name keeps the 40 bound: ${keptOwner.length}`) + assert.ok(Buffer.from(keptOwner, 'utf8').equals(Buffer.from(ownerName, 'utf8').subarray(0, Buffer.byteLength(keptOwner))), 'the owner name is a prefix of what the peer published, byte for byte') }) test('cleanRemote sanitizes peer name, homeName and owner at the record boundary (#75)', async (t) => { diff --git a/patches/ds-bot/files/test/reach-policy.test.js b/patches/ds-bot/files/test/reach-policy.test.js index ec9fee7d6..f8effc48b 100644 --- a/patches/ds-bot/files/test/reach-policy.test.js +++ b/patches/ds-bot/files/test/reach-policy.test.js @@ -6,7 +6,7 @@ import { join } from 'node:path' import { test } from 'node:test' import { createRuntime, provideFlyBot } from '../index.js' import { fakeContext } from './fake-ctx.js' -import { hasAnyGrant, hasGrant, reach } from '../src/host/reach.js' +import { hasAnyGrant, hasGrant, othersInRoom, reach } from '../src/host/reach.js' const bot = (id, ownerId, extra = {}) => ({ id, sessionId: id, name: id.toUpperCase(), instructions: '', createdAt: 1, ownerId, ...extra }) @@ -375,3 +375,20 @@ test('replacing the designated Chief does not grant the new one', async (t) => { assert.equal(fb.bot.hasAnyGrant('a'), false) assert.deepEqual(events, []) }) + +// #1923: `othersInRoom` labels a friend's Bot for the model, so the 60 bound must not split a character. +test('a peer label for a room is cut on a code point boundary (#1923)', () => { + const straddle = 'a'.repeat(59) + 'πŸ˜€tail' + const state = team() + state.rooms = { + r: { + id: 'r', members: ['a'], + participants: [{ kind: 'bot', remote: true, id: '@peer.bob.home', handle: '@peer.bob.home', displayName: straddle }], + }, + } + const [label] = othersInRoom(state, state.rooms.r, state.bots.a) + assert.equal(label.isWellFormed(), true, `the label is not well formed: ${JSON.stringify(label.slice(-2))}`) + assert.equal(label.includes('πŸ˜€'), false, 'the straddling emoji is dropped whole') + assert.ok(label.length <= 60 && label.length > 50, `the label keeps the 60 bound: ${label.length}`) + assert.ok(Buffer.from(label, 'utf8').equals(Buffer.from(straddle, 'utf8').subarray(0, Buffer.byteLength(label))), 'the label is a prefix of the published name, byte for byte') +}) diff --git a/patches/ds-bot/files/test/remote-room.test.js b/patches/ds-bot/files/test/remote-room.test.js index 5125be246..7eea3ff08 100644 --- a/patches/ds-bot/files/test/remote-room.test.js +++ b/patches/ds-bot/files/test/remote-room.test.js @@ -863,6 +863,33 @@ test('room-share envelope round-trips join, line and leave', () => { assert.equal(parseRoomShare('hello there'), undefined) }) +// #1923: these fields cross an owner boundary, so the envelope's own bounds must never split a character. +test('a room-share envelope clips peer names on a code point boundary (#1923)', () => { + const name = 'a'.repeat(59) + 'πŸ˜€tail' + const handle = '@' + 'b'.repeat(118) + 'πŸ˜€tail' + const packed = encodeRoomShare({ + op: 'join', roomId: 'war', name, inviterName: name, + inviterHome: '@alice.home', inviterHandle: '@chief.alice.home', invitedHandle: '@coder.bob.home', seq: 1, + roster: [{ handle, name }], + }) + const parsed = parseRoomShare(packed) + assert.equal(parsed.op, 'join') + const cases = [ + ['name', parsed.name, name, 60], + ['inviterName', parsed.inviterName, name, 60], + ['roster[0].handle', parsed.roster[0].handle, handle, 120], + ['roster[0].name', parsed.roster[0].name, name, 60], + ] + for (const [field, kept, source, max] of cases) { + assert.equal(typeof kept, 'string', `${field} is in the envelope: ${JSON.stringify(parsed)}`) + assert.equal(kept.isWellFormed(), true, `${field} is not well formed: ${JSON.stringify(kept.slice(-2))}`) + assert.equal(kept.includes('πŸ˜€'), false, `${field} drops the straddling emoji whole`) + assert.ok(kept.length <= max, `${field} keeps the ${max} bound: ${kept.length}`) + assert.ok(kept.length > max - 10, `${field} does not drop the whole field for one character: ${kept.length}`) + assert.ok(Buffer.from(kept, 'utf8').equals(Buffer.from(source, 'utf8').subarray(0, Buffer.byteLength(kept))), `${field} is a prefix of what the sender meant, byte for byte`) + } +}) + test('applyInboundRoomShare projects a join with no chat line, then a leave hides it', async (t) => { const { rt } = host(t, TEAM) await rt.load()