Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/2691-name-identity.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A Bot or group-chat name written in either Unicode canonical form, or carrying an invisible format character such as a zero-width space, is now one identity: the duplicate guard refuses the twin, `@Name` and every lookup reach the same record, and a stored name keeps only the characters that show on screen ([#2691](https://github.com/ThinkFlowLab/FlyBot/issues/2691)).
3 changes: 2 additions & 1 deletion core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -2412,8 +2412,9 @@ var ADDRESS_BEFORE = /[a-z0-9_./]/i;
var LITERAL = /```[\s\S]*?(?:```|$)|`[^`\n]*`|\[[^\]\n]*\]\([^)\s]*(?:\s+(?:"[^"\n]*"|'[^'\n]*'))?\)/g;
var fitsAt = (text2, at, word) => text2.slice(at, at + word.length).toLowerCase() === word.toLowerCase() && !NAME_GOES_ON.test(text2.slice(at + word.length));
var overlaps = (spans, start, end) => spans.some((span) => start < span.end && span.start < end);
var stripInvisible = (value) => String(value ?? "").replace(/\p{Cf}/gu, "");
function nameProblem(name) {
const text2 = String(name ?? "").trim();
const text2 = stripInvisible(name).trim();
if (text2 === "") return "A Bot needs a name";
if (/[@`\r\n]/.test(text2)) return "A Bot name cannot contain @, ` or a line break";
if (ALL_WORDS.includes(text2.toLowerCase())) return `"${text2}" calls everyone in a group, so it cannot be a Bot name`;
Expand Down
10 changes: 6 additions & 4 deletions core/src/host/roster.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { mkdir } from 'node:fs/promises'
import { join } from 'node:path'
import { MAX_AVATAR_IMAGE, ROOM_MAX_MEMBERS, ROOM_MODES, ROOM_NAME_MAX, ROOM_NOTICE_MAX } from './constants.js'
import { currentColor, cut, isColor, unquote } from './text.js'
import { currentColor, cut, isColor, stripInvisible, unquote } from './text.js'
import { nameProblem } from '../shared/mentions.js'
import { cleanExternal } from './room-participant-external.js'
import { currentParticipants, implicitRoomOwner, syncMembers } from './room-membership.js'
Expand Down Expand Up @@ -62,7 +62,9 @@ export function install(rt) {
// `pickModel` leaves the model to the user: the chat opens on a model card. So does a
// Bot made when no model is served.
async function createBot({ name: botName, role = '', instructions = '', color, brief = '', createdBy, main = false, avatar, model, effort, workDir, pickModel = false, systemPrompt, fields, remote, external, ownerId: explicitOwnerId, hidden, ephemeral }) {
const cleanName = cut(String(botName ?? '').trim(), 40).text
// The stored name is the visible one: the invisible characters a name can carry would
// make two Bots read the same on screen while the key still tells them apart (#2691).
const cleanName = cut(stripInvisible(String(botName ?? '')).trim(), 40).text
// A friend's Bot (remote-chat.js) keeps its own name: it is not a team member, so
// team names never clash with it and creating it starts no turn.
const isRemote = typeof remote?.handle === 'string'
Expand Down Expand Up @@ -190,7 +192,7 @@ export function install(rt) {
const moving = patch.workDir !== undefined
const workDir = moving ? await rt.cleanWorkDir(patch.workDir ?? '') : undefined
if (patch.name !== undefined) {
const next = cut(String(patch.name).trim(), 40).text
const next = cut(stripInvisible(String(patch.name)).trim(), 40).text
const clash = rt.findBot(next)
if (next === '' || (clash && clash.id !== id)) throw new Error(`Name ${next} is not available`)
// A name saved before the rule stays usable until it changes.
Expand Down Expand Up @@ -277,7 +279,7 @@ export function install(rt) {
const cleanNotice = text => cut(String(text ?? '').trim(), ROOM_NOTICE_MAX).text

function cleanRoomName(text, selfId) {
const next = cut(unquote(text).replace(/\s+/g, ' '), ROOM_NAME_MAX).text
const next = cut(stripInvisible(unquote(text)).replace(/\s+/g, ' '), ROOM_NAME_MAX).text
if (next !== '' && rt.findRooms(next).some(room => room.id !== selfId)) throw new Error(`Another group chat is already called "${next}"`)
return next
}
Expand Down
8 changes: 6 additions & 2 deletions core/src/host/text.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
// cutting and estimating text, and the clock format of chat anchors.
import { COLORS, EXTRA_COLORS, LEGACY_COLORS, OWN_CHAT_LINE_MAX } from './constants.js'
import { codePointEnd, codePointStart, cut, fits } from '../shared/text-boundary.js'
import { stripInvisible } from '../shared/mentions.js'

export { cut }
export { cut, stripInvisible }

// A color id saved under its legacy name reads as the current one.
export const currentColor = color => (typeof color === 'string' && Object.hasOwn(LEGACY_COLORS, color) ? LEGACY_COLORS[color] : color)
Expand All @@ -20,7 +21,10 @@ export const textOf = content => (Array.isArray(content) ? content : [])
.join('\n')
.trim()

export const normalize = value => String(value ?? '').trim().toLowerCase()
// The identity key for a name (#2691): both canonical spellings (NFC and NFD) of one
// visible name are one key, and so are the invisible characters `trim()` keeps. The NFC
// pass runs again after lowercasing because a lower can compose a new sequence (U+0130).
export const normalize = value => stripInvisible(String(value ?? '').normalize('NFC')).trim().toLowerCase().normalize('NFC')

export const unquote = text => String(text ?? '').trim().replace(/^["'“”「『《]+|["'“”」』》]+$/g, '').trim()

Expand Down
11 changes: 9 additions & 2 deletions core/src/shared/mentions.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,16 @@ const fitsAt = (text, at, word) => text.slice(at, at + word.length).toLowerCase(
&& !NAME_GOES_ON.test(text.slice(at + word.length))
const overlaps = (spans, start, end) => spans.some(span => start < span.end && span.start < end)

// What a Bot may be named: something "@" can call without a second reading.
// A name can carry characters that cannot be seen: the Unicode format characters (Cf) —
// zero-width space / non-joiner / joiner, the byte-order mark, soft hyphen, the bidi
// controls — and `trim()` removes no character that is not White_Space. The identity key
// (#2691) drops them, and a stored name drops them too, so what is saved is what is shown.
export const stripInvisible = value => String(value ?? '').replace(/\p{Cf}/gu, '')

// What a Bot may be named: something "@" can call without a second reading. A name made
// only of invisible characters reads as no name at all.
export function nameProblem(name) {
const text = String(name ?? '').trim()
const text = stripInvisible(name).trim()
if (text === '') return 'A Bot needs a name'
if (/[@`\r\n]/.test(text)) return 'A Bot name cannot contain @, ` or a line break'
if (ALL_WORDS.includes(text.toLowerCase())) return `"${text}" calls everyone in a group, so it cannot be a Bot name`
Expand Down
137 changes: 137 additions & 0 deletions core/test/name-identity.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// #2691: one visible name is one identity. The key folds canonically equivalent spellings
// (NFC/NFD) and drops the invisible format characters a name can carry, and the guards that
// admit a Bot or a room use the same key as the lookup. Fake data in a temp home only.
import assert from 'node:assert/strict'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test } from 'node:test'
import { createRuntime } from '../index.js'
import { nameProblem } from '../src/shared/mentions.js'
import { normalize } from '../src/host/text.js'
import { fakeContext } from './fake-ctx.js'

const TEAM = () => ({
version: 1,
mainBotIds: ['chief'],
bots: {
chief: { id: 'chief', sessionId: 'chief', name: 'Chief', role: 'Lead', instructions: '', createdAt: 1 },
cafe: { id: 'cafe', sessionId: 'cafe', name: 'Caf\u00e9', role: '', instructions: '', createdAt: 2 },
dev: { id: 'dev', sessionId: 'dev', name: 'Dev', role: '', instructions: '', createdAt: 3 },
ops: { id: 'ops', sessionId: 'ops', name: 'Ops', role: '', instructions: '', createdAt: 4 },
},
rooms: {
room: {
id: 'room', name: 'Caf\u00e9', named: true, members: ['chief', 'dev'], admin: 'chief', mode: 'everyone',
seq: 0, log: [],
},
},
exchanges: [],
exchangeTurns: {},
questions: {},
revision: 1,
})

function setup(t, seed = TEAM()) {
const home = mkdtempSync(join(tmpdir(), 'ds-bot-name-identity-'))
writeFileSync(join(home, 'state.json'), JSON.stringify(seed))
let sessions = 0
const agents = new Map()
const ctx = fakeContext()
const agentFor = async (id) => {
if (!agents.has(id)) agents.set(id, { id, session: { id, seq: 1 }, followup: () => {}, ctx: { tools: { restrict: () => () => {} }, effect: execute => execute() } })
return agents.get(id)
}
Object.assign(ctx, {
llm: {
listProviders: () => [{ id: 'deepseek', name: 'DeepSeek' }],
listModels: async () => [{ id: 'deepseek-v4-flash' }],
resolveModelInfo: async () => ({}),
},
workspaceRegistry: {
get: () => undefined,
resolveByPath: async () => undefined,
create: async path => ({ id: 'w1', path }),
archiveSession: async () => {},
},
sessionController: {
create: async () => ({ sessionId: `new${++sessions}` }),
resolveAgent: async id => ({ agent: await agentFor(id) }),
selectModel: async () => {},
},
sessionTitle: { rename: async () => {} },
sessions: { flush: async () => {} },
})
const rt = createRuntime(ctx, { home, github: false, diagnosticsFile: false })
t.after(async () => {
await ctx.dispose()
rmSync(home, { recursive: true, force: true, maxRetries: 5 })
})
const state = () => JSON.parse(readFileSync(join(home, 'state.json'), 'utf8'))
return { ctx, rt, state, home }
}

test('#2691 the name key folds canonical spellings and drops invisible format characters', () => {
const nfc = 'Caf\u00e9'
const nfd = 'Cafe\u0301'
assert.notEqual(nfc, nfd)
assert.equal(normalize(nfd), normalize(nfc))
// The invisible spellings a name can carry: zero-width space / non-joiner / joiner,
// byte-order mark, soft hyphen, word joiner.
for (const hidden of ['Dev\u200b', 'De\u200cv', 'D\u200dev', '\ufeffDev', 'Dev\u00ad', '\u2060Dev']) {
assert.equal(normalize(hidden), 'dev', JSON.stringify([...hidden].map(c => c.codePointAt(0))))
}
assert.equal(normalize(' Dev\u200b '), 'dev', 'invisible characters go before trim, so the space they hid is trimmed')
// Lowercasing can compose a new sequence (U+0130), so the NFC pass runs once more after it.
assert.equal(normalize('\u0130lker'), normalize('i\u0307lker'))
assert.equal(normalize(null), '')
})

test('#2691 a name of only invisible characters is refused as empty', () => {
assert.equal(nameProblem('\u200b\u200c\u200d\ufeff\u00ad'), 'A Bot needs a name')
assert.equal(nameProblem(' \u200b '), 'A Bot needs a name')
// A visible name with an invisible character is a valid name; the stored name drops the character.
assert.equal(nameProblem('Dev\u200b'), undefined)
})

test('#2691 a stored name is found by either canonical spelling or with an invisible character', async (t) => {
const { rt } = setup(t)
await rt.load()
assert.equal(rt.findBot('Caf\u00e9')?.id, 'cafe')
assert.equal(rt.findBot('Cafe\u0301')?.id, 'cafe', 'NFD spelling of the stored NFC name')
assert.equal(rt.findBot('Dev\u200b')?.id, 'dev', 'the stored name with a zero-width space')
assert.deepEqual(rt.findRooms('Cafe\u0301').map(room => room.id), ['room'])
})

test('#2691 a name stored before this change still resolves by its NFC spelling', async (t) => {
const seed = TEAM()
seed.bots.cafe.name = 'Cafe\u0301'
seed.rooms.room.name = 'Cafe\u0301'
const { rt, state } = setup(t, seed)
await rt.load()
assert.equal(rt.findBot('Caf\u00e9')?.id, 'cafe')
assert.deepEqual(rt.findRooms('Caf\u00e9').map(room => room.id), ['room'])
assert.equal(state().bots.cafe.name, 'Cafe\u0301', 'the stored name is left exactly as it was')
})

test('#2691 the duplicate guard refuses a second Bot and a second room with the same key', async (t) => {
const { rt } = setup(t)
await rt.load()
await assert.rejects(rt.createBot({ name: 'Cafe\u0301', pickModel: true }), /already exists/)
await assert.rejects(rt.createBot({ name: 'Dev\u200b', pickModel: true }), /already exists/)
await assert.rejects(rt.createRoom({ name: 'Cafe\u0301', members: ['chief', 'dev'] }), /already called/)
})

test('#2691 a name is stored without the invisible characters it was written with', async (t) => {
const { rt, state } = setup(t)
await rt.load()
const created = await rt.createBot({ name: 'Ne\u200bw', pickModel: true })
assert.equal(created.name, 'New')
assert.equal(state().bots[created.id].name, 'New')
await rt.updateBot('dev', { name: 'Dev\u200bO\u200bps' })
assert.equal(state().bots.dev.name, 'DevOps')
const room = await rt.createRoom({ name: 'De\u200bsk', members: ['chief', 'dev'] })
assert.equal(room.name, 'Desk')
await rt.updateRoom(room.id, { name: 'Desk\u200b 2' })
assert.equal(state().rooms[room.id].name, 'Desk 2')
})
2 changes: 1 addition & 1 deletion docs/subsystems/core-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ owned_paths:

# core-client

The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/client/`, built into the committed bundle `core/client.js`. Split out of [ds-bot-core](ds-bot-core.md) so a host-only change does not have to edit this page. `client.js` is generated: never hand-edit it, run `npm run build` in `core/` and commit both.
The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/client/`, built into the committed bundle `core/client.js`. Split out of [ds-bot-core](ds-bot-core.md) so a host-only change does not have to edit this page. `client.js` is generated: never hand-edit it, run `npm run build` in `core/` and commit both. The bundle also carries the `core/src/shared/` modules the client imports — `mentions.js` (the `@Name` rule, `nameProblem` and `stripInvisible`), `text-boundary.js`, `remote-label.js`, `approval-waiting.js`, `room-share.js` among them — so a change in one of those rebuilds `client.js` too even when no file under `core/src/client/` was edited; the rules themselves are documented on [core-host](core-host.md#key-files).

## Owns

Expand Down
Loading
Loading