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/2692-identity-sites.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A sibling Bot name, a member's role label and a memory topic written in either Unicode canonical form are now one identity each: `create_child_bot` refuses a child whose name only spells the sibling's name the other way, a queued task card reaches the member whose role label is the same role, and one topic is one file — a file written under an older spelling is still the topic's file, so nothing already saved is lost ([#2692](https://github.com/ThinkFlowLab/FlyBot/issues/2692)).
7 changes: 5 additions & 2 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -8729,6 +8729,9 @@ body[data-ds-dark-theme] .bt-mw-ask{color:#f0b84a}
@media (prefers-reduced-motion:reduce){.bt-mw-row{animation:none}}
`;

// src/shared/identity.js
var nameKey = (value) => stripInvisible(String(value ?? "").normalize("NFC")).trim().toLowerCase().normalize("NFC");

// src/shared/task-board.js
var COLUMNS = ["not-started", "in-progress", "done"];
var PRIORITIES = ["urgent", "high", "normal", "low"];
Expand Down Expand Up @@ -8767,8 +8770,8 @@ function compareForWork(a, b) {
return (b?.updatedAt ?? 0) - (a?.updatedAt ?? 0);
}
function sameRole(a, b) {
const left = String(a ?? "").trim().toLowerCase();
const right = String(b ?? "").trim().toLowerCase();
const left = nameKey(a);
const right = nameKey(b);
return left !== "" && left === right;
}
function compareForQueue(a, b) {
Expand Down
32 changes: 23 additions & 9 deletions core/src/host/memory.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { currentParticipants } from './room-membership.js'
import { defineTool } from './tool-schema.js'
import { clip, codePoints, splitRef, unquote } from './text.js'
import { cut, fits } from '../shared/text-boundary.js'
import { nameKey } from '../shared/identity.js'
import { output } from './tools.js'
import { retiredBot } from './store.js'
import { botNode, buildGraph, buildIndex, relatedOf, searchIndex, topicNode } from './memory-search.js'
Expand Down Expand Up @@ -106,9 +107,11 @@ export function cleanEntry(raw) {
return splitMeta(line).text
}

// A flat file name: case folds on some file systems, so names are lower case.
// A flat file name: case folds on some file systems, so names are lower case. Since #2692 the name
// is also the shared name key, so canonical equivalents are one topic and one file, and a
// decomposed Latin topic (refused while U+0301 was not a letter) is accepted as its NFC form.
export function cleanTopic(raw) {
const name = String(raw ?? '').trim().replace(/^\[\[|\]\]$/g, '').replace(/\.md$/i, '').trim().toLowerCase().replace(/\s+/g, '-')
const name = nameKey(raw).replace(/^\[\[|\]\]$/g, '').replace(/\.md$/i, '').replace(/\s+/g, '-')
return /^[\p{L}\p{N}][\p{L}\p{N}_-]{0,47}$/u.test(name) && name !== 'memory' ? name : undefined
}

Expand Down Expand Up @@ -511,23 +514,31 @@ export function install(rt) {
return ''
}
}
const topicNamesIn = (dir) => {
// A topic file's name is the topic, but not always the key: a file written before #2692 keeps the
// spelling it was written with (a decomposed Hangul syllable among them), so the topic is the key
// computed from that name and the name itself is still the file recall reads and remember
// appends to. When both spellings exist, the one already in key form wins.
const topicFilesIn = (dir) => {
try {
return readdirSync(dir).filter(name => name.endsWith('.md') && name !== MAIN).map(name => name.slice(0, -3)).sort()
const byTopic = new Map()
for (const file of readdirSync(dir).filter(name => name.endsWith('.md') && name !== MAIN).sort()) {
const topic = nameKey(file.slice(0, -3))
if (!byTopic.has(topic) || file === `${topic}.md`) byTopic.set(topic, file)
}
return [...byTopic].map(([topic, file]) => ({ topic, file }))
} catch {
return []
}
}
const topicNames = (scope) => {
const dir = scopeDir(scope)
return dir === undefined ? [] : topicNamesIn(dir)
return dir === undefined ? [] : topicFilesIn(dir).map(file => file.topic)
}
// MEMORY.md first, then the topics by name.
const loadScope = (scope) => {
const dir = dirOf(scope)
const open = scopeDir(scope) !== undefined
return [undefined, ...(open ? topicNamesIn(dir) : [])].map((topic) => {
const file = topic === undefined ? MAIN : `${topic}.md`
return [{ topic: undefined, file: MAIN }, ...(open ? topicFilesIn(dir) : [])].map(({ topic, file }) => {
const content = open ? readText(join(dir, file)) : ''
return { topic, file, path: join(dir, file), content, ...parseMemory(content) }
})
Expand Down Expand Up @@ -690,7 +701,7 @@ export function install(rt) {
const next = joinLines(insertEntry(base, line))
checkSize(target, next, label)
let index
if (topic !== undefined && !main.links.includes(topic)) {
if (topic !== undefined && !main.links.some(link => nameKey(link) === topic)) {
index = joinLines(addLink(main.lines.length > 0 ? main.lines : parseMemory(EMPTY_MAIN).lines, topic))
checkSize(main, index, label)
}
Expand Down Expand Up @@ -745,7 +756,10 @@ export function install(rt) {
if (empty) {
await unlink(old.file.path).catch(error => { if (error?.code !== 'ENOENT') throw error })
const main = files[0]
if (main.links.includes(old.file.topic)) await writeText(main.path, joinLines(removeLink(main.lines, old.file.topic)))
// The link was written with whatever spelling the topic had then; remove that one, not a
// second entry the key would otherwise leave behind (#2692).
const link = main.links.find(one => nameKey(one) === old.file.topic)
if (link !== undefined) await writeText(main.path, joinLines(removeLink(main.lines, link)))
} else {
await writeText(old.file.path, joinLines(lines))
}
Expand Down
8 changes: 4 additions & 4 deletions core/src/host/text.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// cutting and estimating text, and the clock format of chat anchors.
import { COLORS, EXTRA_COLORS, LEGACY_COLORS, OWN_CHAT_LINE_MAX } from './constants.js'
import { codePointEnd, codePointStart, cut, fits } from '../shared/text-boundary.js'
import { nameKey } from '../shared/identity.js'
import { stripInvisible } from '../shared/mentions.js'

export { cut, stripInvisible }
Expand All @@ -21,10 +22,9 @@ export const textOf = content => (Array.isArray(content) ? content : [])
.join('\n')
.trim()

// The identity key for a name (#2691): both canonical spellings (NFC and NFD) of one
// visible name are one key, and so are the invisible characters `trim()` keeps. The NFC
// pass runs again after lowercasing because a lower can compose a new sequence (U+0130).
export const normalize = value => stripInvisible(String(value ?? '').normalize('NFC')).trim().toLowerCase().normalize('NFC')
// The identity key for a name (#2691, #2692): one function in `shared/identity.js`, so the tree's
// sibling names, a role label and a memory topic compare by the same rule as a Bot name does.
export const normalize = nameKey

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

Expand Down
7 changes: 7 additions & 0 deletions core/src/shared/identity.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// The identity key for a visible name (#2691, #2692), in one place so the host, a shared module and
// a name that is also a file name cannot drift apart. Both canonical spellings (NFC and NFD) of one
// visible name are one key, and so are the invisible format characters `trim()` keeps. The NFC pass
// runs again after lowercasing because a lower can compose a new sequence (U+0130).
import { stripInvisible } from './mentions.js'

export const nameKey = value => stripInvisible(String(value ?? '').normalize('NFC')).trim().toLowerCase().normalize('NFC')
7 changes: 5 additions & 2 deletions core/src/shared/task-board.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Board columns for the task records in src/host/tasks.js (#1058).
// The state machine stays the record. A column is how the board reads it.
// result-ready still waits for acceptance, so it stays In progress.
import { nameKey } from './identity.js'

export const COLUMNS = ['not-started', 'in-progress', 'done']
export const PRIORITIES = ['urgent', 'high', 'normal', 'low']
Expand Down Expand Up @@ -54,9 +55,11 @@ export function compareForWork(a, b) {

// A role label, matched the way a person reads it (#1247): free text, trimmed, case-insensitive.
// The label is not a membership role (`admin` / `member`), so nothing here reads that field.
// Since #2692 the comparison is the shared name key, so either canonical spelling of one visible
// label is one role and a queued card reaches the carrier that spells it the other way.
export function sameRole(a, b) {
const left = String(a ?? '').trim().toLowerCase()
const right = String(b ?? '').trim().toLowerCase()
const left = nameKey(a)
const right = nameKey(b)
return left !== '' && left === right
}

Expand Down
19 changes: 18 additions & 1 deletion core/test/bot-tasks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test } from 'node:test'
import { apply } from '../index.js'
import { columnOf, occupiesSlot } from '../src/shared/task-board.js'
import { columnOf, occupiesSlot, sameRole } from '../src/shared/task-board.js'
import { fakeContext } from './fake-ctx.js'

const team = () => ({
Expand Down Expand Up @@ -681,3 +681,20 @@ test('only a claimed or working card occupies the member\'s WIP slot (#1744)', (
assert.equal(occupiesSlot({}), false)
assert.equal(columnOf({ status: 'result-ready' }), 'in-progress', 'the board reading is unchanged')
})

// #2692: a role label is one identity whichever canonical spelling it is written in, so a queued
// card reaches the member whose label spells the same role instead of staying in the pool forever.
test('#2692 a role label is one identity whichever canonical spelling it uses', () => {
const nfc = 'Caf\u00e9'
const nfd = 'Cafe\u0301'
assert.equal(sameRole(nfc, nfd), true)
assert.equal(sameRole(nfd, nfc), true)
// the folded spelling is the one the tree plugin hands out as a role label
assert.equal(sameRole(` ${nfd} `, nfc), true)
// the invisible format characters trim() keeps are part of the same identity
assert.equal(sameRole(`${nfd}\u200b`, nfc), true)
// a different role still is a different role, and an empty label still matches nothing
assert.equal(sameRole(nfd, 'Caf\u00e9 writer'), false)
assert.equal(sameRole('', ''), false)
assert.equal(sameRole(undefined, nfc), false)
})
33 changes: 33 additions & 0 deletions core/test/memory.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,39 @@ test('topics hold details, linked from MEMORY.md, and go when their last entry d
assert.equal(read('bots', 'b2', 'MEMORY.md'), '# Memory\n\n## Index\n')
})

// #2692: a topic is one identity and one file name. Canonically equivalent spellings used to be
// two topics writing two files, so one topic split into two memories. A decomposed Latin topic is
// refused by the \p{L} rule, so the collision needs a script whose decomposition stays letters:
// the Hangul syllable 가 (U+AC00) and U+1100 U+1161.
test('#2692 a topic is one identity whichever canonical spelling it is written in', async (t) => {
const { tool, read, stop, home } = start()
t.after(() => stop())
assert.equal(cleanTopic('\uac00'), cleanTopic('\u1100\u1161'))
assert.equal(cleanTopic('\u1100\u1161'), '\uac00', 'the file stem is the canonical form')
await tool('remember', { text: 'Flies from Hangzhou', topic: '\uac00' }, 'b2')
// the other spelling names the same topic, and reads the same file
assert.match(await tool('recall', { topic: '\u1100\u1161' }, 'b2'), /^Topic \[\[\uac00\]\] in your memory \(\uac00\.md: \d+ of 16000 characters\):\n# \uac00\n\n- Flies from Hangzhou/)
assert.match(await tool('remember', { text: 'Prefers window seats', topic: '\u1100\u1161' }, 'b2'), /in topic \[\[\uac00\]\] \(\uac00\.md: \d+ of 16000 characters\)/)
assert.deepEqual(readdirSync(join(home, 'memory', 'bots', 'b2')).sort(), ['MEMORY.md', '\uac00.md'])
assert.equal(read('bots', 'b2', 'MEMORY.md'), '# Memory\n\n## Index\n- [[\uac00]]\n')
})

// A topic file written before this change keeps the spelling it was written with. The key is
// computed from the name, so the file is still the one recall reads and remember appends to.
test('#2692 a topic saved under another spelling before this change is still the file it names', async (t) => {
const { tool, read, stop, home } = start()
t.after(() => stop())
const dir = join(home, 'memory', 'bots', 'b2')
mkdirSync(dir, { recursive: true })
writeFileSync(join(dir, 'MEMORY.md'), '# Memory\n\n## Index\n- [[\u1100\u1161]]\n')
writeFileSync(join(dir, '\u1100\u1161.md'), '# legs\n\n- Saved the old way [added: 2026-01-01]\n')
assert.match(await tool('recall', { topic: '\uac00' }, 'b2'), /^Topic \[\[\uac00\]\] in your memory \(\u1100\u1161\.md: \d+ of 16000 characters\):\n# legs/)
assert.match(await tool('remember', { text: 'Added after the fix', topic: '\uac00' }, 'b2'), /in topic \[\[\uac00\]\]/)
assert.deepEqual(readdirSync(dir).sort(), ['MEMORY.md', '\u1100\u1161.md'], 'no second file for the same topic')
assert.match(read('bots', 'b2', '\u1100\u1161.md'), /- Added after the fix \[/)
assert.equal(read('bots', 'b2', 'MEMORY.md'), '# Memory\n\n## Index\n- [[\u1100\u1161]]\n', 'the link it already had is not written twice')
})

test('Main Bots and group admins write the team memory; only Main Bots touch another Bot\'s', async (t) => {
const { tool, read, stop } = start()
t.after(() => stop())
Expand Down
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 bundle also carries the `core/src/shared/` modules the client imports — `mentions.js` (the `@Name` rule, `nameProblem` and `stripInvisible`), `text-boundary.js`, `remote-label.js`, `approval-waiting.js`, `room-share.js` among them — so a change in one of those rebuilds `client.js` too even when no file under `core/src/client/` was edited; the rules themselves are documented on [core-host](core-host.md#key-files).
The browser side of the patched `ds-bot` core (`core/`): sources in `core/src/client/`, built into the committed bundle `core/client.js`. Split out of [ds-bot-core](ds-bot-core.md) so a host-only change does not have to edit this page. `client.js` is generated: never hand-edit it, run `npm run build` in `core/` and commit both. The bundle also carries the `core/src/shared/` modules the client imports — `mentions.js` (the `@Name` rule, `nameProblem` and `stripInvisible`), `text-boundary.js`, `remote-label.js`, `approval-waiting.js`, `room-share.js`, `identity.js` (the name key `nameKey`, #2692, which `task-board.js` compares a role label with) among them — so a change in one of those rebuilds `client.js` too even when no file under `core/src/client/` was edited; the rules themselves are documented on [core-host](core-host.md#key-files).

## Owns

Expand Down
Loading
Loading