dsh-a2a-groups 0.0.0-stage → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README-ui.md +350 -0
- package/README.md +160 -2
- package/bin/agent-groups.js +438 -0
- package/client.js +3782 -0
- package/cordis.patch.yml +17 -0
- package/docs/ARCHITECTURE.md +281 -0
- package/docs/BACKLOG.md +201 -0
- package/docs/CONTRACT.md +592 -0
- package/docs/DEPLOY.md +645 -0
- package/docs/DESIGN.md +390 -0
- package/docs/MCP.md +260 -0
- package/docs/PRODUCT.md +186 -0
- package/docs/RUNBOOK.md +406 -0
- package/lib/core/bridge.js +1167 -0
- package/lib/core/card.js +217 -0
- package/lib/core/colleague-cards.js +149 -0
- package/lib/core/config.js +235 -0
- package/lib/core/endpoint.js +69 -0
- package/lib/core/groups.js +3010 -0
- package/lib/core/invite.js +412 -0
- package/lib/core/memberships.js +366 -0
- package/lib/core/probe.js +219 -0
- package/lib/core/registry.js +344 -0
- package/lib/core/server.js +1304 -0
- package/lib/core/skills-group.js +1164 -0
- package/lib/core/skills-session.js +116 -0
- package/lib/core/skills.js +76 -0
- package/lib/core/wire.js +772 -0
- package/lib/dsh/deliver.js +601 -0
- package/lib/dsh/inbox.js +658 -0
- package/lib/dsh/profile.js +195 -0
- package/lib/dsh/prompt.js +55 -0
- package/lib/dsh/sessions.js +278 -0
- package/lib/dsh/toolkit.js +166 -0
- package/lib/dsh/tools.js +101 -0
- package/lib/index.js +648 -0
- package/lib/mcp/mcp-deps.js +145 -0
- package/lib/mcp-server.js +261 -0
- package/lib/shared/tool-runtime.js +1349 -0
- package/lib/shared/tool-specs.js +1123 -0
- package/lib/types/client.d.ts +144 -0
- package/package.json +84 -4
package/docs/CONTRACT.md
ADDED
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
# dsh-a2a 模块契约(并行开发必须遵守)
|
|
2
|
+
|
|
3
|
+
> 这份文件是**唯一的接口真相**。三个开发分支按此实现,Lead 负责集成与最终验证。
|
|
4
|
+
> 任何一方要改这些签名,先改这份文件并在团队消息里说明,不要私自改。
|
|
5
|
+
|
|
6
|
+
## 0. 硬约束(所有模块)
|
|
7
|
+
|
|
8
|
+
1. **零外部依赖**:只允许 `node:*` 与相对路径 import。`@deepseek-ai/*` 只能在 Host 通过 `ctx` 注入,绝不 `import`。
|
|
9
|
+
- 原因:profile/workspace 插件由普通 Node 解析加载,bare specifier 解析不到 DSH 安装目录。
|
|
10
|
+
2. **ESM**,Node ≥ 22,纯 `.js`(不引入 TypeScript 构建)。
|
|
11
|
+
3. 每个文件顶部写模块级 JSDoc:这文件解决什么问题、边界在哪。
|
|
12
|
+
4. 不用 `#private` 字段以外的花活;导出命名函数 + 一个 factory。
|
|
13
|
+
5. 时间一律 epoch 毫秒在内部流转,只在**边界**(日志/输出)转 ISO。
|
|
14
|
+
6. 不打印凭据:`token` / `claimKey` / `memberKey` / `ownerKey` / `apiKey` 在日志与错误信息里必须脱敏成 `sha256:前8位`。
|
|
15
|
+
|
|
16
|
+
## 1. A2A 线格式(决策:**1.0**)
|
|
17
|
+
|
|
18
|
+
调用方(网关、其他 A2A 客户端)说的是 A2A **1.0**:
|
|
19
|
+
|
|
20
|
+
| 维度 | 取值 |
|
|
21
|
+
|---|---|
|
|
22
|
+
| 方法名 | PascalCase:`SendMessage` / `SendStreamingMessage` / `GetTask` / `CancelTask` |
|
|
23
|
+
| 一元结果 | `{"jsonrpc":"2.0","id":…,"result":{"task":{…}}}` 或 `{"result":{"message":{…}}}` |
|
|
24
|
+
| 流式结果 | `{"result":{"statusUpdate":{…}}}`、`{"result":{"artifactUpdate":{…}}}` |
|
|
25
|
+
| 任务状态 | protobuf 枚举名:`TASK_STATE_SUBMITTED` / `_WORKING` / `_COMPLETED` / `_CANCELED` / `_FAILED` / `_INPUT_REQUIRED` / `_AUTH_REQUIRED` / `_REJECTED` |
|
|
26
|
+
| 请求消息 | `params.message` = `{role:"user", messageId, contextId?, parts:[{kind:"text", text}], metadata?}` |
|
|
27
|
+
| 流结束 | `statusUpdate` 上带 `final: true` |
|
|
28
|
+
| 版本头 | 每个请求带 `A2A-Version: 1.0`;缺省按 `0.3` 理解 → 我们**显式拒绝**不匹配的版本 |
|
|
29
|
+
|
|
30
|
+
**技能选择**:`params.message.metadata.skill`(字符串)。缺省 = `session/ask`。
|
|
31
|
+
|
|
32
|
+
**我们的技能表**(`metadata.skill` 的合法值):
|
|
33
|
+
|
|
34
|
+
| skill | 输入 | 输出 |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| `session/ask` | `text` + 可选 `data.timeoutMs` | artifact `reply`,一键把事情投进绑定会话并等回合结束 |
|
|
37
|
+
| `ping` | 无 | artifact `pong`,回带 `sessionId`/`version`,用于连通性自检 |
|
|
38
|
+
| `group/create` | `data.name/topic?/ownerDisplayName?/visibility?` | artifact `group`(含 `groupId`、**仅此一次**的 ownerKey) |
|
|
39
|
+
| `group/invite` | `data.groupId/memberId/memberKey/label?/expiresInHours?` | artifact `invite`(链接 + 提示词文本)。`expiresInHours: null` = 永不过期;不传 = 配置默认(7 天)。**无次数限制**,只有群主可发 |
|
|
40
|
+
| `group/join` | `data.groupId/token/displayName?/endpoint?` | artifact `member`(`memberId`、`memberKey`、群摘要、`feedTail`)。**只需 token,无密码** |
|
|
41
|
+
| `group/post` | `data.groupId/memberId/memberKey/text/payload?` | artifact `posted`(`seq`、`msgId`) |
|
|
42
|
+
| `group/read` | `data.groupId/memberId/memberKey/sinceSeq?/limit?` | artifact `feed`(增量条目) |
|
|
43
|
+
| `group/subscribe` | 同上 + `sinceSeq` | **流式**:每条新帖一个 `artifactUpdate`,心跳 `statusUpdate working` |
|
|
44
|
+
| `group/members` | `data.groupId/memberId/memberKey` | artifact `members` |
|
|
45
|
+
| `group/info` | `data.groupId/memberId/memberKey` | artifact `info`(`name`/`topic`/`memberCount`/`onlineCount`/`lastSeq`/`lastPostAt`/`isOwner`)。**不含名单** |
|
|
46
|
+
| `group/directory` | `data.query?/limit?/offset?` — **无凭证** | artifact `directory`:只含 `groupId/name/topic/memberCount/onlineCount/lastActivityAt/lastSeq`。`unlisted` 的群**完全不出现在结果里**(不是标了隐藏) |
|
|
47
|
+
| `group/role` | `data.groupId/memberId/memberKey/roleAbout?/tags?` | artifact `member`(层 A:按群独立的自我描述,只能改自己) |
|
|
48
|
+
| `group/leave` | 同上 | artifact `left` |
|
|
49
|
+
| `group/request` | `data.groupId/displayName/reason?` — **无凭证** | artifact `request`(`requestId` 公开队列索引 + `claimKey` **仅此一次**的秘密)。`unlisted` 的群返回 `unknown_group` |
|
|
50
|
+
| `group/requests` | `data.groupId/memberId/memberKey/status?` — **仅群主** | artifact `requests`(待审列表,含申请人的名字与理由) |
|
|
51
|
+
| `group/decide` | 同上 + `requestId/approve` | artifact `decided`。**批准不直接建成员**——申请人用自己的 `claimKey` 去领 |
|
|
52
|
+
| `group/claim` | `data.groupId/requestId/claimKey` — **无群密码** | artifact `member`(领取已批准的成员身份;`claimKey` 一次性) |
|
|
53
|
+
|
|
54
|
+
**为什么 `group/claim` 不需要密码**:群密码的全部作用是"阻止不认识的人",
|
|
55
|
+
而审批本身就是"我认识并同意"。两条入群路径的凭证因此不同且不重叠:
|
|
56
|
+
|
|
57
|
+
| | 邀请链接 | 申请审批 |
|
|
58
|
+
|---|---|---|
|
|
59
|
+
| 凭证 | `token`(群主批量发) | `claimKey`(申请人自己持有) |
|
|
60
|
+
| 有效期 | 可设,含永不过期 | 固定 7 天 |
|
|
61
|
+
| 次数 | 不限 | 一次 |
|
|
62
|
+
| 撤销 | `revokeInvite` 精确撤那一条 | 拒绝即结束 |
|
|
63
|
+
|
|
64
|
+
错误码(JSON-RPC `error.code`):
|
|
65
|
+
|
|
66
|
+
| code | 含义 |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `-32700` / `-32600` / `-32601` / `-32602` / `-32603` | JSON-RPC 标准(解析/请求/方法/参数/内部) |
|
|
69
|
+
| `-32001` | 未认证(缺/错 `x-api-key`、缺 `memberKey`) |
|
|
70
|
+
| `-32002` | 未授权(不是成员、令牌过期/撤销/耗尽) |
|
|
71
|
+
| `-32003` | 版本不支持(`A2A-Version` 不匹配) |
|
|
72
|
+
| `-32004` | 业务拒绝,`data.code` 带稳定子码(见下表) |
|
|
73
|
+
|
|
74
|
+
业务子码(`data.code`,稳定,可被程序判断):
|
|
75
|
+
|
|
76
|
+
| 子码 | 含义 |
|
|
77
|
+
|---|---|
|
|
78
|
+
| `invite_invalid` / `invite_expired` / `invite_revoked` | 邀请不可用 |
|
|
79
|
+
| `request_pending` / `request_rejected` / `request_expired` / `request_invalid` / `request_not_found` | 申请不可领取 |
|
|
80
|
+
| `not_owner` | 非群主(发邀请、看申请、审批) |
|
|
81
|
+
| `not_member` | 凭证无效或不是成员 |
|
|
82
|
+
| `unknown_group` | 群不存在,或(对无凭证路径)是 `unlisted` 因而不承认存在 |
|
|
83
|
+
| `missing_gateway` / `missing_agent_name` | 生成邀请前未配置网关坐标 |
|
|
84
|
+
| `group_full` / `too_long` / `empty_message` / `rate_limited` | 容量与内容限制 |
|
|
85
|
+
|
|
86
|
+
## 2. 模块职责与导出
|
|
87
|
+
|
|
88
|
+
### 2.0 目录边界:一句话判断一个文件属于哪个宿主
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
lib/
|
|
92
|
+
├── core/ 零 DSH 依赖,任何宿主可复用
|
|
93
|
+
├── dsh/ 唯一读 ctx 的地方
|
|
94
|
+
├── shared/ 两个宿主共用,谁也不属于
|
|
95
|
+
├── mcp/ 唯一读 MCP 协议的地方
|
|
96
|
+
├── index.js DSH 插件入口(包 main)
|
|
97
|
+
└── mcp-server.js MCP 入口(bin: agent-groups-mcp)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
| 目录 | 文件 | 判据 |
|
|
101
|
+
|---|---|---|
|
|
102
|
+
| `core/` | groups, memberships, invite, card, wire, server, bridge, registry, skills, skills-group, skills-session, probe, config | 不 import `ctx`,只用 `node:*` 和彼此 |
|
|
103
|
+
| `dsh/` | deliver, sessions, inbox, tools, toolkit, profile, prompt | 读 `ctx` |
|
|
104
|
+
| `shared/` | tool-specs, tool-runtime | 纯数据 + 纯逻辑,两个宿主都调 |
|
|
105
|
+
| `mcp/` | mcp-deps | 为"没有 DSH 的进程"接线 |
|
|
106
|
+
|
|
107
|
+
**这条边界是可检查的,不是口号**:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
# 除 index.js 外,lib/dsh/ 之外不应出现"真实"的 ctx 代码(注释不算)
|
|
111
|
+
grep -rn '\bctx\b' lib/ --include='*.js' | grep -v '^lib/dsh/' | grep -v '^lib/index.js'
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
目前只剩 `lib/core/server.js` 的 3 处,全部是**可选注入**:
|
|
115
|
+
|
|
116
|
+
```js
|
|
117
|
+
createServer({ ctx, registrar = ctx?.webServer, ... }) // 有 ctx 就用,没有就靠 registrar
|
|
118
|
+
function originProvider() { const webServer = ctx?.webServer; ... } // 拿不到就返回 undefined
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
这正是 `core/` 能同时服务两种宿主的机制:DSH 传 `ctx`,独立宿主传 `registrar`,
|
|
122
|
+
同一个文件两种用法。`ctx?.` 的可选链是让"没有 DSH"成为一种正常情况,而不是异常路径。
|
|
123
|
+
|
|
124
|
+
**为什么 `index.js` 和 `mcp-server.js` 留在 `lib/` 而不是搬进子目录**:它们是
|
|
125
|
+
`package.json` 的入口。插件是通过 junction 装进 DSH profile 的,改包入口会破坏一个
|
|
126
|
+
正在运行的安装,而收益只是路径好看一点。
|
|
127
|
+
|
|
128
|
+
### 2.1 `lib/core/wire.js`(分支 A)
|
|
129
|
+
|
|
130
|
+
纯粹的 A2A 1.0 编解码 + 任务存储。**不含任何 DSH 概念**。
|
|
131
|
+
|
|
132
|
+
```js
|
|
133
|
+
export const TASK_STATES = {...}; // 枚举名常量
|
|
134
|
+
export function toWireState(internal); // 'working' → 'TASK_STATE_WORKING'
|
|
135
|
+
export function fromWireState(wire); // 反向,未知 → 'unknown'
|
|
136
|
+
export function rpcResult(id, payload); // payload = {task|message|statusUpdate|artifactUpdate}
|
|
137
|
+
export function rpcError(id, code, message, data); // data 可选
|
|
138
|
+
export function textOfMessage(message); // parts 里 kind==='text' 拼接
|
|
139
|
+
export function makeMessage({role, text, messageId, contextId, metadata}); // 造 1.0 消息
|
|
140
|
+
export function makeArtifact({artifactId, name, text}); // {kind:'text'} part
|
|
141
|
+
export function statusUpdate({taskId, contextId, state, note, final});
|
|
142
|
+
export function artifactUpdate({taskId, contextId, artifact, append, lastChunk});
|
|
143
|
+
export function writeSseHead(res); // 设 SSE 头(一次)
|
|
144
|
+
export function writeSse(res, payload); // 写一帧 `data: <json>\n\n`
|
|
145
|
+
export function writeComment(res, text); // 心跳注释 `: text\n\n`
|
|
146
|
+
export class TaskStore { ... } // 内存 + 可选落盘 + TTL
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`TaskStore` 契约:
|
|
150
|
+
|
|
151
|
+
```js
|
|
152
|
+
new TaskStore({ ttlMs = 24*3600e3, persistPath, clock, limit = 500 })
|
|
153
|
+
store.create({ contextId, metadata }) // → { id, contextId, state:'submitted', … }
|
|
154
|
+
store.update(id, { state, note }) // → 任务;未知 id 返回 undefined
|
|
155
|
+
store.attachArtifact(id, artifact) // 追加 artifact
|
|
156
|
+
store.get(id) // → 任务 | undefined
|
|
157
|
+
store.cancel(id) // → {task, changed:boolean}
|
|
158
|
+
store.list({ limit }) // 最新在前
|
|
159
|
+
store.onChange(fn) // → dispose;fn(task),供订阅者使用
|
|
160
|
+
store.flush() // 落盘(best-effort,不抛)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### 2.2 `lib/dsh/deliver.js`(分支 A)
|
|
164
|
+
|
|
165
|
+
把「一段外部文本」变成「目标会话里真实的一轮」,是 session-bus 语义的最小复用。
|
|
166
|
+
|
|
167
|
+
```js
|
|
168
|
+
export function createDeliverer({ ctx, logger, config });
|
|
169
|
+
// → { deliver(input) → Promise<receipt>, isBusy(sessionId), waitIdle(sessionId, ms) }
|
|
170
|
+
|
|
171
|
+
// input: { sessionId, text, messageId, mode='queue'|'steer'|'inbox', source }
|
|
172
|
+
// receipt: { injected:'followup'|'steer'|'inbox'|'duplicate', interrupted:boolean }
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
要求:
|
|
176
|
+
- 优先走 `ctx.sessionController.resolveAgent(sessionId)` → `agent.followup|steer|inject`,带 `source.kind='agent-message'`(左侧卡片,不伪装成本人输入)。
|
|
177
|
+
- Host 不支持 agent-sourced 投递时,退回 `ctx.sessionController.prompt({requestId, sessionId, mode, content})`。
|
|
178
|
+
- **幂等**:同 `messageId` 已在本会话 inbox/日志里就不重复投(按 `source.busMessageId` 或 `source.a2aMessageId` 查)。
|
|
179
|
+
- 失败要回滚:投递抛错就不要留下任何副作用。
|
|
180
|
+
|
|
181
|
+
### 2.3 `lib/core/server.js`(分支 A)
|
|
182
|
+
|
|
183
|
+
```js
|
|
184
|
+
export function createServer({ ctx, logger, config, deliverer, tasks, skills });
|
|
185
|
+
// → { attach(), detach(), card(), info() }
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
- `attach()`:用 `ctx.webServer.register({kind:'exact', path:'/a2a', handler})` 与 `card` 路由;返回 disposer。
|
|
189
|
+
- 路由:
|
|
190
|
+
- `GET /a2a/.well-known/agent-card.json`(+ `agent.json` 别名)
|
|
191
|
+
- `GET /a2a/.well-known/` 前缀下其它路径 → 404 JSON
|
|
192
|
+
- `POST /a2a` → JSON-RPC 分发
|
|
193
|
+
- 鉴权:`config.server.token`,接受 `Authorization: Bearer <t>` 或 `x-api-key: <t>`;未配置 token 时**只允许 loopback**(`req.socket.remoteAddress` 是 127.0.0.1/::1)并打一条 warn。
|
|
194
|
+
- 版本校验:`A2A-Version` 缺失按 `0.3`;不是 `1.0` → `-32003`。
|
|
195
|
+
- `message` 一元 → 返回 `result.task`;`SendStreamingMessage` → SSE。
|
|
196
|
+
- 请求体上限 `config.server.maxBodyBytes`(默认 1 MiB),超限 413。
|
|
197
|
+
- 超时:技能执行超过 `config.server.taskTimeoutMs`(默认 10 min)→ 任务 `TASK_STATE_FAILED` + JSON-RPC 错误。
|
|
198
|
+
- `card()` 返回的对象**必须**包含:`name`、`description`、`url`(`config.server.publicUrl`)、`version`、`protocolVersion:'1.0'`、`preferredTransport:'JSONRPC'`、`capabilities`、`defaultInputModes/OutputModes`、`supportedInterfaces:[{url, protocolBinding:'JSONRPC', protocolVersion:'1.0'}]`、`skills[]`。
|
|
199
|
+
|
|
200
|
+
`skills` 是注入的注册表,形状:
|
|
201
|
+
|
|
202
|
+
```js
|
|
203
|
+
{ get(id) → { id, name, description, examples, run(input, context) → Promise<result> } | undefined,
|
|
204
|
+
list() → [meta…] }
|
|
205
|
+
// run 的 context: { taskId, contextId, signal, deliverer, tasks, logger, now() }
|
|
206
|
+
// run 返回: { text, artifacts?: [artifact], finalState? }
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### 2.4 `lib/core/bridge.js`(分支 B)
|
|
210
|
+
|
|
211
|
+
A2A **客户端**。零依赖,用全局 `fetch`。
|
|
212
|
+
|
|
213
|
+
```js
|
|
214
|
+
export function createBridge({ logger, config, clock });
|
|
215
|
+
// → {
|
|
216
|
+
// discover(target) → Promise<card>, // 带缓存(ttlMs)
|
|
217
|
+
// send(target, { skill, text, data, contextId, timeoutMs }) → Promise<{taskId, state, text, raw}>,
|
|
218
|
+
// stream(target, { skill, text, data, sinceSeq, signal, onEvent }) → Promise<{taskId, state}>
|
|
219
|
+
// invalidate(target)
|
|
220
|
+
// }
|
|
221
|
+
|
|
222
|
+
// target: { baseUrl, agent?, apiKey?, protocolVersion?, forwardProto? }
|
|
223
|
+
// baseUrl 形如 http://gw:3000/a2a/<agent> 或直连 http://host:3091/a2a
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
要求:
|
|
227
|
+
- 请求头固定带 `A2A-Version: <protocolVersion ?? '1.0'>`;有 `apiKey` 带 `x-api-key`;`forwardProto` 有值时带 `X-Forwarded-Proto`。
|
|
228
|
+
- 一元用 `SendMessage`,流式用 `SendStreamingMessage`。
|
|
229
|
+
- SSE 解析:支持跨 chunk 分帧、`data:` 多行、注释行忽略、`[DONE]` 容错。
|
|
230
|
+
- **断线重连**:`stream` 收到异常 EOF 且给了 `sinceSeq` 时按指数退避重连(最多 `config.client.reconnectAttempts`,默认 3),通过 `onEvent({type:'reconnect', attempt})` 告知。
|
|
231
|
+
- 错误分类:`{kind:'auth'|'permission'|'version'|'network'|'rpc', code?, message}`,抛 `A2aClientError`。
|
|
232
|
+
- 卡片 `url` 的 scheme 若是 `https` 但 `baseUrl` 是 `http` 且端口不同 → 直接用 `baseUrl`(`X-Forwarded-Proto` 缺失导致的可预期偏差),只记 warn。
|
|
233
|
+
|
|
234
|
+
### 2.5 `lib/core/registry.js`(分支 B)
|
|
235
|
+
|
|
236
|
+
对端登记簿,落盘 `<DSH_HOME>/a2a/agents.json`。
|
|
237
|
+
|
|
238
|
+
```js
|
|
239
|
+
export function createRegistry({ home, logger });
|
|
240
|
+
// → { list(), get(name), upsert(entry), remove(name), resolve(nameOrUrl) → target, flush() }
|
|
241
|
+
// entry: { name, description?, baseUrl, agent?, protocolVersion?, apiKey?, enabled }
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### 2.6 `lib/core/groups.js`(分支 C)
|
|
245
|
+
|
|
246
|
+
群状态机 + 加密 + 邀请 + feed。**纯逻辑,不碰网络、不碰 DSH**。
|
|
247
|
+
|
|
248
|
+
```js
|
|
249
|
+
export function createGroupStore({ home, logger, clock, config });
|
|
250
|
+
// → {
|
|
251
|
+
// create({ name, topic, ownerDisplayName, settings }) → { group, ownerKey },
|
|
252
|
+
// invite({ groupId, ownerKey, label, expiresInHours }) → { token, link, prompt, expiresAt, inviteId },
|
|
253
|
+
// // ownerKey 必填(只有群主能发邀请);expiresInHours: null = 永不过期
|
|
254
|
+
// join({ groupId, token, displayName, endpoint }) → { member, memberKey, group, feedTail },
|
|
255
|
+
// authenticate({ groupId, memberId, memberKey }) → member | undefined,
|
|
256
|
+
// post({ groupId, member, text, payload }) → { seq, msgId, at },
|
|
257
|
+
// read({ groupId, member, sinceSeq, limit }) → { entries, lastSeq, truncated },
|
|
258
|
+
// members({ groupId, member }) → [publicMember…],
|
|
259
|
+
// leave({ groupId, member }) → boolean,
|
|
260
|
+
// revokeInvite({ groupId, ownerKey, token }) → boolean,
|
|
261
|
+
// subscribe({ groupId, fn }) → dispose, // 新帖回调,供 group/subscribe 用
|
|
262
|
+
// snapshot({ groupId, member }) → 摘要(不含任何密钥;带 member 时含 feedTail)
|
|
263
|
+
// listDirectory({ query, limit, offset }) → 频道目录页(无凭证,无名单)
|
|
264
|
+
// requestJoin({ groupId, displayName, reason }) → { requestId, claimKey, expiresAt },
|
|
265
|
+
// listRequests({ groupId, member, status }) → [请求视图](仅群主),
|
|
266
|
+
// decideRequest({ groupId, member, requestId, approve }) → { status }(仅群主),
|
|
267
|
+
// claimJoin({ groupId, requestId, claimKey }) → { member, memberKey, group },
|
|
268
|
+
// flush()
|
|
269
|
+
// }
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
安全要求(必须实现,验收会测):
|
|
273
|
+
- **没有群密码**。入群凭证只有两种,互不重叠:邀请 `token`、审批后领取用的 `claimKey`。
|
|
274
|
+
- 邀请令牌 / 成员凭证 / `claimKey`:存 `sha256` 十六进制,**明文只在创建/加入的响应里出现一次**。
|
|
275
|
+
- 常量时间比较(`crypto.timingSafeEqual`)。
|
|
276
|
+
- 邀请:`expiresAt`(可为 `null` = 永不过期)+ `revoked`。**没有次数计数**——撤销才是精确控制。
|
|
277
|
+
- 申请:`expiresAt` 固定 7 天;一条申请只能被领取一次(状态机 `pending → approved/rejected → claimed`)。
|
|
278
|
+
- feed:`seq` 单调递增,`msgId` 为 uuid;`retentionDays` 之外的老条目在 `flush` 时裁掉。
|
|
279
|
+
- 令牌桶:每成员 `maxPostsPerMinute`(默认 20),超限抛 `code:'rate_limited'`。
|
|
280
|
+
- 落盘文件权限 `0o600`,目录 `0o700`;写入用「临时文件 + rename」。
|
|
281
|
+
- `snapshot()` / `members()` / `listDirectory()` 绝不能带出 `tokenHash` / `keyHash` / `claimHash`。
|
|
282
|
+
|
|
283
|
+
`unlisted` 的语义(验收会测):`listDirectory` 里**整行不返回**,`requestJoin` 返回 `unknown_group`。
|
|
284
|
+
它不是"标了隐藏"——那等于回答了"存在这个 id 的群",是一个存在性探测。
|
|
285
|
+
|
|
286
|
+
邀请链接格式(固定,UI 与提示词都依赖):
|
|
287
|
+
|
|
288
|
+
```
|
|
289
|
+
agent-a2a://group?v=1&gw=<网关基址>&agent=<网关里的 agent 名>&g=<groupId>&t=<token>&n=<群名>&e=<expiresAt ISO>
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
`e=` 在永不过期的邀请里省略。
|
|
293
|
+
|
|
294
|
+
提示词(`invite()` 返回的 `prompt`,中文,可直接粘到 IM):
|
|
295
|
+
|
|
296
|
+
```
|
|
297
|
+
我在 DSH 里建了个协作群「{name}」,邀请你加入。
|
|
298
|
+
|
|
299
|
+
把下面这一行贴到你的 DSH 会话里直接发送即可:
|
|
300
|
+
/a2a-join {link}
|
|
301
|
+
|
|
302
|
+
也可以直接用自然语言让我加群:
|
|
303
|
+
用 a2a_group_join 加入这个群:{link}
|
|
304
|
+
|
|
305
|
+
入群后:群里有人发言或派活时会自动出现在你会话里(左侧「收到任务消息」卡片),
|
|
306
|
+
直接回复就是在群里回话。想静音用 a2a_group_inbox off,想退出用 a2a_group_leave。
|
|
307
|
+
(此邀请在 {expiresAt} 前有效;群主可以随时撤销。链接谁拿到谁能用,别转发到无关的地方。)
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
永不过期时最后一行改为:
|
|
311
|
+
`(此邀请长期有效;群主可以随时撤销这条链接。)`
|
|
312
|
+
|
|
313
|
+
### 2.7 `lib/core/config.js` / `lib/index.js` / `lib/dsh/tools.js` / `lib/dsh/prompt.js`(Lead)
|
|
314
|
+
|
|
315
|
+
- `config.js`:`normalizeConfig(raw)` → 完整默认值(见 §3)。
|
|
316
|
+
- `index.js`:`apply(ctx, config)` —— 装配 store/registry/deliverer/server/tools/prompt。
|
|
317
|
+
- `tools.js`:面给模型的工具,调用方是会话。
|
|
318
|
+
- `prompt.js`:系统提示词段(群礼仪:不要刷屏、结论要收线、什么时候该发群)。
|
|
319
|
+
|
|
320
|
+
**工具面(19 个)**,`a2a_group_*` 全部作用于**当前会话**(`senderOf(exec).id`):
|
|
321
|
+
|
|
322
|
+
| 工具 | 作用 |
|
|
323
|
+
|---|---|
|
|
324
|
+
| `a2a_group_create` / `invite` / `join` / `post` / `read` | 基本操作 |
|
|
325
|
+
| `a2a_group_info` / `role` / `leave` | 元数据与层 A 角色 |
|
|
326
|
+
| `a2a_group_directory` | 浏览宿主上的群(无凭证) |
|
|
327
|
+
| `a2a_group_request` / `requests` / `decide` / `claim` | 申请审批四步 |
|
|
328
|
+
| `a2a_groups` / `a2a_group_inbox` | 本会话的群列表与静音 |
|
|
329
|
+
|
|
330
|
+
`a2a_group_create` 在 `groups.embedded: false`(默认)时**通过 A2A 在远端宿主上建群**,
|
|
331
|
+
这现在是常态;`embedded: true` 时才在本机建。
|
|
332
|
+
|
|
333
|
+
### 2.7b `lib/core/memberships.js`(成员账本)
|
|
334
|
+
|
|
335
|
+
**键是 `(sessionId, groupId)`,不是 `groupId`。** 这是本次最重要的模型变更。
|
|
336
|
+
|
|
337
|
+
```js
|
|
338
|
+
export const HOST_SESSION_ID = 'host'; // 纯宿主用它(宿主没有会话,也不该有 membership)
|
|
339
|
+
|
|
340
|
+
createMembershipLedger({ home, logger, clock })
|
|
341
|
+
// → {
|
|
342
|
+
// list({ sessionId?, groupId? }) → [record…],
|
|
343
|
+
// get(groupId, sessionId?) → record | undefined,
|
|
344
|
+
// has(groupId) → boolean, // 任意会话是否为成员
|
|
345
|
+
// membershipsOf(sessionId) → [record…],
|
|
346
|
+
// localMemberships() / remoteMemberships(),
|
|
347
|
+
// upsert({ sessionId, groupId, memberId, memberKey, baseUrl, local, groupName,
|
|
348
|
+
// role, lastSeq, groupTopic?, memberCount?, onlineCount?, lastTime?,
|
|
349
|
+
// isOwner?, feed? }) → record,
|
|
350
|
+
// remove({ groupId?, sessionId? }) → 删除条数,
|
|
351
|
+
// flush(), path()
|
|
352
|
+
// }
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
规则:
|
|
356
|
+
|
|
357
|
+
1. **`sessionId` 必填**。一条无法指明会话的 membership 永远无法被投递,接受它只会造出
|
|
358
|
+
一条静默失效的记录。
|
|
359
|
+
2. **同一个群可被同一台机器的两个会话分别加入** —— 一个项目里负责两个模块的人,在那个群
|
|
360
|
+
里就是两个成员,两个凭证、两个未读游标。这是设计目标。
|
|
361
|
+
3. 文件格式 `version: 2`。`version: 1`(实例级、无 `sessionId`)**拒绝加载并重新开始**,
|
|
362
|
+
不猜测归属——猜错会把别人的群消息投进这个会话。
|
|
363
|
+
4. `memberKey` 以明文存于 `0600` 文件。必须如此:插件每次调用都要出示它,本地加密的
|
|
364
|
+
密钥只能放在同一台机器上,对"本机另一个进程"这个真实威胁毫无作用。
|
|
365
|
+
5. 缓存的群视图(`groupName` / `memberCount` / `feed` 等)由 `a2a_group_info` 填充。
|
|
366
|
+
面板从账本渲染而非从网络取数,没有缓存就只能显示裸 id 和"0 人"。
|
|
367
|
+
|
|
368
|
+
### 2.7c `lib/dsh/inbox.js` 的投递路由
|
|
369
|
+
|
|
370
|
+
订阅**按群**(一个群一条 SSE,两个会话在同一个群不需要两条连接),投递**按 membership**:
|
|
371
|
+
|
|
372
|
+
```
|
|
373
|
+
群消息到达
|
|
374
|
+
→ 查该 groupId 的所有远程 membership
|
|
375
|
+
→ 逐个投给各自 sessionId,去重键是 (sessionId, msgId)
|
|
376
|
+
→ 一个会话看过,不影响另一个会话收到
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
订阅凭证用其中任意一条 membership 的(群宿主只回答一个订阅者),
|
|
380
|
+
但**由哪条凭证开的流不决定谁能收到**。
|
|
381
|
+
|
|
382
|
+
水位(`sinceSeq`)取该群所有 membership 里**最小**的 `lastSeq`:
|
|
383
|
+
读得多的那个重读几条(去重会丢掉),读得少的不会漏。反过来会静默跳过。
|
|
384
|
+
|
|
385
|
+
### 2.7d `bin/agent-groups.js`(独立群宿主)
|
|
386
|
+
|
|
387
|
+
群宿主**不是** DSH 实例:不加载模型、没有会话、不投递。它是一个零依赖 Node 程序。
|
|
388
|
+
|
|
389
|
+
```js
|
|
390
|
+
export { createHttpRegistrar, parseFlags, usage }; // 可被测试导入,导入无副作用
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
`createHttpRegistrar(logger)` → `{ registrar, handle, routes }`:
|
|
394
|
+
|
|
395
|
+
- `registrar.register({ kind: 'exact'|'prefix', path, handler }) → disposeFn`
|
|
396
|
+
挂到原生 `http.Server`;`detach()` 真的能移除,所以同一进程内可重启。
|
|
397
|
+
- 路由匹配规则与 DSH webserver **一致**:exact 优先于 prefix,prefix 里最长者胜。
|
|
398
|
+
这样才保证"嵌入"与"独立"两种形态下 A2A 表面完全相同。
|
|
399
|
+
- 未知路径返回 **JSON 404**,不是 HTML 外壳——否则探测卡片的对端会把页面误当成卡片。
|
|
400
|
+
|
|
401
|
+
`server.js` 因此改为**注入** `registrar` 而不是读 `ctx.webServer`:
|
|
402
|
+
|
|
403
|
+
```js
|
|
404
|
+
createServer({ registrar, where, logger, config, deliverer, tasks, skills, onFlush })
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
- 在 DSH 里:`registrar = ctx.webServer`
|
|
408
|
+
- 独立运行:`registrar = createHttpRegistrar()`
|
|
409
|
+
|
|
410
|
+
注册的 5 条路由:
|
|
411
|
+
|
|
412
|
+
| 路径 | 说明 |
|
|
413
|
+
|---|---|
|
|
414
|
+
| `/a2a/.well-known/agent-card.json` | 卡片(公开,发现先于认证) |
|
|
415
|
+
| `/a2a/.well-known/agent.json` | 同上(别名) |
|
|
416
|
+
| `/a2a/.well-known/*` | JSON 404,把未知发现路径变成结构化回答 |
|
|
417
|
+
| `/a2a` | JSON-RPC + SSE |
|
|
418
|
+
| `/__health` | 运维端点(见下) |
|
|
419
|
+
|
|
420
|
+
**`/__health`**(需要与 `/a2a` 相同的令牌):
|
|
421
|
+
|
|
422
|
+
- `GET` / `HEAD` → `{ ok, agent, version, uptimeSeconds, skills }`,**不落盘**
|
|
423
|
+
- `POST` → 额外执行 `onFlush()`,返回 `{ flushed: true }`;失败时 `500 { ok: false, flushError }`
|
|
424
|
+
|
|
425
|
+
要求 `POST` 才落盘,是为了让爬虫或浏览器预取**无法触发出磁盘写**。
|
|
426
|
+
备份脚本应先 `POST /__health` 再复制 `groups.json`。
|
|
427
|
+
|
|
428
|
+
命令行(标志优先于环境变量,环境变量优先于默认值):
|
|
429
|
+
|
|
430
|
+
| 标志 | 环境变量 | 默认 |
|
|
431
|
+
|---|---|---|
|
|
432
|
+
| `--port` | `AGENT_GROUPS_PORT` | 3095 |
|
|
433
|
+
| `--host` | `AGENT_GROUPS_HOST` | 127.0.0.1 |
|
|
434
|
+
| `--data` | `AGENT_GROUPS_DATA` | `./data` |
|
|
435
|
+
| `--token` | `AGENT_GROUPS_TOKEN` | 无(仅回环可信) |
|
|
436
|
+
| `--agent` | `AGENT_GROUPS_AGENT` | agent-groups |
|
|
437
|
+
| `--gateway` | `AGENT_GROUPS_GATEWAY` | 无(则无法生成邀请) |
|
|
438
|
+
| `--forward-proto` | `AGENT_GROUPS_FORWARD_PROTO` | 无 |
|
|
439
|
+
| `--public-url` | `AGENT_GROUPS_PUBLIC_URL` | 无 |
|
|
440
|
+
|
|
441
|
+
位置参数一律**报错退出**:静默忽略 `agent-groups 3095` 会在默认端口启动并看起来正常,
|
|
442
|
+
那比一个用法错误糟得多。
|
|
443
|
+
|
|
444
|
+
宿主持有 `groups.json`,**不持有 `memberships.json`**(成员才有 membership)。
|
|
445
|
+
技能里那几处"把自己登记为成员"的调用在宿主上按设计**静默跳过**——
|
|
446
|
+
群主本来就是所建群的一员,这个语义由群自身满足。
|
|
447
|
+
|
|
448
|
+
### 2.7e 后加的四个模块与两处行为
|
|
449
|
+
|
|
450
|
+
名字带 `2.7e` 而不是新章节,是因为它们仍然属于「宿主与账本这一层」,插在这里比另起一节更容易被找到。
|
|
451
|
+
|
|
452
|
+
#### `lib/core/endpoint.js` —— 谁的地址
|
|
453
|
+
|
|
454
|
+
```js
|
|
455
|
+
ownEndpointFromConfig(config) // 从 groups.gatewayBase + groups.agentName 拼出本实例地址
|
|
456
|
+
normalizeEndpoint(value) // 校验并规范化外部传入的地址
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
**存在理由**:`group/join` 跑在**宿主进程里**,如果在宿主里调 `ownEndpointFromConfig(config)`,得到的是
|
|
460
|
+
**宿主自己的地址**,会被写到每个成员身上。所以宿主只用调用方送来的地址;只有成员侧才调这个函数。
|
|
461
|
+
|
|
462
|
+
这条曾经是真实缺陷:所有成员的地址都被写成了宿主的,导致「看起来注册了,但谁也收不到」。
|
|
463
|
+
|
|
464
|
+
#### `lib/core/colleague-cards.js` —— 名片汇编
|
|
465
|
+
|
|
466
|
+
```js
|
|
467
|
+
buildColleagueCards({ persons, memberships }) // → [{ personId, displayName, headline, tags, groups, online }]
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
**为什么不是直接读 `persons[]`**:名片要回答「这个人**和我**有什么关系」,所以需要当前实例的 memberships
|
|
471
|
+
才能算出「在哪些共同的群里」「在这个群里负责什么」。纯 persons 表做不到。
|
|
472
|
+
|
|
473
|
+
#### `lib/core/skills-group.js` 的 `person/profile`
|
|
474
|
+
|
|
475
|
+
**新 skill,第 22 个。** `group/role` 写的是**某个群内的** `about`/`tags`;`headline` 是**跨群的**人属性,
|
|
476
|
+
存在 `persons[]` 里。两者不能合并 —— 一个人可以在 A 群「负责对账」、在 B 群「兼产品」,同时名片上写「后端工程师」。
|
|
477
|
+
|
|
478
|
+
```js
|
|
479
|
+
{ skill: 'person/profile', data: { groupId, personId, personKey, headline } }
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
带 `groupId` 不是因为它属于群,而是因为**凭证挂在群成员身份上** —— 有了凭证才知道写哪个人的行。
|
|
483
|
+
|
|
484
|
+
#### `announceJoin(group, joiner)`(`skills-group.js` 内部函数)
|
|
485
|
+
|
|
486
|
+
入群后发两条一对一消息:给群主 `join-notice`,给新人 `welcome`。
|
|
487
|
+
|
|
488
|
+
三条约束:
|
|
489
|
+
|
|
490
|
+
1. **欢迎不能由被欢迎者签名** —— `sendPersonMessage` 拒绝自发消息。有群主用群主,没有用保留 id `system`。
|
|
491
|
+
2. **两条消息都不带邀请令牌** —— 否则令牌进群主收件箱,正是转发截图的来源。
|
|
492
|
+
3. **通知失败不能让入群失败** —— 成员已经落盘了。
|
|
493
|
+
|
|
494
|
+
#### 行为变更一:`setProfile` 只在真变化时写系统条目
|
|
495
|
+
|
|
496
|
+
旧版无条件 `appendSystem('X 更新了本群角色')`,导致每次保存都广播 —— 成员观察到的现象是「同一条通知反复刷屏,
|
|
497
|
+
间隔从两小时缩到 100 秒」,并合理怀疑「有客户端在循环调用」。
|
|
498
|
+
|
|
499
|
+
现在比较前后值(tags 两边排序后比较),无变化直接返回 `{ changed: false }`,**且不更新 `profileUpdatedAt`**
|
|
500
|
+
(那个字段的含义是「最后修改时间」,空存一次就刷新等于说谎)。
|
|
501
|
+
|
|
502
|
+
#### 行为变更二:`group/read` 的 `from` 是对象
|
|
503
|
+
|
|
504
|
+
```js
|
|
505
|
+
// 错过的写法(曾存在)
|
|
506
|
+
from: entry.from.displayName,
|
|
507
|
+
personId: entry.from.personId,
|
|
508
|
+
|
|
509
|
+
// 正确
|
|
510
|
+
from: { personId: entry.from.personId, displayName: entry.from.displayName },
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
`publicEntry` 约定 `from: { personId, displayName }`。降级成字符串后,所有读 `from.displayName` 的消费者拿到
|
|
514
|
+
`undefined` —— 而测试只断言了 `from.personId` 和面板文本,所以一直是绿的。
|
|
515
|
+
|
|
516
|
+
**这条值得记住**:断言「渲染出来的文本」不能代替断言「结构」。
|
|
517
|
+
|
|
518
|
+
### 2.8 `client.js`(分支 D,UI)
|
|
519
|
+
|
|
520
|
+
左侧群面板。DSH 客户端插件规范:
|
|
521
|
+
- `package.json` 加 `dsh.client = { platform:'web', inject:[…] }`,`exports['./client']` 指向 `client.js`。
|
|
522
|
+
- `client.js` 用 `window.__ModuleLoader__.load({ id, factory: (require) => {...} })` 形式(与已装插件同构),`react` / `react/jsx-runtime` 与 `@deepseek-ai/dsh-client-ui-primitives` 从共享表 `require`。
|
|
523
|
+
- 数据来源:不直连 A2A,只读 Host 通过 `ctx.remote` 暴露的只读视图(由 Lead 在 `index.js` 注册)。
|
|
524
|
+
- **首版可以只做只读**:群列表、成员、最近 feed;发帖走会话里的工具。
|
|
525
|
+
|
|
526
|
+
## 3. 配置形状(`normalizeConfig` 的产物)
|
|
527
|
+
|
|
528
|
+
```js
|
|
529
|
+
{
|
|
530
|
+
enabled: true,
|
|
531
|
+
server: {
|
|
532
|
+
enabled: true,
|
|
533
|
+
port: null, // null = 复用现有 webServer 端口(推荐)
|
|
534
|
+
publicUrl: '', // 卡片里对外声明的 url;空则用 webServer.getOrigin()+'/a2a'
|
|
535
|
+
token: '', // 空 = 只允许 loopback
|
|
536
|
+
bindSessionId: 'self', // 'self' = 首个调用会话;或显式 sessionId
|
|
537
|
+
maxBodyBytes: 1048576,
|
|
538
|
+
taskTimeoutMs: 600000,
|
|
539
|
+
streaming: true, // 关掉后 SendStreamingMessage 返回 -32601
|
|
540
|
+
skills: { session: true, group: true }
|
|
541
|
+
},
|
|
542
|
+
client: {
|
|
543
|
+
enabled: true,
|
|
544
|
+
apiKey: '', // AISIX 网关 Key(一人一把)
|
|
545
|
+
gatewayBase: '', // 形如 http://gw:3000/a2a
|
|
546
|
+
protocolVersion: '1.0',
|
|
547
|
+
forwardProto: '', // 联调 http 时填 'http'
|
|
548
|
+
cardTtlMs: 300000,
|
|
549
|
+
timeoutMs: 120000,
|
|
550
|
+
reconnectAttempts: 3
|
|
551
|
+
},
|
|
552
|
+
groups: {
|
|
553
|
+
enabled: true,
|
|
554
|
+
home: '', // 默认 <DSH_HOME>/a2a
|
|
555
|
+
embedded: false, // true = 本实例自己建群;默认 false,群在共享宿主上
|
|
556
|
+
gatewayBase: '', // 网关基址,生成邀请链接用
|
|
557
|
+
agentName: '', // 本实例(或宿主)在网关里的名字,一台实例一个
|
|
558
|
+
inviteExpiresInHours: 168, // null = 邀请默认永不过期;数字 = 小时数
|
|
559
|
+
maxPostChars: 32000,
|
|
560
|
+
maxPostsPerMinute: 20,
|
|
561
|
+
retentionDays: 30,
|
|
562
|
+
wakeMembers: true
|
|
563
|
+
},
|
|
564
|
+
delivery: {
|
|
565
|
+
presentation: 'notice', // notice = agent-message 卡片;bubble = 普通用户消息
|
|
566
|
+
defaultMode: 'queue',
|
|
567
|
+
hopBudget: 16,
|
|
568
|
+
maxHops: 16,
|
|
569
|
+
dedupeWindowMs: 600000
|
|
570
|
+
},
|
|
571
|
+
tools: true,
|
|
572
|
+
promptSection: true
|
|
573
|
+
}
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
## 4. 测试约定
|
|
577
|
+
|
|
578
|
+
- 每个分支自带 `test/<area>.test.mjs`,用 `node --test`。
|
|
579
|
+
- **不依赖真实网络/真实 DSH**:`webServer`、`sessionController`、`fs` 都用假对象注入。
|
|
580
|
+
- 需要 HTTP 的地方用 `node:http` 起临时端口(`listen(0)`),不引测试框架。
|
|
581
|
+
- 断言必须验证**行为**(投递了几次、状态怎么变、密钥有没有泄漏),不是「函数被调用了」。
|
|
582
|
+
|
|
583
|
+
## 5. 写域(并行不冲突)
|
|
584
|
+
|
|
585
|
+
| 分支 | 可写 |
|
|
586
|
+
|---|---|
|
|
587
|
+
| A `wire-server` | `lib/core/wire.js` `lib/core/server.js` `lib/dsh/deliver.js` | 协议与投递 |
|
|
588
|
+
| B `bridge-client` | `lib/core/bridge.js` `lib/core/registry.js` `lib/core/skills-session.js` | 出站调用 |
|
|
589
|
+
| C `groups-core` | `lib/core/groups.js` `lib/core/invite.js` `lib/core/memberships.js` `lib/core/skills-group.js` | 群状态机 |
|
|
590
|
+
| D `ui` | `client.js` | 浏览器面板 |
|
|
591
|
+
| Lead | `lib/index.js` `lib/core/config.js` `lib/shared/*` `lib/mcp/*` `lib/dsh/tools.js` | 装配与适配 |
|
|
592
|
+
> 需要改别人文件的接口时:**先发消息**,改 `docs/CONTRACT.md` 的对应段落,再动代码。
|