diff --git a/.agents/skills/verify-deepseekbot/features/README.md b/.agents/skills/verify-deepseekbot/features/README.md index 31b43f6ad..a86a94ba9 100644 --- a/.agents/skills/verify-deepseekbot/features/README.md +++ b/.agents/skills/verify-deepseekbot/features/README.md @@ -70,7 +70,7 @@ One file per user-facing feature. Each file has four sections: `Sub-features`, ` | Historical Overview: idle PersonaBot (retired by #2026) | no | `scripts/e2e-activity-idle.mjs` | [activity-center-idle.md](activity-center-idle.md) | | Temporary Workspace Grant authorization, read and revocation | no | `scripts/e2e-workspace-grant.mjs` | [workspace-grant.md](workspace-grant.md) | | Default Bot Browser open, observe, stop and Access refusal | no | `scripts/e2e-browser-lifecycle.mjs` | [browser-lifecycle.md](browser-lifecycle.md) | -| Skill Invocation from `/` in a Bot DM | yes for the reply | `verify-app` Bot DM and `skills-list` / `send --skill` | [skill-invocation.md](skill-invocation.md) | +| Skill Invocation from `/` in a Bot DM or Group Channel | yes for the reply | `verify-app` DM/Group; `skills-list`, `send --skill` / `channelSend` | [skill-invocation.md](skill-invocation.md) | | Composer footer customization and optional reply speed | yes for a speed sample | none (Web interaction path) | [composer-footer.md](composer-footer.md) | | Disposable Memory history, restore and stale-head refusal | no | `scripts/e2e-memory-restore.mjs` | [memory-restore.md](memory-restore.md) | | Version-bound Memory images, Markdown previews and CLI export | no | `scripts/e2e-memory-images.mjs`, `scripts/e2e-memory-markdown.mjs`, `scripts/e2e-memory-formats.mjs` | [memory-images.md](memory-images.md) | diff --git a/.agents/skills/verify-deepseekbot/features/skill-invocation.md b/.agents/skills/verify-deepseekbot/features/skill-invocation.md index 83f191dd5..f4840f34d 100644 --- a/.agents/skills/verify-deepseekbot/features/skill-invocation.md +++ b/.agents/skills/verify-deepseekbot/features/skill-invocation.md @@ -1,20 +1,25 @@ -# Skill Invocation in a Bot DM +# Skill Invocation in a Bot DM or Group Channel -A Human types `/` in a PersonaBot DM, picks a Skill, and sends it. The Host stores the Skill's text with the message and the Bot reads it as a `` block ([ADR-0346](../../../../docs/adr/0346-skill-invocation-is-snapshotted-text-that-addresses-no-one.md)). +A Human types `/` in a PersonaBot DM or Group Channel, picks a Skill, and sends it. The Host stores the Skill's text with the message and the Bot reads it as a `` block ([ADR-0346](../../../../docs/adr/0346-skill-invocation-is-snapshotted-text-that-addresses-no-one.md)). ## Sub-features -- `menu` lists composer commands first, then Skills: Host Skills and the Project Skills under the Bot's Memory Repository `.agents/skills` or `.dsh/skills`. Each row shows its owner (Bot avatar or Skill icon), `/name` and a one-line description. -- `rank` orders Skills by name prefix, then name subsequence, then description text. +- `menu` lists composer commands first, then Skills: Host Skills and the Project Skills under each conversation member Bot's Memory Repository `.agents/skills` or `.dsh/skills`. Each row shows its owner (Bot avatar or Skill icon), `/name` and a one-line description. +- `rank` orders Skills by name prefix, then name subsequence, then owning Bot display name or slug substring, then description substring; order within each tier stays stable. Typing `alice` lists all of Alice's Skills. - `chip` inserts the picked Skill as one token; Backspace removes it whole; the draft keeps it across reloads. - `snapshot` stores `{name, owner, path, content}` with the message; editing or deleting the `SKILL.md` later does not change the sent message. - `deliver` gives the Bot the message with `[/name]` and the `` block, so DSH does not load the Skill a second time. -- `cli` `deepseekbot skills-list --bot [--query]` and `send --body "/name …" --skill /`. +- `group-routing` invokes Alice's Skill while mentioning Bob: only Bob wakes under a mentions-only wake policy, and his Bot Inbox text contains Alice's snapshot, owner `bot:alice` and path. Without a mention, existing per-Bot Group wake policies decide delivery; the Skill owner is never implicitly addressed. +- `im-mirror` falls back to plain `/name` on Discord/Lark, without `[/name]`, XML or snapshot content. +- `command-collision` keeps commands first and reserves a bare command name while its same-named Skill row remains selectable. External IM DM command intake recognizes supported bare commands before ordinary intake; Channel/CLI sends require explicit Skill tokens or `--skill`. +- `cli` `deepseekbot skills-list --bot [--query]` (or `--channel `) and `send --body "/name …" --skill /`. ## How to get to it (user POV) - Enter Bot mode, open a Bot DM, type `/` and a few letters in the composer, choose a Skill with the arrow keys and Enter (or click it), write the rest of the message, press Enter. +- Open a Group Channel with Alice and Bob, type `/`, inspect both owners and the single Host Skill entry, then pick Alice's Skill and explicitly mention Bob. + ## Driving it with verify-app Preconditions: `launch` reported a model credential and `doctor` passes. @@ -25,8 +30,20 @@ Preconditions: `launch` reported a model credential and `doctor` passes. 4. **Canonical state.** `rpc botharness/channelTimeline '{"channelId":""}'`: the Human entry has `skillInvocations[0]` with `owner {kind: bot}`, the `path` of the seeded file and its body; the Bot entry replies `SKILL_QA_7731`. 5. **CLI.** `deepseekbot skills-list --bot --query qa` lists `qa-echo`; `deepseekbot send --body "/qa-echo" --skill bot:/qa-echo` returns a committed reply. +### Group Channel path + +1. Seed `qa-echo` in Alice's Memory Repository and a distinct Skill in Bob's. Create or open a Group containing Alice and Bob. Set both Group wake policies to mentions-only so an ordinary digest wake cannot obscure the recipient check. +2. Type `/` in the Group composer: both Project Skills appear with their owning Bot avatars; each Host Skill appears once. Type Alice's display name or slug: all her Skills remain, below name matches and above description-only matches. +3. Pick Alice's `qa-echo` row, explicitly mention Bob using the mention picker, and send. Bob alone replies `SKILL_QA_7731`. Read `rpc botharness/channelTimeline '{"channelId":""}'`: the Human message stores owner `{kind: bot, botSlug: alice}`, path and original content; canonical Bot Inbox/Admission state identifies Bob as the recipient, not Alice. +4. Repeat without a mention: with both policies mentions-only neither Bot wakes. Change Bob's policy to all messages and repeat: Bob wakes through ordinary routing with Alice's identical snapshot. Editing/deleting the Skill after send leaves the stored snapshot unchanged. +5. `deepseekbot skills-list --channel --query alice` lists Alice's Skills by display name and slug. Send with `verify-app rpc botharness/channelSend '{"channelId":"","body":"/qa-echo","skillInvocations":[{"name":"qa-echo","owner":{"kind":"bot","botSlug":"alice"},"start":0,"end":8}]}'` to exercise the same snapshot and ordinary routing without the UI. +6. For a same-named command/Skill, inspect commands-first ordering, exercise the bare command, then explicitly select the Skill row and inspect the snapshot. +7. Through the designated Discord/Lark test destination and the IM browser QA procedure, inspect a mirrored Human message: it shows plain `/qa-echo`, with no brackets, XML or snapshot content. Correlate it with Source Event/Outbox facts. The automated provider-carrier tests alone do not verify browser rendering; do not infer that a Group bridge automatically forwards Human messages. + ## Gotchas -- The `/` menu reads the catalog when the DM opens. A Skill written while the DM is open appears after reopening the DM until S3 (live refresh) lands. +- The `/` menu reads the catalog when the conversation opens. A Skill written while the conversation is open appears after reopening it until S3 (live refresh) lands. - DSH discovers `/.agents/skills` because the Orchestrator's cwd is the Memory Repository; a Skill placed in `.botharness/` is not found. - Recalling an older message with ArrowUp, or restoring a failed send, keeps `/name` as plain text without the chip. + +- S2 non-UI automation covers catalog membership, ranking, routing, snapshot delivery and plain-text provider sends. Group menu avatars, command selection and Discord/Lark browser rendering still require the UI/IM journey above. diff --git a/changes/4233-group-skill-ranking.md b/changes/4233-group-skill-ranking.md new file mode 100644 index 000000000..b1fb4dfcf --- /dev/null +++ b/changes/4233-group-skill-ranking.md @@ -0,0 +1,11 @@ +--- +section: Added +--- + +## en + +- Added owning Bot display name and slug matching to Skill search in Group Channels and `skills-list`, after Skill name matches and before description matches; invoking another Bot's Skill preserves normal routing and the shared text snapshot ([#4233](https://github.com/BotHarness/BotHarness/issues/4233)). + +## zh + +- 群聊与 `skills-list` 的 Skill 搜索新增所属 Bot 显示名和 slug 匹配,排在 Skill 名称匹配之后、描述匹配之前;调用其他 Bot 的 Skill 仍遵循原有路由,并保留共享文本快照([#4233](https://github.com/BotHarness/BotHarness/issues/4233))。 diff --git a/docs/bot-cli.md b/docs/bot-cli.md index c549cb0cb..9a3c68053 100644 --- a/docs/bot-cli.md +++ b/docs/bot-cli.md @@ -238,7 +238,7 @@ deepseekbot folder-browse [] [--offset ] deepseekbot grant-create --workspace ``` -`skills-list` returns the Skills the `/` menu offers in that conversation, ranked like the menu by `--query` (name prefix, then name subsequence, then description): Host Skills (`owner.kind` `host`) and the Project Skills under each PersonaBot's Memory Repository `.agents/skills` or `.dsh/skills` (`owner.kind` `bot`), as that Bot's Orchestrator sees them. Pass `--channel ` instead of `--bot` for any Channel. `send --skill /` (owner `host` or `bot:`, repeatable) turns the first unused `/name` token in the body into a Skill Invocation for each flag, in flag order (the token must start and end at whitespace or a body boundary; the send is refused if no unused occurrence remains): the Host stores the Skill's text as it is now with the message and the Bot reads it as a `` block. The Skill does not choose who replies; a Skill that is gone refuses with `skill-unavailable`, and Skill text longer than 64,000 characters refuses with `skill-too-large` instead of being truncated. See [ADR-0346](adr/0346-skill-invocation-is-snapshotted-text-that-addresses-no-one.md). +`skills-list` returns the Skills the `/` menu offers in that conversation, ranked like the menu by `--query` (name prefix, then name subsequence, then owning Bot display name or slug substring, then description substring): Host Skills (`owner.kind` `host`) and the Project Skills under each PersonaBot's Memory Repository `.agents/skills` or `.dsh/skills` (`owner.kind` `bot`), as that Bot's Orchestrator sees them. Pass `--channel ` instead of `--bot` for any Channel. `send --skill /` (owner `host` or `bot:`, repeatable) turns the first unused `/name` token in the body into a Skill Invocation for each flag, in flag order (the token must start and end at whitespace or a body boundary; the send is refused if no unused occurrence remains): the Host stores the Skill's text as it is now with the message and the Bot reads it as a `` block. The Skill does not choose who replies; a Skill that is gone refuses with `skill-unavailable`, and Skill text longer than 64,000 characters refuses with `skill-too-large` instead of being truncated. See [ADR-0346](adr/0346-skill-invocation-is-snapshotted-text-that-addresses-no-one.md). `tool-approval-status` returns `status` and, while the card is still `pending`, its `expiresAt`, the time the Web card counts down to. `--outcome allowed-always-kind` is the `secret_cli` card's "Always allow this kind": it allows this call and saves a rule for the same program, account and environment variable names, which `tool-approval-rules` lists with kind `secret-cli`; any other card refuses it. @@ -479,22 +479,14 @@ printf '{"token":"…"}' | deepseekbot im-app-add discord --credentials-stdin deepseekbot im-app-status discord deepseekbot im-bind --app deepseekbot im-status -deepseekbot im-status --read-mode saved -deepseekbot messaging-grants --read-mode status -deepseekbot channel-bridges --read-mode saved deepseekbot channel-ingests deepseekbot channel-connect --bot --conversation -deepseekbot im-reconnect --app deepseekbot im-unbind --app deepseekbot im-app-remove discord --app ``` `im-app-add` covers token-based platforms (Discord, Slack, QQ, DingTalk, WeCom, Matrix); Feishu and WeChat keep `im-authorize`. Credentials arrive only on stdin and are never printed; output lists each app's id, name, connection state and error code. `im-bind` binds a connected app (the `id` from `im-app-status`, also the `ref` in `im-status`) to a Bot, and `im-status` shows each binding's `reception`. `channel-connect` attaches an external group conversation to a Channel as context through that Bot's binding; the conversation must already be known to the binding, so mention the app there once and pick the id from the `candidates` in `channel-ingests`. Otherwise the Host refuses with `conversation-unavailable`. -`im-status` and `messaging-grants` include process-local `recovery` metadata: `retrying` with `reason`, `attempt`, `nextRetryAt`, or `action-required` with `reason`. Temporary provider readiness failures retry exponentially from 250 ms to a 30 s cap until recovery. Host restart reconstructs receivers from enabled bindings and grants. `im-reconnect` uses the existing Owner and current revision without changing conversation access. Failed mutations are reported, never automatically replayed. - -`im-status`, `messaging-grants` and `channel-bridges` support `--read-mode saved|status`. `saved` returns local configuration promptly with `checks: pending`; capability/availability fields are not verified permissions. `status` checks only bound accounts and grants with `checks: complete`, sharing account inspections only within the current request. Omitting the flag retains full app/setup discovery. Mutations reject this flag and keep their fresh Owner checks. See [ADR-0343](adr/0343-im-settings-project-saved-configuration-before-live-checks.md). - ### IM messaging, approvals and failure alerts from the CLI ```bash @@ -2114,5 +2106,3 @@ deepseekbot roles update --models all [--as ] deepseekbot roles update --models provider/model,provider/model [--as ] deepseekbot models allowed [--as ] ``` - -Tool effect declarations (E1) preserve the existing tool commands and approval outcomes. Full Access, saved rules, Secrets, device targets and Workspace Grants retain their existing checks; no new route or CLI authority is introduced. diff --git a/docs/bot-cli.zh.md b/docs/bot-cli.zh.md index 8f2b782f9..ae5e76b3d 100644 --- a/docs/bot-cli.zh.md +++ b/docs/bot-cli.zh.md @@ -233,7 +233,7 @@ deepseekbot folder-browse [<文件夹路径>] [--offset ] deepseekbot grant-create --workspace ``` -`skills-list` 返回该对话 `/` 菜单提供的 Skill,并按 `--query` 以与菜单相同的规则排序(名称前缀、名称子序列、描述):包括 Host Skill(`owner.kind` 为 `host`),以及各 PersonaBot Memory Repository 下 `.agents/skills` 或 `.dsh/skills` 中的 Project Skill(`owner.kind` 为 `bot`)。任意 Channel 可用 `--channel ` 代替 `--bot`。`send --skill /`(owner 为 `host` 或 `bot:`,可重复)按参数顺序,为每个参数把正文中第一个尚未选中的 `/name` token 变成 Skill Invocation(token 两端必须为空白或正文边界;没有未用匹配时拒绝发送):Host 把该 Skill 当前的文本随消息保存,Bot 以 `` 块读到它。Skill 不决定由谁回复;已不存在的 Skill 会以 `skill-unavailable` 拒绝;Skill 文本超过 64,000 个字符时会以 `skill-too-large` 拒绝,不会截断。见 [ADR-0346](adr/0346-skill-invocation-is-snapshotted-text-that-addresses-no-one.md)。 +`skills-list` 返回该对话 `/` 菜单提供的 Skill,并按 `--query` 以与菜单相同的规则排序(名称前缀、名称子序列、所属 Bot 显示名或 slug 子串、描述子串):包括 Host Skill(`owner.kind` 为 `host`),以及各 PersonaBot Memory Repository 下 `.agents/skills` 或 `.dsh/skills` 中的 Project Skill(`owner.kind` 为 `bot`)。任意 Channel 可用 `--channel ` 代替 `--bot`。`send --skill /`(owner 为 `host` 或 `bot:`,可重复)按参数顺序,为每个参数把正文中第一个尚未选中的 `/name` token 变成 Skill Invocation(token 两端必须为空白或正文边界;没有未用匹配时拒绝发送):Host 把该 Skill 当前的文本随消息保存,Bot 以 `` 块读到它。Skill 不决定由谁回复;已不存在的 Skill 会以 `skill-unavailable` 拒绝;Skill 文本超过 64,000 个字符时会以 `skill-too-large` 拒绝,不会截断。见 [ADR-0346](adr/0346-skill-invocation-is-snapshotted-text-that-addresses-no-one.md)。 `tool-approval-status` 返回 `status`;卡片仍为 `pending` 时还返回 `expiresAt`,即网页卡片倒计时的截止时间。`--outcome allowed-always-kind` 对应 `secret_cli` 卡片上的“总是允许此类”:批准这一次,并为同一程序、账号和环境变量名保存一条规则;`tool-approval-rules` 会以 `secret-cli` 类型列出它。其他卡片会拒绝这个选项。 @@ -463,22 +463,14 @@ printf '{"token":"…"}' | deepseekbot im-app-add discord --credentials-stdin deepseekbot im-app-status discord deepseekbot im-bind --app deepseekbot im-status -deepseekbot im-status --read-mode saved -deepseekbot messaging-grants --read-mode status -deepseekbot channel-bridges --read-mode saved deepseekbot channel-ingests deepseekbot channel-connect --bot --conversation -deepseekbot im-reconnect --app deepseekbot im-unbind --app deepseekbot im-app-remove discord --app ``` `im-app-add` 适用于基于 token 的平台(Discord、Slack、QQ、钉钉、企业微信、Matrix);飞书和微信仍用 `im-authorize`。凭证只从标准输入读取,绝不打印;输出列出每个应用的 id、名称、连接状态和错误码。`im-bind` 把已连接的应用(`im-app-status` 里的 `id`,即 `im-status` 里的 `ref`)绑定到 Bot,`im-status` 显示每个绑定的 `reception`。`channel-connect` 通过该 Bot 的绑定,把外部群聊作为上下文接入 Channel;这个会话必须已被该绑定知晓,所以先在那里 @ 一次应用,再从 `channel-ingests` 的 `candidates` 里选 id。否则 Host 会以 `conversation-unavailable` 拒绝。 -`im-status` 和 `messaging-grants` 返回进程内的 `recovery` 状态:`retrying` 包含 `reason`、`attempt`、`nextRetryAt`;`action-required` 包含 `reason`。Provider 暂未就绪时从 250 毫秒开始指数退避,上限 30 秒,持续重试直至恢复。Host 重启后从启用的绑定和授权重建接收器。`im-reconnect` 通过现有 Owner 和当前修订号重连,不改变会话权限。失败的修改会返回错误,不会自动重放。 - -`im-status`、`messaging-grants` 和 `channel-bridges` 支持 `--read-mode saved|status`。`saved` 快速返回本地配置,标记 `checks: pending`;其中能力和可用性字段不代表已验证的权限。`status` 只检查绑定的账号和会话,返回 `checks: complete`,账号检查仅在本次请求内共享。不传此参数时仍进行完整应用和接入方式发现。修改操作拒绝此参数,并保留实时 Owner 检查。见 [ADR-0343](adr/0343-im-settings-project-saved-configuration-before-live-checks.md)。 - ### 用命令行管理 IM 消息、审批和失败提醒 ```bash @@ -2055,5 +2047,3 @@ token 或原始响应。 Web 手动检查请求最多等待 25 秒,关闭自检卡片会取消请求。公网 WebSocket 在验证 Host 身份前将握手消息限制为 1 KiB,并禁用压缩。 公网阶段仅对 DNS、网络、超时及 HTTP 429/5xx 短暂失败重试:最多四次,间隔 1/3/6 秒,仍受同一个 20 秒总时限约束。证书或身份失败不重试。每次自检完成后,界面刷新一次账号电脑列表。 - -工具效果声明(E1)保持既有工具命令与审批结果。Full Access、保存规则、Secrets、设备目标和 Workspace Grant 仍使用原检查;不新增 route 或 CLI 权限。 diff --git a/packages/client/src/client/bot-main.tsx b/packages/client/src/client/bot-main.tsx index 7478474f2..6a4b2da7a 100644 --- a/packages/client/src/client/bot-main.tsx +++ b/packages/client/src/client/bot-main.tsx @@ -1910,7 +1910,10 @@ function ConversationView({ const skillCatalogMount = useMountedResource(() => { let cancelled = false; setSkillCatalog(undefined); - if (channel?.type === 'dm' && channel.botSlug !== undefined && actions.skillCatalog) + if ( + ((channel?.type === 'dm' && channel.botSlug !== undefined) || channel?.type === 'group') && + actions.skillCatalog + ) void actions .skillCatalog(channel.id) .then((skills) => { @@ -1920,7 +1923,7 @@ function ConversationView({ return () => { cancelled = true; }; - }, [scopeKey, channelId]); + }, [scopeKey, channelId, previewRoster]); const skillOptions: ComposerSkillOption[] = (skillCatalog ?? []).map((entry) => ({ ...entry, bot: diff --git a/packages/client/src/client/channel-composer.tsx b/packages/client/src/client/channel-composer.tsx index 2ba38333d..44207d445 100644 --- a/packages/client/src/client/channel-composer.tsx +++ b/packages/client/src/client/channel-composer.tsx @@ -476,7 +476,13 @@ export function ChannelComposer({ slashTarget.current === context?.target ? slashQuery : undefined, ); const skillMatches = - skillQuery === undefined ? [] : rankSkillEntries(skillOptions, skillQuery.query).slice(0, 8); + skillQuery === undefined + ? [] + : rankSkillEntries(skillOptions, skillQuery.query, (option) => + option.owner.kind === 'host' + ? [] + : [option.bot?.displayName ?? option.owner.botSlug, option.owner.botSlug], + ).slice(0, 8); const menuLength = shortcutOptions.length + skillMatches.length; const chipBots = [ ...mentionCandidates, diff --git a/packages/client/src/client/channel-message-body.tsx b/packages/client/src/client/channel-message-body.tsx index e49296532..194c30069 100644 --- a/packages/client/src/client/channel-message-body.tsx +++ b/packages/client/src/client/channel-message-body.tsx @@ -794,13 +794,16 @@ export function ChannelMessageBody({ skill: NonNullable[number], key: number, ) => { + const ownerBot = + skill.owner.kind === 'bot' + ? [...bots, ...store.getSnapshot().bots].find( + (bot) => skill.owner.kind === 'bot' && bot.slug === skill.owner.botSlug, + ) + : undefined; const owner = skill.owner.kind === 'host' ? t('composer.skillHost') - : (store - .getSnapshot() - .bots.find((bot) => skill.owner.kind === 'bot' && bot.slug === skill.owner.botSlug) - ?.displayName ?? skill.owner.botSlug); + : (ownerBot?.displayName ?? skill.owner.botSlug); const preview = skill.content.length > 1500 ? `${skill.content.slice(0, 1500)}…` : skill.content; return ( @@ -810,6 +813,20 @@ export function ChannelMessageBody({ data-skill-owner={skill.owner.kind === 'host' ? 'host' : `bot:${skill.owner.botSlug}`} title={`/${skill.name} · ${owner}\n\n${preview}`} > + {skill.owner.kind === 'bot' && host === undefined ? ( + + ) : null} /{skill.name} ); diff --git a/packages/client/test/skill-composer.test.ts b/packages/client/test/skill-composer.test.ts index 4ba7ae504..c63286a07 100644 --- a/packages/client/test/skill-composer.test.ts +++ b/packages/client/test/skill-composer.test.ts @@ -237,4 +237,128 @@ describe('composer / menu', () => { container.remove(); } }); + it('gives the bare name to a command and keeps the same-named Skill selectable', async () => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + const run = vi.fn(); + const changes: Array = []; + const show = () => + act(async () => { + root.render( + createElement(ChannelComposer, { + value: '/sessions', + skillOptions: [ + { + name: 'sessions', + description: 'Summarize Sessions', + owner: { kind: 'bot', botSlug: 'ada' }, + bot: ada, + }, + ], + context: { + target: 'group', + content: null, + shortcuts: [{ id: 'sessions', label: 'Sessions', run }], + }, + placeholder: 'Message', + sending: false, + onChange: (_v: string, _m: unknown, _c: unknown, skills?: SelectedSkill[]) => + changes.push(skills), + onSubmit: () => undefined, + }), + ); + }); + try { + await show(); + const input = container.querySelector('.bh-composer-rich-input')!; + setRichSelection(input, '/sessions'.length); + await act(async () => { + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + expect( + [...container.querySelectorAll('.bh-composer-shortcuts button')].map((row) => + row.getAttribute('aria-label'), + ), + ).toEqual(['Sessions', '/sessions · Ada']); + await act(async () => { + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + }); + expect(run).toHaveBeenCalledTimes(1); + await show(); + setRichSelection(input, '/sessions'.length); + await act(async () => { + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + await act(async () => { + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + }); + await act(async () => { + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + }); + expect(changes.at(-1)).toEqual([ + { name: 'sessions', owner: { kind: 'bot', botSlug: 'ada' }, start: 0, end: 9 }, + ]); + expect(run).toHaveBeenCalledTimes(1); + } finally { + await act(async () => root.unmount()); + container.remove(); + } + }); + it('ranks a Group menu by owning Bot name between name and description matches', async () => { + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + const container = document.createElement('div'); + document.body.append(container); + const root = createRoot(container); + const alice = { ...ada, slug: 'alice', displayName: 'Alice' }; + const bob = { ...ada, slug: 'bob', displayName: 'Bob' }; + try { + await act(async () => { + root.render( + createElement(ChannelComposer, { + value: '/alice', + skillOptions: [ + { name: 'notes', description: 'Ask alice first', owner: { kind: 'host' } }, + { + name: 'triage', + description: 'Sort bugs', + owner: { kind: 'bot', botSlug: 'alice' }, + bot: alice, + }, + { + name: 'review', + description: 'Review code', + owner: { kind: 'bot', botSlug: 'bob' }, + bot: bob, + }, + { + name: 'alice-intro', + description: 'Introduce', + owner: { kind: 'bot', botSlug: 'bob' }, + bot: bob, + }, + ], + placeholder: 'Message', + sending: false, + onChange: () => undefined, + onSubmit: () => undefined, + }), + ); + }); + const input = container.querySelector('.bh-composer-rich-input')!; + setRichSelection(input, '/alice'.length); + await act(async () => { + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + expect( + [...container.querySelectorAll('.bh-composer-shortcuts button')].map((row) => + row.getAttribute('aria-label'), + ), + ).toEqual(['/alice-intro · Bob', '/triage · Alice', '/notes · 本机']); + } finally { + await act(async () => root.unmount()); + container.remove(); + } + }); }); diff --git a/packages/core/src/bots/cli-skills.ts b/packages/core/src/bots/cli-skills.ts index 0fc2847aa..abd21f16e 100644 --- a/packages/core/src/bots/cli-skills.ts +++ b/packages/core/src/bots/cli-skills.ts @@ -1,3 +1,4 @@ +import type { PersonaBotSummary } from '../bridge/methods.js'; import { dmChannelId, type ChannelSkillInvocationToken } from '../channels/channel.js'; import { parseSkillOwnerKey, rankSkillEntries, type SkillCatalogEntry } from '../skills/entries.js'; import { CliLiveError } from './cli-live-error.js'; @@ -49,9 +50,15 @@ export async function runSkillCommand( const result = (await rpc('channelSkillCatalog', { channelId })) as { skills: SkillCatalogEntry[]; }; + const bots = values.query?.trim() + ? ((await rpc('list', {})) as { bots: PersonaBotSummary[] }).bots + : []; + const names = new Map(bots.map((bot) => [bot.slug, [bot.displayName, bot.slug]])); return { channelId, - skills: rankSkillEntries(result.skills, values.query ?? ''), + skills: rankSkillEntries(result.skills, values.query ?? '', (entry) => + entry.owner.kind === 'host' ? [] : (names.get(entry.owner.botSlug) ?? [entry.owner.botSlug]), + ), }; } diff --git a/packages/core/src/runtime/bot-runtime.ts b/packages/core/src/runtime/bot-runtime.ts index a82bb0ce9..8c8e3f6e6 100644 --- a/packages/core/src/runtime/bot-runtime.ts +++ b/packages/core/src/runtime/bot-runtime.ts @@ -1115,6 +1115,8 @@ interface DigestRow { external_platform?: string | null; external_message_id?: string | null; external_mentions?: string | null; + skill_invocations?: string | null; + human_mentions?: string | null; reply_session_id?: string | null; reply_source_event_id?: string | null; } @@ -1139,7 +1141,20 @@ function mentionedLabel(mentions: MessagingInboundEvent['mentions']): string { : `, mentioned ${mentions.map((mention) => `${mention.name ?? mention.id} (${mention.id})`).join(', ')}`; } +function rowSkillInvocations(row: DigestRow): ChannelSkillInvocation[] | undefined { + return row.author_kind === 'human' && row.skill_invocations + ? (JSON.parse(row.skill_invocations) as ChannelSkillInvocation[]) + : undefined; +} + function groupMessageBody(row: DigestRow): string { + const invocations = rowSkillInvocations(row); + if (invocations?.length) + return sessionSkillText( + row.body, + row.human_mentions ? (JSON.parse(row.human_mentions) as ChannelMention[]) : [], + invocations, + ); return withMentionNames(row.body, rowMentions(row)); } @@ -3842,6 +3857,8 @@ class BotRuntimeImplementation implements BotRuntime { .prepare( ` SELECT a.source_event_id, a.bot_slug, e.message_id, e.body, e.created_at, + json_extract(e.payload_json, '$.skillInvocations') AS skill_invocations, + json_extract(e.payload_json, '$.mentions') AS human_mentions, json_extract(e.payload_json, '$.author.kind') AS author_kind, json_extract(e.payload_json, '$.author.slug') AS author_slug, json_extract(e.payload_json, '$.external.event.actor.name') AS external_sender, @@ -3954,6 +3971,8 @@ class BotRuntimeImplementation implements BotRuntime { }; if (total.count === 0) return undefined; const columns = `SELECT a.source_event_id, a.bot_slug, e.message_id, e.body, e.created_at, + json_extract(e.payload_json, '$.skillInvocations') AS skill_invocations, + json_extract(e.payload_json, '$.mentions') AS human_mentions, json_extract(e.payload_json, '$.author.kind') AS author_kind, json_extract(e.payload_json, '$.author.slug') AS author_slug, json_extract(e.payload_json, '$.external.event.actor.name') AS external_sender, @@ -4026,7 +4045,7 @@ class BotRuntimeImplementation implements BotRuntime { `Channel: ${channel?.name ?? context.channelId} (${context.channelId})`, ...context.rows.map( (row) => - `- Message ${row.message_id} [Source Event ${row.source_event_id}] from ${groupMessageAuthor(row, humanName)} at ${row.created_at}${this.#trustedExternalSender(row)}: ${groupMessageBody(row).slice(0, context.bodyLimit)}${groupMessageBody(row).length > context.bodyLimit ? ` [excerpt; ${groupMessageBody(row).length - context.bodyLimit} more characters available with channel_read]` : ''}`, + `- Message ${row.message_id} [Source Event ${row.source_event_id}] from ${groupMessageAuthor(row, humanName)} at ${row.created_at}${this.#trustedExternalSender(row)}: ${groupMessageBody(row).slice(0, context.bodyLimit)}${groupMessageBody(row).length > context.bodyLimit ? ` [excerpt; ${groupMessageBody(row).length - context.bodyLimit} more characters available with channel_read]` : ''}${skillInvocationPrompt(rowSkillInvocations(row))}`, ), context.omittedCount > 0 ? `${context.omittedCount} earlier or intervening messages remain pending for later turns. Use channel_read if more history is needed.` @@ -4051,7 +4070,7 @@ class BotRuntimeImplementation implements BotRuntime { `${rows.length} ordinary messages are due. ${this.#promptText(botSlug)('digest-hint')}`, ...rows.map( (row) => - `- Message ${row.message_id} [Source Event ${row.source_event_id}] from ${groupMessageAuthor(row, humanName)} at ${row.created_at}${this.#trustedExternalSender(row)}: ${groupMessageBody(row).slice(0, 1000)}`, + `- Message ${row.message_id} [Source Event ${row.source_event_id}] from ${groupMessageAuthor(row, humanName)} at ${row.created_at}${this.#trustedExternalSender(row)}: ${groupMessageBody(row).slice(0, 1000)}${skillInvocationPrompt(rowSkillInvocations(row))}`, ), ...(omittedCount > 0 ? [ diff --git a/packages/core/src/skills/entries.ts b/packages/core/src/skills/entries.ts index e47290fb6..cd2c97830 100644 --- a/packages/core/src/skills/entries.ts +++ b/packages/core/src/skills/entries.ts @@ -23,6 +23,7 @@ export function parseSkillOwnerKey(value: string): SkillOwner | undefined { export function rankSkillEntries( entries: readonly T[], rawQuery: string, + ownerNames?: (entry: T) => readonly string[], ): T[] { const query = rawQuery.trim().toLowerCase(); if (query.length === 0) return [...entries]; @@ -35,12 +36,13 @@ export function rankSkillEntries name.toLowerCase().includes(query))) return 2; + if (entry.description.toLowerCase().includes(query)) return 3; + return 4; }; return entries .map((entry, index) => ({ entry, index, tier: tier(entry) })) - .filter((item) => item.tier < 3) + .filter((item) => item.tier < 4) .sort((a, b) => a.tier - b.tier || a.index - b.index) .map((item) => item.entry); } diff --git a/packages/core/test/bot-live-cli.test.ts b/packages/core/test/bot-live-cli.test.ts index 4c10effb9..4fa816777 100644 --- a/packages/core/test/bot-live-cli.test.ts +++ b/packages/core/test/bot-live-cli.test.ts @@ -79,6 +79,52 @@ async function invoke(argv: string[], url?: string, stdin = '', token = 'private } describe('live CLI over the authenticated DSH carrier', () => { + it.each(['--bot', '--channel'])( + 'ranks Skills by owner display name and slug over the live carrier with %s', + async (flag) => { + const skills = [ + { name: 'description', description: 'Alice workflow', owner: { kind: 'host' } }, + { name: 'review', description: '', owner: { kind: 'bot', botSlug: 'reviewer' } }, + { name: 'alice-report', description: '', owner: { kind: 'host' } }, + { name: 'pdf', description: '', owner: { kind: 'bot', botSlug: 'reviewer' } }, + { name: 'bob-notes', description: '', owner: { kind: 'bot', botSlug: 'bob' } }, + ]; + const live = await host((method) => ({ + ok: true, + value: + method === 'list' + ? { + bots: [ + { slug: 'reviewer', displayName: 'Alice' }, + { slug: 'bob', displayName: 'Bob' }, + ], + } + : { skills }, + })); + const target = flag === '--bot' ? 'reviewer' : 'group-team'; + for (const [query, names] of [ + ['ALICE', ['alice-report', 'review', 'pdf', 'description']], + ['viewer', ['review', 'pdf']], + ] as const) { + const result = await invoke(['skills-list', flag, target, '--query', query], live.url); + expect(result.code).toBe(0); + expect(result.json.channelId).toBe(flag === '--bot' ? 'dm-reviewer' : target); + expect(result.json.skills.map((entry: { name: string }) => entry.name)).toEqual(names); + } + expect(live.calls.filter((call) => call.method === 'list')).toHaveLength(2); + }, + ); + + it('keeps an unfiltered Skill listing to one catalog RPC', async () => { + const live = await host(() => ({ ok: true, value: { skills: [] } })); + const result = await invoke(['skills-list', '--channel', 'group-team'], live.url); + expect(result.code).toBe(0); + expect(result.json.skills).toEqual([]); + expect(live.calls).toEqual([ + { method: 'channelSkillCatalog', args: { channelId: 'group-team' } }, + ]); + }); + it('sends repeated Skill names with distinct owners and spans', async () => { const live = await host((method) => ({ ok: true, diff --git a/packages/core/test/skill-invocation.test.ts b/packages/core/test/skill-invocation.test.ts index 6e1081b12..576c9807b 100644 --- a/packages/core/test/skill-invocation.test.ts +++ b/packages/core/test/skill-invocation.test.ts @@ -3,6 +3,8 @@ import { join } from 'node:path'; import { describe, expect, it, vi } from 'vitest'; +import { createDshImProvider, type DshImOutboundService } from '../src/messaging/dsh-im.js'; +import { parseImCommand } from '../src/messaging/im-commands.js'; import { skillInvocationTokens } from '../src/bots/cli-skills.js'; import { createBridgeMethods } from '../src/bridge/methods.js'; import { OWNER_HUMAN_CONTEXT } from '../src/humans/context.js'; @@ -305,6 +307,52 @@ describe('SkillCatalog', () => { ]); }); + it('ranks owner display names and slugs below name matches and above descriptions stably', () => { + const entries = [ + { name: 'desc-first', description: 'Alice workflow', owners: [] }, + { name: 'review', description: '', owners: ['ALICE', 'reviewer'] }, + { name: 'alice-two', description: '', owners: [] }, + { name: 'a-l-i-c-e', description: '', owners: [] }, + { name: 'pdf', description: '', owners: ['Alice', 'reviewer'] }, + { name: 'alice-one', description: '', owners: [] }, + { name: 'desc-second', description: 'alice notes', owners: [] }, + { name: 'other', description: '', owners: ['Bob'] }, + ]; + expect( + rankSkillEntries(entries, ' ALiCe ', (entry) => entry.owners).map((entry) => entry.name), + ).toEqual([ + 'alice-two', + 'alice-one', + 'a-l-i-c-e', + 'review', + 'pdf', + 'desc-first', + 'desc-second', + ]); + expect( + rankSkillEntries(entries, 'VIEWER', (entry) => entry.owners).map((entry) => entry.name), + ).toEqual(['review', 'pdf']); + expect( + rankSkillEntries(entries, 'lice', (entry) => entry.owners).map((entry) => entry.name), + ).toContain('pdf'); + expect(rankSkillEntries(entries, '', (entry) => entry.owners)).toEqual(entries); + expect(rankSkillEntries(entries, 'missing', (entry) => entry.owners)).toEqual([]); + expect(entries[0]?.name).toBe('desc-first'); + }); + + it('bounds owner lookups to one per unmatched Skill and skips them for an empty query', () => { + const entries = Array.from({ length: 1000 }, (_, index) => ({ + name: `skill-${index}`, + description: '', + })); + const owners = vi.fn(() => ['Alice', 'reviewer']); + expect(rankSkillEntries(entries, 'alice', owners)).toEqual(entries); + expect(owners).toHaveBeenCalledTimes(entries.length); + owners.mockClear(); + expect(rankSkillEntries(entries, ' ', owners)).toEqual(entries); + expect(owners).not.toHaveBeenCalled(); + }); + it('ranks name prefix, then name subsequence, then description', () => { const entries = [ { name: 'zeta', description: 'about pr reviews' }, @@ -616,6 +664,233 @@ describe('Skill Invocation in a Human DM', () => { }); }); +describe('Skill Invocation in a Group Channel', () => { + it.each(['mention-b', 'ordinary-b', 'mentions-only', 'pending-context'] as const)( + 'keeps the Skill owner independent from routing: %s', + async (routing) => { + const runs: OrchestratorAgentRun[] = []; + const core = createCore({ + dshHome: createTempRoot('botharness-group-skill-'), + agents: adapter(async (run) => { + runs.push(run); + }), + }); + try { + core.registry.create({ slug: 'a', displayName: 'Alice' }); + core.registry.create({ slug: 'b', displayName: 'Bob' }); + const group = core.channels.createGroup(OWNER_HUMAN_CONTEXT, { + name: 'Team', + members: ['a', 'b'], + }); + for (const slug of group.members) + core.channels.setGroupWakePolicy( + group.id, + slug, + { + mode: routing === 'ordinary-b' && slug === 'b' ? 'all' : 'mentions', + count: 5, + intervalSeconds: 30, + }, + { kind: 'human' }, + ); + const memory = core.registry.memoryDirFor('a')!; + const path = join(memory, '.agents/skills/review/SKILL.md'); + const content = 'Use this exact snapshot from Alice.'.repeat(40); + const definitions = [skill('review', 'project-agents', path, content)]; + const catalog = new SkillCatalog({ + memoryDirFor: (slug) => core.registry.memoryDirFor(slug), + viewFor: liveView, + }); + catalog.attach( + registry( + [skill('pdf', 'user-agents', '/host/pdf/SKILL.md')], + new Map([ + [memory, definitions], + [ + core.registry.memoryDirFor('b')!, + [ + skill( + 'notes', + 'project-agents', + join(core.registry.memoryDirFor('b')!, '.agents/skills/notes/SKILL.md'), + ), + ], + ], + ]), + ), + ); + const methods = createBridgeMethods(OWNER_HUMAN_CONTEXT, { ...core, skills: catalog }); + const listed = await methods.channelSkillCatalog({ channelId: group.id }); + expect(listed).toMatchObject({ + ok: true, + value: { + skills: [ + { name: 'review', owner: { kind: 'bot', botSlug: 'a' } }, + { name: 'notes', owner: { kind: 'bot', botSlug: 'b' } }, + { name: 'pdf', owner: { kind: 'host' } }, + ], + }, + }); + const body = routing === 'mention-b' ? '/review @Bob' : '/review'; + const sent = await methods.channelSend({ + channelId: group.id, + body, + ...(routing === 'mention-b' + ? { mentions: [{ botSlug: 'b', label: 'Bob', start: 8, end: 12 }] } + : {}), + skillInvocations: [ + { name: 'review', owner: { kind: 'bot', botSlug: 'a' }, start: 0, end: 7 }, + ], + }); + expect(sent.ok).toBe(true); + if (!sent.ok) throw new Error(sent.error.message); + definitions.splice(0); + await core.runtime.whenIdle(); + if (routing === 'pending-context') { + expect(runs).toEqual([]); + expect( + await methods.channelSend({ + channelId: group.id, + body: '@Bob check pending context', + mentions: [{ botSlug: 'b', label: 'Bob', start: 0, end: 4 }], + }), + ).toMatchObject({ ok: true }); + await core.runtime.whenIdle(); + } + expect(runs.map((run) => run.bot.slug)).toEqual(routing === 'mentions-only' ? [] : ['b']); + const stored = core.channels.message(group.id, sent.value.message.id)!; + expect(stored.skillInvocations).toEqual([ + { name: 'review', owner: { kind: 'bot', botSlug: 'a' }, start: 0, end: 7, path, content }, + ]); + if (routing !== 'mentions-only') { + expect(runs[0]?.message).toContain('[/review]'); + expect(runs[0]?.message).toContain( + `\n${content}\n`, + ); + } + } finally { + core.externalMessaging.close(); + await core.runtime.close(); + core.operationalDatabase.close(); + } + }, + ); + + it.each(['discord', 'feishu'] as const)( + 'mirrors the persisted Human body through %s as plain /name without snapshot metadata', + async (platform) => { + const core = createCore({ dshHome: createTempRoot('botharness-skill-im-') }); + try { + core.registry.create({ slug: 'a', displayName: 'Alice' }); + const group = core.channels.createGroup(OWNER_HUMAN_CONTEXT, { + name: 'Team', + members: ['a'], + }); + const catalog = new SkillCatalog({ + memoryDirFor: (slug) => core.registry.memoryDirFor(slug), + viewFor: liveView, + }); + catalog.attach( + registry( + [skill('pdf', 'user-agents', '/host/pdf/SKILL.md', 'PRIVATE_SNAPSHOT_CONTENT')], + new Map(), + ), + ); + const methods = createBridgeMethods(OWNER_HUMAN_CONTEXT, { ...core, skills: catalog }); + const sent = await methods.channelSend({ + channelId: group.id, + body: 'please /pdf now', + skillInvocations: [{ name: 'pdf', owner: { kind: 'host' }, start: 7, end: 11 }], + }); + if (!sent.ok) throw new Error(sent.error.message); + const message = core.channels.message(group.id, sent.value.message.id)!; + expect(message.skillInvocations?.[0]?.content).toBe('PRIVATE_SNAPSHOT_CONTENT'); + const sendChecked = vi.fn(async () => ({ + sent: true, + })); + const fingerprint = 'a'.repeat(64); + const service: DshImOutboundService = { + contractVersion: 1, + listBots: async () => [{ botId: 'app', channel: platform }], + listTargets: async () => [{ targetId: 'team', kind: 'group', route: { chatId: 'team' } }], + describeBot: async () => ({ + version: 1, + botId: 'app', + channel: platform, + connected: true, + account: { fingerprint }, + capabilities: ['proactive-text-checked'], + }), + sendChecked, + }; + const provider = createDshImProvider(service, platform)!; + core.externalMessaging.register(provider); + const target = (await core.externalMessaging.targets(provider.id, 'app'))[0]!; + const grant = await core.externalMessaging.authorize({ + botSlug: 'a', + providerId: provider.id, + accountRef: 'app', + targetRef: target.ref, + fingerprint, + targetDigest: target.digest, + }); + const mirrored = await core.externalMessaging.send('a', grant.id, message.id, message.body); + expect(mirrored.state).toBe('provider-accepted'); + expect(sendChecked).toHaveBeenCalledTimes(1); + const text = sendChecked.mock.calls[0]?.[2]; + expect(text).toBe('please /pdf now'); + expect(text).not.toMatch(/\[\/pdf\]|<|>|PRIVATE_SNAPSHOT_CONTENT/u); + expect(sendChecked.mock.calls[0]?.[3].format).toBe('plain'); + } finally { + core.externalMessaging.close(); + await core.runtime.close(); + core.operationalDatabase.close(); + } + }, + ); + + it('keeps a same-named bare IM command reserved without turning bare Channel text into a Skill', async () => { + expect(parseImCommand('/status')).toMatchObject({ name: 'status' }); + expect(skillInvocationTokens('/status', undefined)).toEqual([]); + const core = createCore({ dshHome: createTempRoot('botharness-skill-command-') }); + try { + core.registry.create({ slug: 'a', displayName: 'Alice' }); + const group = core.channels.createGroup(OWNER_HUMAN_CONTEXT, { + name: 'Team', + members: ['a'], + }); + const catalog = new SkillCatalog({ + memoryDirFor: (slug) => core.registry.memoryDirFor(slug), + viewFor: liveView, + }); + catalog.attach( + registry([skill('status', 'user-agents', '/host/status/SKILL.md')], new Map()), + ); + const methods = createBridgeMethods(OWNER_HUMAN_CONTEXT, { ...core, skills: catalog }); + expect(await methods.channelSkillCatalog({ channelId: group.id })).toMatchObject({ + ok: true, + value: { skills: [{ name: 'status' }] }, + }); + expect(await methods.channelSend({ channelId: group.id, body: '/status' })).toMatchObject({ + ok: true, + value: { message: { body: '/status' } }, + }); + expect(core.channels.readMessages(group.id)[0]?.skillInvocations).toBeUndefined(); + expect( + await methods.channelSend({ + channelId: group.id, + body: '/status', + skillInvocations: [{ name: 'status', owner: { kind: 'host' }, start: 0, end: 7 }], + }), + ).toMatchObject({ ok: true, value: { message: { skillInvocations: [{ name: 'status' }] } } }); + } finally { + core.externalMessaging.close(); + await core.runtime.close(); + core.operationalDatabase.close(); + } + }); +}); + describe('Skill Invocation text', () => { it('marks only picked tokens and escapes the path attribute', () => { const invocations = [