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
25 changes: 19 additions & 6 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -1765,6 +1765,19 @@ function mentionTarget(event) {
const anchor = event.target instanceof Element ? event.target.closest(`a[href^="${MENTION_ORIGIN}"]`) : null;
return anchor ? anchor.getAttribute("href").slice(MENTION_ORIGIN.length) : null;
}
function nameMentions(text, roster) {
const value = String(text ?? "");
if (!value.includes("@") || roster === null || typeof roster !== "object") return value;
const names = /* @__PURE__ */ new Map();
for (const record of [...Array.isArray(roster.remoteBots) ? roster.remoteBots : [], ...Array.isArray(roster.bots) ? roster.bots : []]) {
const handle = typeof record?.remote === "object" && record.remote !== null ? record.remote.handle : record?.handle;
if (typeof handle === "string" && typeof record?.name === "string" && record.name !== "") names.set(handle.replace(/^@+/, "").toLowerCase(), record.name);
}
const handles = [...names.keys()].filter(Boolean).sort((a, b) => b.length - a.length);
if (handles.length === 0) return value;
const source = handles.map((handle) => handle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
return value.replace(new RegExp(`(^|[^a-z0-9_./])@(${source})(?![a-z0-9_\\-/\\\\])(?!\\.[a-z0-9_-])`, "gi"), (_all, before, handle) => `${before}@${names.get(handle.toLowerCase())}`);
}
function clock(ms) {
return new Date(ms).toLocaleTimeString(dateLocale(), { hour: "numeric", minute: "2-digit" });
}
Expand Down Expand Up @@ -5138,7 +5151,7 @@ var workLine = (lead, tool, more) => (0, import_react9.createElement)(Shimmer, n
function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useActivity, useWork, wide, marked, asRow = false }) {
const id = bot.id;
const part = currentPart(bot);
const preview = useSessions((list) => previewOf(list, part, roster.exchangeTurns[part]));
const preview = useSessions((list) => nameMentions(previewOf(list, part, roster.exchangeTurns[part]), roster));
const running = useSessionStatus((map) => map.get(part)?.running === true);
const active = useSessionRetainInfo(part, (info) => (info?.retainedBy?.mainView ?? 0) > 0);
const unread = useSessionStatus((map) => map.get(part)?.completionUnread === true || map.get(part)?.pendingInteraction !== void 0) || marked && !active;
Expand Down Expand Up @@ -5214,8 +5227,8 @@ function RemoteMark({ remote }) {
}
function RoomRow({ room: room2, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useWork, wide, marked }) {
const id = room2.id;
const fallback = useSessions((list) => previewOf(list, id));
const preview = room2.preview?.replace(/\s+/g, " ").trim() || fallback;
const fallback = useSessions((list) => nameMentions(previewOf(list, id), roster));
const preview = nameMentions(room2.preview?.replace(/\s+/g, " ").trim(), roster) || fallback;
const active = useSessionRetainInfo(id, (info) => (info?.retainedBy?.mainView ?? 0) > 0);
const unread = useSessionStatus((map) => map.get(id)?.completionUnread === true) || marked && !active;
useMarkedUnread(id, active, marked, actions);
Expand Down Expand Up @@ -13559,8 +13572,8 @@ function contentParts(content) {
}
function mentionableBots(roster, sessionId) {
const room2 = roster.roomsById[sessionId];
if (room2) return room2.members.map((id) => roster.byId[id]).filter(Boolean);
return roster.byId[sessionId] ? roster.bots : [];
const bots = room2 ? room2.members.map((id) => roster.byId[id]).filter(Boolean) : roster.byId[sessionId] ? roster.bots : [];
return bots.concat(bots.filter((bot) => typeof bot?.remote?.handle === "string").map((bot) => ({ ...bot, name: bot.remote.handle.replace(/^@+/, ""), mentionLabel: bot.name })));
}
function userTextParts(text, bots, referenceLabels = []) {
const parts = [];
Expand All @@ -13579,7 +13592,7 @@ function UserText({ text, bots, referenceLabels, skillNames, references, actions
"button",
{ key: index, type: "button", className: "bt-umention", title: `@${part.bot.name}`, onClick: () => actions.openSession(part.bot.id) },
(0, import_react35.createElement)(BotMark, { bot: part.bot, size: 16 }),
part.label
part.bot.mentionLabel ?? part.label
) : (0, import_react35.createElement)(import_react35.Fragment, { key: index }, ui.projectUserText(part.text, referenceLabels, skillNames, "skill", references)));
}
function Attachments({ attachments, renderMessageImages }) {
Expand Down
8 changes: 4 additions & 4 deletions core/src/client/sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Component, createElement as h, Fragment, useEffect, useLayoutEffect, us
import { createPortal } from 'react-dom'
import { FishLogo } from '@deepseek-ai/dsh-client-ui-primitives'
import { isMainOf } from './sources.js'
import { currentPart, previewOf, relativeShort } from './text.js'
import { currentPart, nameMentions, previewOf, relativeShort } from './text.js'
import { PlusIcon, SearchIcon, GaugeIcon, PlugIcon, ClockIcon, GearIcon, PinMarkIcon, ConnectorIcon, SwitchIcon, CloudDownloadIcon } from './icons.js'
import { MAIN_LOOK } from './characters.js'
import { botState, BotMark, BotAvatar, RoomAvatar } from './mark.js'
Expand Down Expand Up @@ -41,7 +41,7 @@ const workLine = (lead, tool, more) => h(Shimmer, null, `${lead} · ${workPhrase
function BotRow({ bot, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useActivity, useWork, wide, marked, asRow = false }) {
const id = bot.id
const part = currentPart(bot)
const preview = useSessions(list => previewOf(list, part, roster.exchangeTurns[part]))
const preview = useSessions(list => nameMentions(previewOf(list, part, roster.exchangeTurns[part]), roster))
const running = useSessionStatus(map => map.get(part)?.running === true)
const active = useSessionRetainInfo(part, info => (info?.retainedBy?.mainView ?? 0) > 0)
const unread = useSessionStatus(map => map.get(part)?.completionUnread === true || map.get(part)?.pendingInteraction !== undefined) || (marked && !active)
Expand Down Expand Up @@ -93,8 +93,8 @@ function RemoteMark({ remote }) {

function RoomRow({ room, roster, actions, useSessions, useSessionStatus, useSessionRetainInfo, useWork, wide, marked }) {
const id = room.id
const fallback = useSessions(list => previewOf(list, id))
const preview = room.preview?.replace(/\s+/g, ' ').trim() || fallback
const fallback = useSessions(list => nameMentions(previewOf(list, id), roster))
const preview = nameMentions(room.preview?.replace(/\s+/g, ' ').trim(), roster) || fallback
const active = useSessionRetainInfo(id, info => (info?.retainedBy?.mainView ?? 0) > 0)
const unread = useSessionStatus(map => map.get(id)?.completionUnread === true) || (marked && !active)
useMarkedUnread(id, active, marked, actions)
Expand Down
19 changes: 19 additions & 0 deletions core/src/client/text.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,25 @@ export function mentionTarget(event) {
return anchor ? anchor.getAttribute('href').slice(MENTION_ORIGIN.length) : null
}

// A friend's remote Bot is stored and delivered by its handle ("@bot-x.home": what the
// @ menu inserts, what the host matches); the user reads its display name. Previews
// rewrite the handle from the local roster, never from the text, so a remote name
// reaches the UI only through the sanitized roster record. One pass over the text: a
// name that itself contains a handle is never rescanned.
export function nameMentions(text, roster) {
const value = String(text ?? '')
if (!value.includes('@') || roster === null || typeof roster !== 'object') return value
const names = new Map()
for (const record of [...(Array.isArray(roster.remoteBots) ? roster.remoteBots : []), ...(Array.isArray(roster.bots) ? roster.bots : [])]) {
const handle = typeof record?.remote === 'object' && record.remote !== null ? record.remote.handle : record?.handle
if (typeof handle === 'string' && typeof record?.name === 'string' && record.name !== '') names.set(handle.replace(/^@+/, '').toLowerCase(), record.name)
}
const handles = [...names.keys()].filter(Boolean).sort((a, b) => b.length - a.length)
if (handles.length === 0) return value
const source = handles.map(handle => handle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')
return value.replace(new RegExp(`(^|[^a-z0-9_./])@(${source})(?![a-z0-9_\\-/\\\\])(?!\\.[a-z0-9_-])`, 'gi'), (_all, before, handle) => `${before}@${names.get(handle.toLowerCase())}`)
}

function clock(ms) {
return new Date(ms).toLocaleTimeString(dateLocale(), { hour: 'numeric', minute: '2-digit' })
}
Expand Down
11 changes: 8 additions & 3 deletions core/src/client/user-message.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,15 @@ export function contentParts(content) {
}

// In a group only its members are called; elsewhere a mention opens that Bot's chat.
// A friend's remote Bot is also called by its handle — the @ menu inserts "@<handle>"
// and the host delivers on it — so the handle joins as a second name of the same Bot,
// and the chip reads the display name (mentionLabel) from the roster.
export function mentionableBots(roster, sessionId) {
const room = roster.roomsById[sessionId]
if (room) return room.members.map(id => roster.byId[id]).filter(Boolean)
return roster.byId[sessionId] ? roster.bots : []
const bots = room ? room.members.map(id => roster.byId[id]).filter(Boolean)
: roster.byId[sessionId] ? roster.bots : []
return bots.concat(bots.filter(bot => typeof bot?.remote?.handle === 'string')
.map(bot => ({ ...bot, name: bot.remote.handle.replace(/^@+/, ''), mentionLabel: bot.name })))
}

// Runs of plain text and Bot mentions, in order.
Expand All @@ -51,7 +56,7 @@ export function userTextParts(text, bots, referenceLabels = []) {
function UserText({ text, bots, referenceLabels, skillNames, references, actions }) {
return userTextParts(text, bots, referenceLabels).map((part, index) => (part.bot
? h('button', { key: index, type: 'button', className: 'bt-umention', title: `@${part.bot.name}`, onClick: () => actions.openSession(part.bot.id) },
h(BotMark, { bot: part.bot, size: 16 }), part.label)
h(BotMark, { bot: part.bot, size: 16 }), part.bot.mentionLabel ?? part.label)
: h(Fragment, { key: index }, ui.projectUserText(part.text, referenceLabels, skillNames, 'skill', references))))
}

Expand Down
23 changes: 21 additions & 2 deletions core/test/inbound-ui.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { build } from 'esbuild'
// The inbound friend conversation's display: trace lines, the thinking row, the remote speaker.
const { outputFiles } = await build({
stdin: {
contents: "export { stripTrace, previewOf } from './src/client/text.js'\nexport { remoteSpeaker } from './src/client/cells.js'",
contents: "export { stripTrace, previewOf, nameMentions } from './src/client/text.js'\nexport { remoteSpeaker } from './src/client/cells.js'",
resolveDir: fileURLToPath(new URL('..', import.meta.url)),
},
bundle: true, format: 'cjs', platform: 'browser', write: false, logLevel: 'silent',
Expand All @@ -25,7 +25,7 @@ runInNewContext(outputFiles[0].text, {
throw new Error(`Unexpected platform module ${name}`)
},
})
const { stripTrace, previewOf, remoteSpeaker } = module.exports
const { stripTrace, previewOf, nameMentions, remoteSpeaker } = module.exports

test('a reply opening with a delegation trace shows without it', () => {
assert.equal(stripTrace('Chief → 助手\n\nHi Chief! Good to hear from you.'), 'Hi Chief! Good to hear from you.')
Expand Down Expand Up @@ -62,3 +62,22 @@ test("an inbound friend's message uses the friend's sidebar Bot (avatar, name),
assert.notEqual(other.bot.color, 'gray')
assert.equal(other.chatId, undefined)
})

test("a friend Bot's handle in a preview reads as its display name (#71)", () => {
const roster = { bots: [{ id: 'f', name: '代码审查官', remote: { handle: '@bot-2a758c0f.danyang.home' } }], remoteBots: [{ handle: '@bot-2a758c0f.danyang.home', name: '代码审查官' }] }
assert.equal(nameMentions('@bot-2a758c0f.danyang.home 你是?', roster), '@代码审查官 你是?')
assert.equal(nameMentions('看 @bot-2a758c0f.danyang.home。', roster), '看 @代码审查官。')
// An address before the @, or a longer word after the handle, stays as written.
assert.equal(nameMentions('mail a@bot-2a758c0f.danyang.home', roster), 'mail a@bot-2a758c0f.danyang.home')
assert.equal(nameMentions('@bot-2a758c0f.danyang.home2 不在', roster), '@bot-2a758c0f.danyang.home2 不在')
// Unknown handles, missing rosters, and non-string input pass through.
assert.equal(nameMentions('@bot-x.home 你在?', roster), '@bot-x.home 你在?')
assert.equal(nameMentions('@bot-2a758c0f.danyang.home', undefined), '@bot-2a758c0f.danyang.home')
assert.equal(nameMentions(undefined, roster), '')
// One pass: a name that itself contains another handle is not rescanned.
const nested = { bots: [], remoteBots: [
{ handle: '@b.x.home', name: '请找 @a.x.home 的主人' },
{ handle: '@a.x.home', name: '代码审查官' },
] }
assert.equal(nameMentions('@b.x.home 你在?', nested), '@请找 @a.x.home 的主人 你在?')
})
15 changes: 15 additions & 0 deletions core/test/mention-rule.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,21 @@ test('the user bubble keeps the text around a mention, and a group only calls it
assert.equal(canDrawUserMessages(), false)
})

test("a friend's Bot answers its handle too, and the chip reads its display name (#71)", () => {
const remote = { id: 'f', name: '代码审查官', remote: { handle: '@bot-2a758c0f.danyang.home', home: '@danyang.home' } }
const members = mentionableBots({ bots: [remote], byId: { f: remote }, roomsById: { r: { members: ['f'] } } }, 'r')
// The @ menu inserts "@<handle>" and the host delivers on it, so the bubble chips it
// like a name mention, under the display name from the roster.
const parts = userTextParts('@bot-2a758c0f.danyang.home 你是?', members)
assert.deepEqual(plain(parts.filter(part => part.bot).map(part => part.bot.id)), ['f'])
assert.equal(parts[0].bot.mentionLabel, '代码审查官')
assert.deepEqual(plain(parts[1]), { text: ' 你是?' })
// The display-name form still matches the Bot itself, and an address before @ stays text.
const named = userTextParts('@代码审查官 你是谁', members)
assert.deepEqual(plain(named.filter(part => part.bot).map(part => [part.bot.id, part.label])), [['f', '代码审查官']])
assert.deepEqual(plain(userTextParts('mail f@bot-2a758c0f.danyang.home', members).filter(part => part.bot)), [])
})

test('a Bot name is something "@" can call without a second reading', () => {
assert.equal(nameProblem('新 Bot'), undefined)
assert.equal(nameProblem(' '), 'A Bot needs a name')
Expand Down
1 change: 1 addition & 0 deletions docs/subsystems/ds-bot-core.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ Patched upstream `ds-bot` that ships as `core/`. Never edit `ds-bot/` by hand; c
5. The saved team (`state.json`) is versioned. Version 2 (#47) carries the identity schema — `members` records, a per-Bot `ownerId`, the owner's `designatedChiefId`. A legacy version-1 file is migrated on load with a one-time `state.json.v1` backup; an existing backup counts only when it is a readable file holding a parseable version-1 state, and anything else (or any malformed ownership) fails closed (read-only). An older binary refuses a newer version read-only. This is schema foundation only — team mode stays disabled, see [team](team.md). On top of it, member auth (#48) adds host module `src/host/member-auth.js`, installed after `store` in `MODULES` (`core/index.js`): `invitations` and `authSessions` maps live on state (no `STATE_VERSION` bump), the channel resolves the actor from the `x-flybot-member-session` header, and endpoints are `auth-invite` / `auth-redeem` / `auth-revoke` / `auth-whoami` / `auth-remove-member` / `auth-revoke-invite` with `body.actorId` ignored; `auth-invite` alone also opens for an anonymous caller on a host with no sessions yet (bootstrap first invite), `auth-revoke-invite` kills an unredeemed invitation (redeeming it afterwards fails with `revoked`), `removeMember` refuses a member that still owns a Bot (`has_bots`), and the auth mutations are serialized behind an in-process lock so concurrent `removeMember` calls cannot both pass the last-owner check. Sign-in surface only — still not team mode; details in [team](team.md).
6. Host diagnostics never depend on `ctx.logger`: DSH may export no logger, and a test or plugin-local host may carry one without `warn`. `rt.warn` still writes stderr and the diagnostics log in that case (`src/host/diagnostics.js`).
7. `remoteTurn` (`src/host/remote-chat.js`) finds what the user typed on a friend's remote chat by skipping host-appended `role:'user'` context: forms `snapshot`, `instructions` (the AGENTS.md baseline on a chat's first turn) and `notice`, and the time-context kinds. Match by form, not by a whitelist of senders, or the first message of a new chat is dropped (#66).
8. A friend's remote Bot is stored and delivered by its handle (`@<bot>.<home>` — what the @ menu inserts and `sendUserMentions` matches). Where the user reads it — mention chips (`src/client/user-message.js`) and sidebar previews (`nameMentions` in `src/client/text.js`) — the client rewrites the handle to the display name taken from the local roster (`roster.bots[].remote`, `roster.remoteBots`), never from message text, so a remote-controlled name reaches the UI only through the sanitized roster records (#71; display names, #9).

## Tests

Expand Down
32 changes: 29 additions & 3 deletions patches/ds-bot/0001-core-fixes.patch
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,36 @@ index 28cd67da0010775b593175fc61404750e4730f21..778ff0445d3a194e0663c2bbbe6a6bb2
// its account plugin (the Desktop); the API key and custom models use the shell's
// own editors.
diff --git a/src/client/text.js b/src/client/text.js
index d21f4034624b4115bb0aede4ba95b761fc4a61be..be41500104d2c5941a3eb4dfa3d036e70955b33b 100644
index d21f4034624b4115bb0aede4ba95b761fc4a61be..d45cfd895d488b98bafe7b9fe23ea123919f9bac 100644
--- a/src/client/text.js
+++ b/src/client/text.js
@@ -62,6 +62,15 @@ export function reminderPrompts(text) {
@@ -25,6 +25,25 @@ export function mentionTarget(event) {
return anchor ? anchor.getAttribute('href').slice(MENTION_ORIGIN.length) : null
}

+// A friend's remote Bot is stored and delivered by its handle ("@bot-x.home": what the
+// @ menu inserts, what the host matches); the user reads its display name. Previews
+// rewrite the handle from the local roster, never from the text, so a remote name
+// reaches the UI only through the sanitized roster record. One pass over the text: a
+// name that itself contains a handle is never rescanned.
+export function nameMentions(text, roster) {
+ const value = String(text ?? '')
+ if (!value.includes('@') || roster === null || typeof roster !== 'object') return value
+ const names = new Map()
+ for (const record of [...(Array.isArray(roster.remoteBots) ? roster.remoteBots : []), ...(Array.isArray(roster.bots) ? roster.bots : [])]) {
+ const handle = typeof record?.remote === 'object' && record.remote !== null ? record.remote.handle : record?.handle
+ if (typeof handle === 'string' && typeof record?.name === 'string' && record.name !== '') names.set(handle.replace(/^@+/, '').toLowerCase(), record.name)
+ }
+ const handles = [...names.keys()].filter(Boolean).sort((a, b) => b.length - a.length)
+ if (handles.length === 0) return value
+ const source = handles.map(handle => handle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')
+ return value.replace(new RegExp(`(^|[^a-z0-9_./])@(${source})(?![a-z0-9_\\-/\\\\])(?!\\.[a-z0-9_-])`, 'gi'), (_all, before, handle) => `${before}@${names.get(handle.toLowerCase())}`)
+}
+
function clock(ms) {
return new Date(ms).toLocaleTimeString(dateLocale(), { hour: 'numeric', minute: '2-digit' })
}
@@ -62,6 +81,15 @@ export function reminderPrompts(text) {
}
}
export const stripHeader = text => text.replace(HEADER, '')
Expand All @@ -111,7 +137,7 @@ index d21f4034624b4115bb0aede4ba95b761fc4a61be..be41500104d2c5941a3eb4dfa3d036e7

// Of the Bots in a conversation, the one whose memory changed last, by the host's
// clock: changes reaching one window in the same poll share a `seen` time.
@@ -207,7 +216,8 @@ export function previewOf(list, id, hiddenTurns = []) {
@@ -207,7 +235,8 @@ export function previewOf(list, id, hiddenTurns = []) {
const entry = outline[index]
if (hiddenTurns.includes(entry.turn)) continue
const text = entry.response || entry.prompt || ''
Expand Down
Loading
Loading