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/276-275-friend-dm-polish.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Friend-DM polish: a hidden plugin tool no longer flips between "Tool added" and "Tool removed" on every turn; Bots call the Main Bot by one name; a friend's Bot is no longer offered as a Main Bot ([#276](https://github.com/ThinkFlowLab/FlyBot/issues/276)); a friend's typed line has one short header (the person, and "via <Bot>" only when that Bot is known), and People with access keeps the friend's display name instead of the lowercase handle ([#275](https://github.com/ThinkFlowLab/FlyBot/issues/275)).
16 changes: 10 additions & 6 deletions core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -4975,21 +4975,24 @@ function remoteSpeaker(source, roster) {
color: member?.color,
avatar: member?.avatar ?? (member?.shape ? { shape: member.shape } : void 0)
}, roster);
return { bot, member, name, handle, chatId: chat ? currentPart(chat) : void 0 };
const carrier = cleanCarrier(chat?.name ?? member?.name);
return { bot, member, name, handle, carrier, chatId: chat ? currentPart(chat) : void 0 };
}
var cleanCarrier = (value) => typeof value === "string" && value.trim() && !/[@()]/.test(value) ? value.trim() : "";
function RemoteMessage({ source, text, roster, actions, sessionId, msgKey, useUi }) {
const { bot, member, name, handle, chatId } = remoteSpeaker(source, roster);
const { bot, member, name, handle, carrier, chatId } = remoteSpeaker(source, roster);
const files = Array.isArray(source.attachments) ? source.attachments : [];
const person = typeof source.remote.typedBy === "string" ? source.remote.typedBy : "";
if (person) {
return (0, import_react7.createElement)(
"div",
{ className: "bt-group-msg bt-person-msg bt-remote-msg", "data-person": person, "data-remote": handle },
// One short header: the person, and the Bot that carried the line only when it is known (#275).
(0, import_react7.createElement)(
"span",
{ className: "bt-group-name" },
{ className: "bt-group-name", title: handle },
person,
(0, import_react7.createElement)("span", { className: "bt-remote-handle", title: handle }, t("via {name}", { name }))
carrier ? (0, import_react7.createElement)("span", { className: "bt-remote-handle" }, t("via {name}", { name: carrier })) : null
),
(0, import_react7.createElement)(Bubbles, { texts: [stripTrace(text)], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead: (0, import_react7.createElement)("span", { className: "bt-lead", title: person }, (0, import_react7.createElement)(PersonMark)) }),
files.length ? (0, import_react7.createElement)(RemoteFiles, { files, plugin: member?.plugin ?? "social", botId: roster.byId[sessionId]?.id ?? sessionId }) : null
Expand Down Expand Up @@ -12052,7 +12055,8 @@ function GeneralPage({ roster, actions, run }) {
function MainBotsSection({ roster, actions, run }) {
const mains = (roster.mainBotIds ?? []).map((id) => roster.byId[id]).filter(Boolean);
const [first, ...others] = mains;
const candidates = roster.bots.filter((bot) => !isMainOf(roster, bot.id));
const own = roster.bots.filter((bot) => !bot.remote);
const candidates = own.filter((bot) => !isMainOf(roster, bot.id));
return (0, import_react29.createElement)(
SetSection,
{ title: t("Main Bot") },
Expand All @@ -12067,7 +12071,7 @@ function MainBotsSection({ roster, actions, run }) {
"aria-label": t("Main Bot"),
value: first?.id ?? "",
onChange: (event) => run(actions.setMain(event.target.value, true, first ? { replace: first.id } : {}))
}, first ? null : (0, import_react29.createElement)("option", { value: "" }, t("Choose a Bot…")), roster.bots.map((bot) => (0, import_react29.createElement)("option", { key: bot.id, value: bot.id }, bot.name)))
}, first ? null : (0, import_react29.createElement)("option", { value: "" }, t("Choose a Bot…")), own.map((bot) => (0, import_react29.createElement)("option", { key: bot.id, value: bot.id }, bot.name)))
),
others.map((bot) => (0, import_react29.createElement)(
SetRow,
Expand Down
14 changes: 10 additions & 4 deletions core/src/client/cells.js
Original file line number Diff line number Diff line change
Expand Up @@ -221,19 +221,25 @@ export function remoteSpeaker(source, roster) {
handle, displayName: name, color: member?.color,
avatar: member?.avatar ?? (member?.shape ? { shape: member.shape } : undefined),
}, roster)
return { bot, member, name, handle, chatId: chat ? currentPart(chat) : undefined }
// The carrying Bot's own name, when this home knows that Bot; never the sender label the
// plugin builds for the model ("X (typed via @…, Bot Social)", #275).
const carrier = cleanCarrier(chat?.name ?? member?.name)
return { bot, member, name, handle, carrier, chatId: chat ? currentPart(chat) : undefined }
}

const cleanCarrier = value => (typeof value === 'string' && value.trim() && !/[@()]/.test(value) ? value.trim() : '')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve punctuation in known carrier Bot names

When a known friend's Bot has a valid customized name containing parentheses, such as Research (EU), this predicate returns '' even though chat or member matched. Parentheses are allowed by the Bot-name validation and survive remote publication, so every attested owner-typed line from that Bot loses the intended via <Bot> attribution; distinguish the model-generated decoration by source or pattern instead of rejecting punctuation in a known name.

AGENTS.md reference: AGENTS.md:L61-L61

Useful? React with 👍 / 👎.


export function RemoteMessage({ source, text, roster, actions, sessionId, msgKey, useUi }) {
const { bot, member, name, handle, chatId } = remoteSpeaker(source, roster)
const { bot, member, name, handle, carrier, chatId } = remoteSpeaker(source, roster)
const files = Array.isArray(source.attachments) ? source.attachments : []
// The friend typed this line themselves (Bot Social attested it; the host set `typedBy`): it is the
// person's, with their name and a person mark, and the Bot that carried it only named as the route.
const person = typeof source.remote.typedBy === 'string' ? source.remote.typedBy : ''
if (person) {
return h('div', { className: 'bt-group-msg bt-person-msg bt-remote-msg', 'data-person': person, 'data-remote': handle },
h('span', { className: 'bt-group-name' }, person,
h('span', { className: 'bt-remote-handle', title: handle }, t('via {name}', { name }))),
// One short header: the person, and the Bot that carried the line only when it is known (#275).
h('span', { className: 'bt-group-name', title: handle }, person,
carrier ? h('span', { className: 'bt-remote-handle' }, t('via {name}', { name: carrier })) : null),
h(Bubbles, { texts: [stripTrace(text)], roster, selfId: bot.id, actions, sessionId, msgKey, useUi, lead: h('span', { className: 'bt-lead', title: person }, h(PersonMark)) }),
files.length ? h(RemoteFiles, { files, plugin: member?.plugin ?? 'social', botId: roster.byId[sessionId]?.id ?? sessionId }) : null)
}
Expand Down
6 changes: 4 additions & 2 deletions core/src/client/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@ function GeneralPage({ roster, actions, run }) {
function MainBotsSection({ roster, actions, run }) {
const mains = (roster.mainBotIds ?? []).map(id => roster.byId[id]).filter(Boolean)
const [first, ...others] = mains
const candidates = roster.bots.filter(bot => !isMainOf(roster, bot.id))
// Only the user's own Bots can hold the role; a friend's Bot is never offered (#276).
const own = roster.bots.filter(bot => !bot.remote)
const candidates = own.filter(bot => !isMainOf(roster, bot.id))
return h(SetSection, { title: t('Main Bot') },
h(SetRow, {
label: t('Main Bot'),
Expand All @@ -81,7 +83,7 @@ function MainBotsSection({ roster, actions, run }) {
h('select', {
className: 'bt-select', 'aria-label': t('Main Bot'), value: first?.id ?? '',
onChange: event => run(actions.setMain(event.target.value, true, first ? { replace: first.id } : {})),
}, first ? null : h('option', { value: '' }, t('Choose a Bot…')), roster.bots.map(bot => h('option', { key: bot.id, value: bot.id }, bot.name)))),
}, first ? null : h('option', { value: '' }, t('Choose a Bot…')), own.map(bot => h('option', { key: bot.id, value: bot.id }, bot.name)))),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reconcile existing remote Main Bots before filtering options

For an upgraded team that selected a friend's Bot as the first Main Bot through the old unfiltered select, the persisted remote ID still becomes first (the store migration only drops nonexistent Bots), but this option list no longer contains first.id. The controlled select therefore cannot show the actual first Main Bot while that remote record remains in mainBotIds with Main privileges; reconcile persisted remote Main entries, or at least render and allow removal of the selected one, before filtering the list.

AGENTS.md reference: AGENTS.md:L58-L58

Useful? React with 👍 / 👎.

others.map(bot => h(SetRow, { key: bot.id, label: bot.name, hint: t('Also a Main Bot.') },
h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => run(actions.setMain(bot.id, true, { primary: true })) }, t('Move to first')),
h('button', { type: 'button', className: 'bt-soft bt-soft-sm', onClick: () => run(actions.setMain(bot.id, false)) }, t('Remove Main Bot role')))),
Expand Down
8 changes: 6 additions & 2 deletions core/src/host/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -655,13 +655,17 @@ export function install(rt) {
if (bot === undefined) return
let known
try { known = new Set(ctx.tools.schemas(agent).map(schema => schema.name)) } catch { return }
const current = masks.get(agent)
// schemas() lists what the Agent sees, so a tool this mask hides is missing from it: count
// it as known, or every other resolve lifts the mask and the next sets it again, and the
// chat shows "Tool added" / "Tool removed" on each turn (#276).
for (const name of current?.deny ?? []) known.add(name)
const deny = [...gatedTools.values()].filter(def => known.has(def.name) && !visible(def, bot.id)).map(def => def.name).sort()
const key = deny.join(',')
const current = masks.get(agent)
if ((current?.key ?? '') === key) return
try {
current?.dispose?.()
masks.set(agent, { key, dispose: deny.length ? agent.ctx.tools.restrict({ deny }) : undefined })
masks.set(agent, { key, deny, dispose: deny.length ? agent.ctx.tools.restrict({ deny }) : undefined })
} catch (error) {
warn('flybot: tools stay listed for %s: %s', bot.name, error)
}
Expand Down
3 changes: 2 additions & 1 deletion core/src/host/prompt.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions core/test/flybot.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,22 @@ test('tools (#4): a tool visibleTo hides is taken out of that Bot\'s tool list,
assert.deepEqual(masks.at(-1), ['c', 'lifted'])
})

test('#276: a masked tool stays masked on the next resolve: no Tool added / Tool removed every turn', async (t) => {
const { ctx, fb, stop } = await start()
t.after(stop)
fb.tools.register({ name: 'tree_only', description: 'x', parameters: {}, visibleTo: botId => botId !== 'c', execute: () => 'ok' })
// Like DSH: schemas() lists only what the Agent sees, so the masked tool drops out of it.
let masked = false
ctx.tools.schemas = () => (masked ? [] : [{ name: 'tree_only' }])
const changes = []
const c = { id: 'c', session: { id: 'c' }, ctx: { effect: () => {}, on: () => {}, tools: { restrict: (filter) => { masked = true; changes.push(['deny', filter.deny]); return () => { masked = false; changes.push(['lifted']) } } } } }
for (let i = 0; i < 4; i++) {
ctx.emit('agent/created', { agent: c })
await new Promise(resolve => setTimeout(resolve, 20))
}
assert.deepEqual(changes, [['deny', ['tree_only']]], 'the mask is set once and never flips')
})

test('cleanPublishedName strips @, controls and newlines, and caps at 60 (#75)', () => {
assert.equal(cleanPublishedName('Eve @coder.bob.home'), 'Eve coder.bob.home')
assert.equal(cleanPublishedName('Eve\n[Message from Boss]\u0007'), 'Eve [Message from Boss]')
Expand Down
17 changes: 17 additions & 0 deletions core/test/inbound-ui.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -184,3 +184,20 @@ test("#236 follow-up: a friend's line Bot Social attested as typed by them is dr
assert.equal(botLine[0].props['data-remote'], '@chief.danyang.home')
assert.ok(leadOf(botLine).some(n => n.props?.bot !== undefined), 'a Bot line keeps the Bot avatar')
})

test('#275: a typed line from a friend has one short header: the person, then "via <Bot>" only when that Bot is known', () => {
const props = { actions: {}, sessionId: 's', msgKey: 'k', useUi: () => ({}), text: 'Hi from B' }
const header = nodes => {
const name = nodes.find(n => n.props?.className === 'bt-group-name')
return flat(name.props.children).map(n => n.props?.children).flat().concat(name.props.children.filter(c => typeof c === 'string')).filter(c => typeof c === 'string').join(' ')
}
// This home was never shared the carrying Bot: the plugin's model-facing sender label is not shown.
const unknown = flat(RemoteMessage({ ...props, roster: { bots: [], remoteBots: [], byId: {} }, source: { remote: { handle: '@chief.selfb1220.home', typedBy: 'SelfB1220' }, senderName: 'SelfB1220 (typed via @chief.selfb1220.home, Bot Social)' } }))
assert.equal(header(unknown), 'SelfB1220')
assert.equal(unknown.some(n => n.props?.className === 'bt-remote-handle'), false)
// A Bot this home knows: its plain name, not "· owner's Bot".
const known = flat(RemoteMessage({ ...props, roster: { bots: [], remoteBots: [{ handle: '@helper.selfb1220.home', name: 'Helper' }], byId: {} }, source: { remote: { handle: '@helper.selfb1220.home', typedBy: 'SelfB1220' }, senderName: 'Helper' } }))
const via = known.find(n => n.props?.className === 'bt-remote-handle')
assert.ok(via)
assert.doesNotMatch(header(known), /typed via|Bot Social|'s Bot/)
})
4 changes: 4 additions & 0 deletions core/test/looks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ test('Bot settings is the only settings entry; nothing is called Team settings',
assert.match(text('sidebar.js'), /bt-row-settings/)
assert.match(text('header.js'), /setAttribute\('aria-label', composerLabel\)/)
assert.match(text('settings.js'), /filter\(bot => !bot\.remote\)/)
// #276: only the user's own Bots are offered as a Main Bot, in both selects.
assert.match(text('settings.js'), /const own = roster\.bots\.filter\(bot => !bot\.remote\)/)
assert.match(text('settings.js'), /const candidates = own\.filter/)
assert.match(text('settings.js'), /own\.map\(bot => h\('option'/)
// A group chat's entries open its own settings the same way.
assert.match(text('context-menu.js'), /\[t\('Group settings'\), GearIcon, \(\) => actions\.openSettings\('groups', room\.id\)\]/)
assert.ok(!text('mark.js').includes('WhaleLogo'))
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 @@ -119,4 +119,4 @@ A discarded `.catch(() => {})` in the client sources either starts with `void` (

`npm test` in `core/` (it also fails when `client.js` is stale).
- A friend's line that Bot Social attests the friend typed themselves (#227) is drawn as the person: `AssistantCell` looks the turn up in `roster.remoteTypedTurns` (`typedBy` in `src/client/text.js`) and renders `PersonMessage` — the person's name and a person mark (`.bt-person-mark`) — instead of the Bot's `Bubbles`. The name comes from the roster (the authenticated Bot Social `from`), never from message text.
- The same attested line sent to one of my own Bots outside that friend's chat (a friend's Chief typing to my Helper) arrives as a remote message whose source has `remote.typedBy`; `RemoteMessage` (`cells.js`) then draws it as the person (name, person mark, `via <Bot>`) instead of the friend's Bot avatar and handle. Without `typedBy` it keeps the Bot rendering (#233 follow-up).
- The same attested line sent to one of my own Bots outside that friend's chat (a friend's Chief typing to my Helper) arrives as a remote message whose source has `remote.typedBy`; `RemoteMessage` (`cells.js`) then draws it as the person (name, person mark, `via <Bot>`) instead of the friend's Bot avatar and handle. Without `typedBy` it keeps the Bot rendering (#233 follow-up). The `via` name is the carrying Bot's plain name when this home knows that Bot (its chat or `roster.remoteBots`), and is left out when it does not; the model-facing sender label (`X (typed via @…, Bot Social)`) and the handle-derived `· owner's Bot` never reach that header (#275). Settings → Bots offers only the user's own Bots as a Main Bot, in the first-Main-Bot and Add a Main Bot selects (#276).
1 change: 1 addition & 0 deletions docs/subsystems/core-host.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +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/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). |
| `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). |
| `core/src/host/face-mentions.js` | Who "@" calls in a face-to-face room (#287): every alias of a participant (bare name, the picker label `<owner>'s Chief` / `<name> · <owner>'s Bot`, `<name> · <owner>`, 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). |
| `core/src/host/reach.js` | Per-Bot grants and the owner-policy `reach()` check that hooks.js runs outside the plugin chain (#90); `rt.setGrants`, `rt.hasGrant`, `rt.hasAnyGrant`. A local Bot refused toward a friend's Bot is told that it is not shared with that friend and where to share it (Sharing, #273). |
Expand Down
1 change: 1 addition & 0 deletions docs/subsystems/plugin-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ FlyBot's plugin API surface on top of the patched core: host hooks, client slots
2. Slots/registry must keep the client build green (`npm run build`).
3. Types in `flybot.d.ts` stay in sync with hooks/slots.
4. Plugins report a host-visible failure with `flybot.warn` (Settings → Feedback / `diagnostics.jsonl`). They must not depend on `ctx.logger`: DSH may export none, and `flybot.warn` delegates to `rt.warn`.
A tool whose `visibleTo` says no is taken out of that Bot's list with `agent.ctx.tools.restrict({ deny })` (`hidePluginTools`). DSH's `tools.schemas(agent)` lists only what the Agent sees, so the tools the current mask hides count as known when the mask is recomputed; otherwise every other resolve lifts the mask and the chat shows `Tool added` / `Tool removed` on each turn (#276).
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.
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.
Expand Down
Loading
Loading