dsh-context-domains 0.0.0-stage → 0.3.1
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/DESIGN.md +957 -0
- package/LICENSE +21 -0
- package/README.md +199 -2
- package/SPEC.md +54 -0
- package/client.js +624 -0
- package/cordis.patch.yml +6 -0
- package/docs/PUBLISH.md +101 -0
- package/icon.svg +15 -0
- package/index.js +25 -0
- package/lib/apply.js +369 -0
- package/lib/catalog.js +154 -0
- package/lib/config.js +62 -0
- package/lib/http.js +398 -0
- package/lib/paths.js +18 -0
- package/lib/resolve.js +90 -0
- package/lib/runtime.js +38 -0
- package/lib/store.js +194 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +70 -4
package/DESIGN.md
ADDED
|
@@ -0,0 +1,957 @@
|
|
|
1
|
+
# DSH 插件设计开发文档 · 上下文领域(Context Domains)
|
|
2
|
+
|
|
3
|
+
| 项 | 值 |
|
|
4
|
+
|---|---|
|
|
5
|
+
| 插件包名 | `@local/dsh-context-domains` |
|
|
6
|
+
| 目标运行时 | `@deepseek-ai/dsh` 0.2.2-rc.1(Node 26 / Web 客户端) |
|
|
7
|
+
| 形态 | 一个 bundle:Host 半边 + Client 半边,无构建步骤(纯 ESM + `window.__ModuleLoader__`) |
|
|
8
|
+
| 原型 | OpenDesign 项目 `dsh-context-domains`(`index.html`),本文档是实现依据 |
|
|
9
|
+
| 状态 | 设计已冻结,待实现 |
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## 0. 摘要
|
|
14
|
+
|
|
15
|
+
插件把 skill / tool 的**逐条开关**换成**按领域整块开关**:
|
|
16
|
+
|
|
17
|
+
- 设置页里用户自己把一批 skill、一批 tool 归纳成「领域」(例如「飞书」= 32 个 skill)。
|
|
18
|
+
- 新会话页在 preset 控件旁并排两个下拉(技能领域 / 工具领域),勾选即整块加载。
|
|
19
|
+
- 会话期间,会话头右侧常驻一条领域芯片带(`● 名称 数量`,绿点=已加载、灰点=未加载),点击即切换,右侧实时显示「已加载 x/y 领域 · n skill + m tool」。
|
|
20
|
+
|
|
21
|
+
**受支持的机制**(全部为公开契约,不打补丁):
|
|
22
|
+
|
|
23
|
+
| 面 | 机制 | 性质 |
|
|
24
|
+
|---|---|---|
|
|
25
|
+
| tool 屏蔽 | `ctx.tools.restrict({ deny })`(agent scope) | 原生:从 schema、查找、执行三处一致移除 |
|
|
26
|
+
| tool 兜底deny | `ctx.tools.guard()`(agent scope,同步) | 原生:顺序无关的单调拒绝 |
|
|
27
|
+
| skill 从模型目录移除 | 在 **agent scope** 注册一个「遮罩 provider」,把被关领域的 skill 重投为 `modelInvocable:false` | 原生:注册表「就近层覆盖同名」语义 |
|
|
28
|
+
| skill 执行兜底 | `tools.guard('skill')` 按名拒绝 | 原生 |
|
|
29
|
+
| 全部 skill 关闭 | 额外 `restrict({ deny: ['skill'] })` | 原生(顺带省掉目录消息) |
|
|
30
|
+
|
|
31
|
+
唯一的“技巧”是 skill 遮罩 provider:它借用注册表的层级覆盖(`global → scope 链,最近的层赢同名`),把一个已有 skill 重新投递成“用户可调、模型不可调”,从而让 `dsh-tool-skill` 的目录自然不含它,同时 `/name` 手动调用仍然可用。§4.5.2 给出验证路径与降级方案。
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## 1. 目标与非目标
|
|
36
|
+
|
|
37
|
+
### 目标
|
|
38
|
+
1. 领域 CRUD(设置页):名称、图标、说明、新会话默认、成员多选(skill / tool 两页)。
|
|
39
|
+
2. 新会话两个领域下拉,与 preset 控件同排。
|
|
40
|
+
3. 会话头领域芯片带:绿/灰点由**实际生效集合**派生,而不是开关状态。
|
|
41
|
+
4. 切换只影响本会话,下一轮组提示词时生效;运行中的调用不中断。
|
|
42
|
+
5. 未归入任何领域的项维持原有逐条开关行为(零迁移成本)。
|
|
43
|
+
6. 全部关闭时给出可量化的上下文节省。
|
|
44
|
+
|
|
45
|
+
### 非目标
|
|
46
|
+
- 不替代 `skills/list` 与现有 skill 管理页;领域只做归纳。
|
|
47
|
+
- 不做「按 token 预算自动选域」的智能推荐(v2 议题)。
|
|
48
|
+
- 不写会话事件、不改 profile patch、不 monkey patch 任何 Harness 内部方法。
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## 2. 数据模型
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
// 一个领域
|
|
56
|
+
interface Domain {
|
|
57
|
+
id: string // 稳定 id(nanoid),改名不改 id
|
|
58
|
+
name: string // 下拉/芯片显示名,<= 12 字符建议
|
|
59
|
+
icon?: string // emoji 或内置图标名
|
|
60
|
+
description?: string
|
|
61
|
+
members: Member[]
|
|
62
|
+
defaultOn: boolean // 新会话默认加载
|
|
63
|
+
order: number
|
|
64
|
+
/** 只影响显示:mixed 时芯片里分别列 skill / tool */
|
|
65
|
+
kind: 'skill' | 'tool' | 'mixed'
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface Member {
|
|
69
|
+
type: 'skill' | 'tool'
|
|
70
|
+
name: string // skill: kebab-case 名;tool: 线上 wire 名
|
|
71
|
+
/** 仅用于设置页分组展示与失效提示,不参与解算 */
|
|
72
|
+
source?: string // skill: provider;tool: 归属猜测(见 §4.3.2)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 持久化文档
|
|
76
|
+
interface StoreDocument {
|
|
77
|
+
version: 1
|
|
78
|
+
domains: Domain[]
|
|
79
|
+
/** 会话级覆盖;key = SessionId */
|
|
80
|
+
sessions: Record<string, { skills: string[]; tools: string[]; at: number }>
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
**解算口径**
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
loadedDomains(session) = session.skills ?? domains.filter(d => d.defaultOn && d.kind !== 'tool')
|
|
88
|
+
loadedTools(session) = session.tools ?? domains.filter(d => d.defaultOn && d.kind !== 'skill')
|
|
89
|
+
|
|
90
|
+
requestedSkills = ∪ members(skill) of loadedDomains, 沿用原单开关的项不动
|
|
91
|
+
requestedTools = ∪ members(tool) of loadedTools
|
|
92
|
+
|
|
93
|
+
effectiveSkills = requestedSkills ∩ liveSkillCatalog
|
|
94
|
+
effectiveTools = requestedTools ∩ liveToolCatalog
|
|
95
|
+
missing = requested − effective // 设置页标红 / 芯片角标
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
**冲突规则**:同一 skill 落在多个领域,任一领域加载即加载(并集);删除领域不影响成员本体,只影响引用。
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## 3. 包结构
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
dsh-context-domains/
|
|
106
|
+
├─ package.json # dsh.bundle.patch + dsh.client
|
|
107
|
+
├─ cordis.patch.yml # 一行 insert
|
|
108
|
+
├─ index.js # Host 入口(apply)
|
|
109
|
+
├─ lib/
|
|
110
|
+
│ ├─ config.js # schemastery Config
|
|
111
|
+
│ ├─ store.js # 原子 JSON 持久化 + 变更广播
|
|
112
|
+
│ ├─ catalog.js # skill / tool 发现与归属
|
|
113
|
+
│ ├─ resolve.js # 领域 → 请求集合 → 生效集合
|
|
114
|
+
│ ├─ apply.js # restrict / guard / 遮罩 provider 的施加与撤销
|
|
115
|
+
│ ├─ http.js # /context-domains/api/* 路由 + 信任栅栏
|
|
116
|
+
│ └─ catalog-cache.js # 目录缓存与失效(skills/change、tools/change、preset 切换)
|
|
117
|
+
├─ client.js # Client 入口(window.__ModuleLoader__)
|
|
118
|
+
├─ client/ # 客户端源码(同 client.js 内联或拆文件后拼接,见 §5.6)
|
|
119
|
+
│ ├─ parts/strip.js # 芯片带
|
|
120
|
+
│ ├─ parts/picker.js # 两个下拉
|
|
121
|
+
│ ├─ parts/settings.js # 设置页
|
|
122
|
+
│ └─ parts/styles.js # 样式(token,运行时注入 <style data-plugin-css>)
|
|
123
|
+
├─ locale/{en,zh,...}.json
|
|
124
|
+
└─ icon.svg
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`package.json`:
|
|
128
|
+
|
|
129
|
+
```json
|
|
130
|
+
{
|
|
131
|
+
"name": "@local/dsh-context-domains",
|
|
132
|
+
"version": "0.1.0",
|
|
133
|
+
"private": true,
|
|
134
|
+
"type": "module",
|
|
135
|
+
"exports": {
|
|
136
|
+
".": "./index.js",
|
|
137
|
+
"./client": "./client.js",
|
|
138
|
+
"./locale/*.json": "./locale/*.json",
|
|
139
|
+
"./icon": "./icon.svg"
|
|
140
|
+
},
|
|
141
|
+
"files": ["index.js", "lib/**", "client.js", "cordis.patch.yml", "locale/*.json", "icon.svg"],
|
|
142
|
+
"dsh": {
|
|
143
|
+
"bundle": { "patch": "./cordis.patch.yml" },
|
|
144
|
+
"client": {
|
|
145
|
+
"platform": "web",
|
|
146
|
+
"immediately": true,
|
|
147
|
+
"inject": [
|
|
148
|
+
"@deepseek-ai/dsh-client-locale",
|
|
149
|
+
"@deepseek-ai/dsh-client-ui-conversation",
|
|
150
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
151
|
+
]
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
`cordis.patch.yml`:
|
|
158
|
+
|
|
159
|
+
```yaml
|
|
160
|
+
- insert:
|
|
161
|
+
- id: context-domains
|
|
162
|
+
name: '@local/dsh-context-domains'
|
|
163
|
+
config:
|
|
164
|
+
barPlacement: header # header | dock | off
|
|
165
|
+
skillGate: shadow # shadow | coarse | off
|
|
166
|
+
protectedTools: [present]
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
依赖说明:`@deepseek-ai/*` 由 dsh 安装解析,bundle 不声明这些依赖;本插件除 Node 内置模块外**不引入任何第三方依赖**(理由见 §4.2)。
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## 4. Host 设计
|
|
174
|
+
|
|
175
|
+
### 4.1 入口与 Config
|
|
176
|
+
|
|
177
|
+
```js
|
|
178
|
+
// index.js
|
|
179
|
+
export const name = 'context-domains'
|
|
180
|
+
export const inject = ['webServer', 'connection'] // 路由必需
|
|
181
|
+
export { Config } from './lib/config.js'
|
|
182
|
+
import { applyDomains } from './lib/apply.js'
|
|
183
|
+
|
|
184
|
+
export function apply(ctx, config) {
|
|
185
|
+
ctx.inject(['tools', 'agents'], toolsCtx => applyDomains(toolsCtx, config))
|
|
186
|
+
// skills 可选:没有它时 skill 域退化为 off(见 §7 降级矩阵)
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
`lib/config.js`(schemastery,字段全部 `.volatile()` 之外的可持久化项):
|
|
191
|
+
|
|
192
|
+
```js
|
|
193
|
+
import z from '@deepseek-ai/schemastery'
|
|
194
|
+
|
|
195
|
+
export const Config = z.object({
|
|
196
|
+
barPlacement: z.union([z.const('header'), z.const('dock'), z.const('off')]).default('header'),
|
|
197
|
+
skillGate: z.union([z.const('shadow'), z.const('coarse'), z.const('off')]).default('shadow'),
|
|
198
|
+
protectedTools: z.array(z.string()).default(['present']), // 永不被领域关掉的工具
|
|
199
|
+
allowEmptyToolSet: z.boolean().default(false), // 允许把工具全关(默认留 bash 等核心)
|
|
200
|
+
tokenEstimate: z.union([z.const('chars'), z.const('off')]).default('chars'),
|
|
201
|
+
})
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
设置页写入领域数据**不走 Config**(Config 是部署者可调的旋钮,领域是用户数据,体量大且频繁变更)。Config 变更会重启插件,领域变更不会。
|
|
205
|
+
|
|
206
|
+
### 4.2 持久化
|
|
207
|
+
|
|
208
|
+
选型:**插件自有 JSON 文件**,`$DSH_HOME/context-domains/state.json`(`DSH_HOME` 缺失时 `~/.dsh`)。
|
|
209
|
+
|
|
210
|
+
理由:
|
|
211
|
+
- `ctx.storageDomain` 需要引入 `zod` 与 `@deepseek-ai/dsh-storage-domain` 的 spec,给一个纯手写 bundle 增加两条解析风险;领域数据是单文档、低频写,不需要 KV 表与变更事件。
|
|
212
|
+
- 与既有第三方插件(`dsh-file-activity` 自己的 JSON Lines 存储)一致,属架构允许的“插件自有数据”。
|
|
213
|
+
- 若将来体积变大,可平滑迁移到 `storageDomain`(`StoreDocument.version` 已留位)。
|
|
214
|
+
|
|
215
|
+
写入协议(`lib/store.js`):
|
|
216
|
+
|
|
217
|
+
```
|
|
218
|
+
read: fs.readFile -> JSON.parse -> 校验 version/shape -> 失败则备份为 state.json.bad-<ts> 并回落空文档
|
|
219
|
+
write: 串行队列;临时文件 <file>.tmp-<pid>-<seq> 写入 -> fsync? 不 fsync(与 dsh-atomic-write 同口径)
|
|
220
|
+
-> fs.rename 原子替换 -> 广播 'changed'
|
|
221
|
+
并发: 单进程内串行;跨进程加 <file>.lock(O_EXCL,5s 过期)避免两个 dsh 实例互相覆盖
|
|
222
|
+
损坏: 任何写失败只 reject 本次调用,内存不变(读内存、写磁盘同序)
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
会话记录清理:`session/disposed` 时保留记录(fork/resume 需要),但每 200 次写入或 24h 触发一次修剪:删掉 `at` 早于 30 天且 `domains` 已不存在的会话条目。
|
|
226
|
+
|
|
227
|
+
### 4.3 目录发现
|
|
228
|
+
|
|
229
|
+
#### 4.3.1 Skill
|
|
230
|
+
|
|
231
|
+
```js
|
|
232
|
+
// lib/catalog.js
|
|
233
|
+
export async function readSkills(ctx, { scope, cwd, signal }) {
|
|
234
|
+
if (!ctx.get('skills')) return { items: [], complete: false, available: false }
|
|
235
|
+
const snap = await ctx.skills.snapshot({ scope, cwd, signal })
|
|
236
|
+
return {
|
|
237
|
+
complete: snap.complete,
|
|
238
|
+
available: true,
|
|
239
|
+
items: snap.skills.map(s => ({
|
|
240
|
+
name: s.name,
|
|
241
|
+
description: s.description,
|
|
242
|
+
whenToUse: s.whenToUse,
|
|
243
|
+
source: s.provider, // 真实归属:skill-filesystem / office / bundle 名
|
|
244
|
+
provider: s.provider,
|
|
245
|
+
origin: s.source, // project-dsh / user-dsh / bundled / runtime…
|
|
246
|
+
modelInvocable: s.invocation.modelInvocable,
|
|
247
|
+
userInvocable: s.invocation.userInvocable,
|
|
248
|
+
path: s.path,
|
|
249
|
+
costChars: s.description.length + (s.whenToUse?.length ?? 0),
|
|
250
|
+
})),
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
- **分层已合并**:`global + scope 链`,最近层赢同名 → 我们不需要自己合并多来源。
|
|
256
|
+
- `snapshot.complete === false` 表示某 provider 未就绪:结果**不进缓存**,沿用上次 good 值,并在设置页显示「发现不完整」。
|
|
257
|
+
- 设置页(无会话)用 `readSkills(ctx, { cwd: defaultDirectory })`:只读全局层 + 不激活任何 agent;需要看 preset 层时用 `agentPresets.list()` 逐个 `acquireScope(id)` 再读一次,UI 标注「仅 <preset> 可见」。
|
|
258
|
+
- 冷会话(新会话页)**不激活 agent**:下拉走 HTTP API,Host 用同一函数并带 `scope: presetScopeKey`。
|
|
259
|
+
|
|
260
|
+
#### 4.3.2 Tool
|
|
261
|
+
|
|
262
|
+
```js
|
|
263
|
+
export function readTools(ctx, { scope }) {
|
|
264
|
+
if (!ctx.get('tools')) return { items: [], available: false }
|
|
265
|
+
return {
|
|
266
|
+
available: true,
|
|
267
|
+
items: ctx.tools.definitions(scope).map(d => ({
|
|
268
|
+
name: d.name,
|
|
269
|
+
description: d.description,
|
|
270
|
+
costChars: JSON.stringify(d.parameters).length + d.description.length,
|
|
271
|
+
owner: attribution.get(d.name), // 见下
|
|
272
|
+
})),
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
`ToolSchema` **没有 owner 字段**(`name / description / parameters / deferLoading`),归属只有三条路:
|
|
278
|
+
|
|
279
|
+
1. **按前缀建议**(默认):`mcp__<server>__*`、`cordis_*`、`terminal_*`、`job_*`、`schedule_*`、`team_*`、`subagent*`、`goal_*`。设置页「新建领域」时把同前缀的候选一次勾好,用户删改后即成事实。
|
|
280
|
+
2. **跨 scope 差分**(免费):在 `definitions(undefined)`(全局)与 `definitions(presetScopeKey)` 之间做差,得到“只属于某 preset 的工具”,设置页显示来源 preset。这能解决「哪个工具是创造者模式带的」,不能解决「哪个包带的」。
|
|
281
|
+
3. **注册时记账**(可选,默认关闭,config `attribution: 'patch'`):在 `apply` 中包装一次 `ctx.tools.register`,把 `name → 调用方 fiber 的包名` 记进内存 Map,`dispose` 时还原原方法。包装是**单点、可还原、失败即弃用**的,仅用于改善设置页分组文案;任何异常都不影响开关逻辑。归入 v1.1,不阻塞 v1。
|
|
282
|
+
|
|
283
|
+
缓存与失效(`lib/catalog-cache.js`):
|
|
284
|
+
|
|
285
|
+
| 事件 | 动作 |
|
|
286
|
+
|---|---|
|
|
287
|
+
| `skills/change` | 清 skills 缓存 |
|
|
288
|
+
| `tools/change` | 清 tools 缓存(**注意**:我们自己调 `restrict` 也会触发,需要 `applying` 标志位去抖,否则自激) |
|
|
289
|
+
| `plugin-manager/changed` | 全清 |
|
|
290
|
+
| `agent-preset/selected` | 清该 session 的 scope 视图 |
|
|
291
|
+
| `connection/reset`(Client 侧) | 客户端丢弃本地快照重新拉取 |
|
|
292
|
+
|
|
293
|
+
TTL:全局目录 5s 内复用;带 `scope` 的视图按 `scopeKey` 分桶,不跨会话复用。
|
|
294
|
+
|
|
295
|
+
### 4.4 生效解算
|
|
296
|
+
|
|
297
|
+
`lib/resolve.js` 是纯函数,可单测:
|
|
298
|
+
|
|
299
|
+
```js
|
|
300
|
+
export function resolveSelection(domains, sessionState) {
|
|
301
|
+
const skillDomains = sessionState?.skills ?? domains.filter(d => d.defaultOn && d.kind !== 'tool').map(d => d.id)
|
|
302
|
+
const toolDomains = sessionState?.tools ?? domains.filter(d => d.defaultOn && d.kind !== 'skill').map(d => d.id)
|
|
303
|
+
const byId = new Map(domains.map(d => [d.id, d]))
|
|
304
|
+
const pick = (ids, type) => {
|
|
305
|
+
const out = new Set()
|
|
306
|
+
for (const id of ids) for (const m of byId.get(id)?.members ?? []) if (m.type === type) out.add(m.name)
|
|
307
|
+
return [...out].sort()
|
|
308
|
+
}
|
|
309
|
+
return { loadedDomains: skillDomains, loadedToolDomains: toolDomains,
|
|
310
|
+
skills: pick(skillDomains, 'skill'), tools: pick(toolDomains, 'tool') }
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function planToolRestriction({ allToolNames, requestedTools, protectedTools, allowEmptyToolSet }) {
|
|
314
|
+
const live = new Set(allToolNames)
|
|
315
|
+
let deny = allToolNames.filter(n => !requestedTools.includes(n)) // 语义:领域是白名单
|
|
316
|
+
deny = deny.filter(n => !protectedTools.includes(n))
|
|
317
|
+
if (!allowEmptyToolSet && deny.length === allToolNames.length) {
|
|
318
|
+
// 至少保留一个可执行工具,否则会话会被锁死
|
|
319
|
+
const keep = allToolNames.filter(n => protectedTools.includes(n)).slice(0, 1)
|
|
320
|
+
deny = deny.filter(n => !keep.includes(n))
|
|
321
|
+
}
|
|
322
|
+
return { deny: deny.filter(n => live.has(n)) } // restrict 遇未知名会抛错,先过滤
|
|
323
|
+
}
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
> `restrict` 的语义坑:`deny` 里出现未注册名会整调用失败,`allow` 与 `deny` 同时给会取交集后再匹配。因此本项目**只用 deny 白名单差集**,并且每次施加前用当次 `definitions()` 过滤。
|
|
327
|
+
|
|
328
|
+
### 4.5 施加
|
|
329
|
+
|
|
330
|
+
#### 4.5.1 生命周期
|
|
331
|
+
|
|
332
|
+
```js
|
|
333
|
+
// lib/apply.js(核心)
|
|
334
|
+
export function applyDomains(ctx, config) {
|
|
335
|
+
const store = openStore(ctx) // §4.2
|
|
336
|
+
const registry = new Map() // sessionId -> { agent, dispose, restriction }
|
|
337
|
+
|
|
338
|
+
ctx.on('agent/created', ({ agent }) => {
|
|
339
|
+
const entry = { agent, dispose: undefined, restriction: undefined, skillMask: undefined }
|
|
340
|
+
registry.set(agent.id, entry)
|
|
341
|
+
// 所有 agent 级注册都落在 agent.ctx,随 agent 释放一起消失
|
|
342
|
+
const undo = agent.ctx.effect(() => {
|
|
343
|
+
applyToAgent(ctx, agent, entry, config, store)
|
|
344
|
+
return () => { revoke(entry) }
|
|
345
|
+
})
|
|
346
|
+
entry.dispose = undo
|
|
347
|
+
// 插件自己的 effect 也持有它:卸载插件时不依赖 agent.ctx 是否还在
|
|
348
|
+
ctx.effect(() => () => { undo?.(); registry.delete(agent.id) })
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
ctx.on('agent/disposed', ({ agent }) => { revoke(registry.get(agent.id)); registry.delete(agent.id) })
|
|
352
|
+
|
|
353
|
+
// 领域/会话选择变更 → 重算 + 重新施加
|
|
354
|
+
store.on('changed', () => { for (const [, e] of registry) reapply(ctx, e, config, store) })
|
|
355
|
+
|
|
356
|
+
registerHttp(ctx, { store, config }) // §4.7
|
|
357
|
+
}
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
`applyToAgent` 三步,顺序固定:
|
|
361
|
+
|
|
362
|
+
1. 读目录(带 scope)→ `resolveSelection` → `planToolRestriction`
|
|
363
|
+
2. **skill 遮罩**:先注册(目录消息在下一步组装,越早越好)
|
|
364
|
+
3. **tool restrict**:`agent.ctx.tools.restrict({ deny })`;`deny` 为空则跳过(空过滤器会抛错)
|
|
365
|
+
|
|
366
|
+
`reapply` 是「撤销 + 重建」:先 dispose 上一次的 restrict 与 provider,再用新集合注册;不做增量合并(restrict 之间取交,增量会越限制越死)。
|
|
367
|
+
|
|
368
|
+
#### 4.5.2 Skill 遮罩(关键实现)
|
|
369
|
+
|
|
370
|
+
事实链(已核对 `dsh-skill` / `dsh-tool-skill` 的实现):
|
|
371
|
+
|
|
372
|
+
1. `dsh-tool-skill` 在 `agent/pre-step` 里发布目录,仅当 `ctx.tools.get('skill', agent) === skillTool`;条目来自 `ctx.skills.snapshot({ scope: agent, cwd })`,再用 `isModelInvocable` 过滤。
|
|
373
|
+
2. `ctx.skills` 合并顺序:`global → scope 链(远→近)`,`merged.set(name, ...)` 后者覆盖前者 → **agent scope 里的同名条目赢**。
|
|
374
|
+
3. `skills.registerProvider(create)` 注册进调用方 ctx 所属的层;`agent.ctx` 即 agent scope。
|
|
375
|
+
|
|
376
|
+
于是遮罩 provider:
|
|
377
|
+
|
|
378
|
+
```js
|
|
379
|
+
// 注册在 agent.ctx —— 只影响这个会话
|
|
380
|
+
const control = { invalidate: () => {} }
|
|
381
|
+
agent.ctx.skills.registerProvider((c) => {
|
|
382
|
+
control.invalidate = c.invalidate
|
|
383
|
+
return {
|
|
384
|
+
name: 'context-domains-mask',
|
|
385
|
+
async list(opts) { // opts: { cwd, signal }
|
|
386
|
+
if (disabled.size === 0) return []
|
|
387
|
+
const base = await ctx.skills.list({ cwd: opts.cwd, signal: opts.signal }) // 无 scope = 只看 global 层
|
|
388
|
+
return base
|
|
389
|
+
.filter(s => disabled.has(s.name))
|
|
390
|
+
.map(s => ({
|
|
391
|
+
...s,
|
|
392
|
+
rank: 900, // 同层排序;跨层覆盖与 rank 无关
|
|
393
|
+
locator: { name: s.name },
|
|
394
|
+
invocation: { modelInvocable: false, userInvocable: s.invocation.userInvocable },
|
|
395
|
+
}))
|
|
396
|
+
},
|
|
397
|
+
async get(candidate, opts) {
|
|
398
|
+
const real = await ctx.skills.get(candidate.name, { cwd: opts.cwd, signal: opts.signal })
|
|
399
|
+
if (!real) return undefined
|
|
400
|
+
return { ...real, invocation: { ...real.invocation, modelInvocable: false } }
|
|
401
|
+
},
|
|
402
|
+
}
|
|
403
|
+
})
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
要点:
|
|
407
|
+
|
|
408
|
+
- `list/get` 里**不带 scope** 读取,只看 global 层 → 不会读到自己的遮罩,无递归。
|
|
409
|
+
- 遮罩只改 `modelInvocable`:模型目录里消失(省 token),用户 `/name` 仍可调用(`isUserInvocable` 保持原值),符合“关领域=不给模型用,不是不让我用”。
|
|
410
|
+
- `control.invalidate()` 在切换时调用,让注册表丢弃快照缓存。
|
|
411
|
+
- 目录 digest 变化会让 `dsh-tool-skill` 发布一条**替换目录**消息 —— 正好就是「下一轮生效」。
|
|
412
|
+
|
|
413
|
+
**验证关卡**(必须实现期实测,不达标就降级):
|
|
414
|
+
|
|
415
|
+
| 检查 | 期望 | 不达标的降级 |
|
|
416
|
+
|---|---|---|
|
|
417
|
+
| `snapshot({scope: agent})` 里被遮罩的名字 `modelInvocable === false` | ✔ | `skillGate: 'coarse'` |
|
|
418
|
+
| 目录消息不再列出被遮罩 skill(看会话里的「系统提示词 / 工具定义」行) | ✔ | 同上 |
|
|
419
|
+
| `/被遮罩名` 仍能加载真实正文 | ✔ | 保留(只影响体验) |
|
|
420
|
+
| 关闭 32 个飞书 skill 后,目录消息长度显著下降 | ✔ | 同上 |
|
|
421
|
+
|
|
422
|
+
**`skillGate: 'coarse'`**:完全不遮罩;只在“技能域一个都没开”时 `restrict({ deny: ['skill'] })`(工具与整份目录一起消失,真省),部分关闭时仅用 guard 拦调用、不省 token,UI 上该域芯片显示「省不了 token」的黄色标记。
|
|
423
|
+
|
|
424
|
+
#### 4.5.3 兜底 guard
|
|
425
|
+
|
|
426
|
+
```js
|
|
427
|
+
agent.ctx.tools.guard((exec) => {
|
|
428
|
+
if (exec.name === 'skill') {
|
|
429
|
+
const name = typeof exec.arguments?.name === 'string' ? exec.arguments.name : undefined
|
|
430
|
+
if (name && disabled.has(name)) return `领域未加载:skill "${name}" 不可用`
|
|
431
|
+
}
|
|
432
|
+
if (disabledTools.has(exec.name)) return `领域未加载:工具 "${exec.name}" 不可用`
|
|
433
|
+
return undefined
|
|
434
|
+
})
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
guard 是同步、顺序无关的单调拒绝,用来兜住两条 restrict 管不到的缝:**preset scoped 注册的工具**(restrict 只作用于 global 工具)和**模型凭记忆调用的旧名**。
|
|
438
|
+
|
|
439
|
+
### 4.6 事件清单(Host)
|
|
440
|
+
|
|
441
|
+
| 事件 | 用途 |
|
|
442
|
+
|---|---|
|
|
443
|
+
| `agent/created` | 建立每会话条目,取 `agent.ctx` |
|
|
444
|
+
| `agent/disposed` | 撤销条目(双保险之一) |
|
|
445
|
+
| `tools/change` / `skills/change` | 清缓存;若生效集合变化则 reapply(带 `applying` 去抖) |
|
|
446
|
+
| `plugin-manager/changed` | 清全部缓存 |
|
|
447
|
+
| `agent-preset/selected` | 清该会话 scope 视图(目录属于 preset) |
|
|
448
|
+
| `session/disposed` | 会话状态修剪(延迟,不立即删) |
|
|
449
|
+
|
|
450
|
+
### 4.7 HTTP API(Client ↔ Host)
|
|
451
|
+
|
|
452
|
+
手写 bundle 没有 typert 代码生成,因此走 **`ctx.webServer.register` 前缀路由 + 同源 fetch**(`dsh-file-activity` / `horon-web` 同款)。全部端点前置信任栅栏:
|
|
453
|
+
|
|
454
|
+
```js
|
|
455
|
+
const rejection = ctx.connection.requestRejection(req) // 401 | 403 | undefined
|
|
456
|
+
if (rejection !== undefined) { res.statusCode = rejection; res.end(); return }
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
| 方法 | 路径 | 说明 |
|
|
460
|
+
|---|---|---|
|
|
461
|
+
| GET | `/context-domains/api/snapshot?sessionId=` | 一次拿全:domains、selection、catalog(skills/tools)、effective、missing、cost |
|
|
462
|
+
| POST | `/context-domains/api/toggle` | `{ sessionId, kind:'skill'|'tool', domainId, enabled }` → 写会话覆盖 + reapply |
|
|
463
|
+
| POST | `/context-domains/api/defaults` | 改 `defaultOn`(设置页用) |
|
|
464
|
+
| POST | `/context-domains/api/domains` | 领域 CRUD(`op:'create'|'update'|'delete'|'reorder'`) |
|
|
465
|
+
| GET | `/context-domains/api/catalog` | 设置页全量目录(含 preset 差分) |
|
|
466
|
+
| GET | `/context-domains/api/health` | `{ version, skillGate, skillGateVerified, storePath }` |
|
|
467
|
+
|
|
468
|
+
响应统一 `{ ok:true, value } | { ok:false, error:{ code, message } }`,`cache-control: no-store`。请求体上限 256 KiB(领域成员最多几千条)。写操作按 `sessionId` 串行(`Map<sessionId, Promise>` 队列),避免连点产生交错的 restrict。
|
|
469
|
+
|
|
470
|
+
**快照示例**
|
|
471
|
+
|
|
472
|
+
```json
|
|
473
|
+
{
|
|
474
|
+
"ok": true,
|
|
475
|
+
"value": {
|
|
476
|
+
"version": 1,
|
|
477
|
+
"domains": [
|
|
478
|
+
{ "id": "d_dev", "name": "日常编码", "icon": "🛠", "kind": "skill", "defaultOn": true,
|
|
479
|
+
"members": [{ "type": "skill", "name": "code-review", "source": "dsh-agent-preset" }] }
|
|
480
|
+
],
|
|
481
|
+
"session": { "skills": ["d_dev"], "tools": ["d_fs"] },
|
|
482
|
+
"catalog": {
|
|
483
|
+
"skills": [{ "name": "code-review", "source": "dsh-agent-preset", "modelInvocable": true }],
|
|
484
|
+
"tools": [{ "name": "bash", "owner": "dsh-tool-bash" }]
|
|
485
|
+
},
|
|
486
|
+
"effective": { "skills": ["code-review"], "tools": ["bash", "read"] },
|
|
487
|
+
"missing": { "skills": [], "tools": ["team_task_list"] },
|
|
488
|
+
"cost": { "skillsChars": 1840, "toolsChars": 9602, "savedChars": 4310 }
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
---
|
|
494
|
+
|
|
495
|
+
## 5. Client 设计
|
|
496
|
+
|
|
497
|
+
### 5.1 挂载点(已核对 live 槽位)
|
|
498
|
+
|
|
499
|
+
| 界面 | 槽位 | kind / scope | 用途原文 |
|
|
500
|
+
|---|---|---|---|
|
|
501
|
+
| 新会话两个下拉 | `conversation.hero.options` | list / session | 「New Session 行里 workspace 与 preset 控件之后、右对齐的紧凑选项;有 Session 才渲染」 |
|
|
502
|
+
| 会话头芯片带 | `conversation.session.header.utilities` | list / session | 「会话头右对齐的 utilities,按 order 升序」 |
|
|
503
|
+
| 设置页 | `settings.section` | list / root | 「每个 list 条目一页设置」;ownerProps `{ close }` |
|
|
504
|
+
| (可选)整行芯片带 | `conversation.input.dock` | list / session | 「composer 卡片上方的整行条目」——底部,仅当 `barPlacement: 'dock'` |
|
|
505
|
+
|
|
506
|
+
**为什么不是“会话头下方一条整行横带”**:当前 DSH 没有“transcript 之上的整行插槽”。`conversation.session`、`conversation.chat.flow` 都是 `single` 且已被官方占用(`replaceRisk: shadows-shipped-ui`,替换即接管官方 UI)。所以 v1 的“顶栏”落在**会话头右侧**(固定、不随滚动),交互等价:所有领域芯片 + 悬停成员面板 + 右侧汇总;`barPlacement: 'dock'` 提供整行方案(位于 composer 上方)。若将来上游给出 transcript 之前的 list 槽位,只需加一个注册点,不改数据层。
|
|
507
|
+
|
|
508
|
+
注册形态:
|
|
509
|
+
|
|
510
|
+
```js
|
|
511
|
+
ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({
|
|
512
|
+
name: 'conversation.session.header.utilities',
|
|
513
|
+
id: 'context-domains.strip',
|
|
514
|
+
order: 20, // 排在 open-in-app(-10)、schedule(-5)、session-log(0) 之后
|
|
515
|
+
locale: NS,
|
|
516
|
+
}, DomainStrip))
|
|
517
|
+
|
|
518
|
+
ctx.slots.inject('conversation.hero.options', () => ctx.slots.register({
|
|
519
|
+
name: 'conversation.hero.options', id: 'context-domains.picker', order: 10, locale: NS,
|
|
520
|
+
}, DomainPicker))
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
### 5.2 组件树
|
|
524
|
+
|
|
525
|
+
```
|
|
526
|
+
<DomainsRuntime> // 每 session 一份快照 + 乐观更新 + 串行化写
|
|
527
|
+
├─ <DomainPicker kind="skill" /> // hero.options:一个小胶囊 + 下拉(多选)
|
|
528
|
+
├─ <DomainPicker kind="tool" />
|
|
529
|
+
├─ <DomainStrip /> // header.utilities:芯片带 + 溢出菜单 + 汇总
|
|
530
|
+
│ ├─ <DomainChip domain state /> // ● 名称 n ;hover → <MemberPopover>
|
|
531
|
+
│ └─ <MemberPopover /> // 成员列表(每组带 switch)、全部/全不选、管理领域
|
|
532
|
+
└─ <DomainsSettings /> // settings.section:左列表 + 右编辑
|
|
533
|
+
├─ <DomainList /> // 拖拽排序、默认徽标、未归入领域行
|
|
534
|
+
├─ <DomainEditor /> // 名称/图标/说明/默认加载
|
|
535
|
+
└─ <MemberGrid tab="skill|tool"/> // 搜索、全选、反向、来源标注、失效标红
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
### 5.3 数据流
|
|
539
|
+
|
|
540
|
+
- `DomainsRuntime` 用 `sessionId`(标准 prop)挂载:`GET snapshot` → 订阅 `visibilitychange` 与自身写操作后重取;写操作**乐观更新**芯片点色,失败回滚 + Toast。
|
|
541
|
+
- 芯片点色只认 `value.effective`,不认 `selection`(避免“开关开着其实没挂上”)。
|
|
542
|
+
- 写串行:同一 session 的 toggle 请求排队(客户端也排队,减少 Host 端竞争)。
|
|
543
|
+
- 图标/文案全部走 `ctx.locale.register(NS, { zh, en })` + `locale.bind(NS)`;`label` 用 thunk(`() => t('sectionTitle')`)以便切语言时重投影。
|
|
544
|
+
|
|
545
|
+
### 5.4 样式
|
|
546
|
+
|
|
547
|
+
- 只用 `--dsw-alias-*` token(`bg-layer-1/2`、`border-l1/l2`、`label-primary/secondary/tertiary`、`state-success-primary`、`state-idle-primary`、`brand-primary`)。
|
|
548
|
+
- 芯片:`height:22px; padding:0 7px; border-radius:999px; font-size:11px; gap:5px`;绿点 `6px`(`state-success-primary`)+ `box-shadow: 0 0 0 3px color-mix(...)`;灰点用 `state-idle-primary`。
|
|
549
|
+
- 类名前缀 `cdx-`,样式以 `<style data-plugin-css="context-domains/…">` 注入(重复挂载判重,与 `dsh-context` 同法)。
|
|
550
|
+
- 溢出:芯片带 `max-width` + 横向滚动(`scrollbar-width:none`),右侧汇总固定不滚。
|
|
551
|
+
|
|
552
|
+
### 5.5 交互与可达性
|
|
553
|
+
|
|
554
|
+
- 芯片 = `<button role="switch" aria-checked>`;`Space`/`Enter` 切换;`←/→` 在芯片间移动。
|
|
555
|
+
- 下拉 = 受控 popover,`Escape` 关闭并归还焦点;点击外部关闭。
|
|
556
|
+
- 悬停成员面板 300ms 延迟打开、离开 150ms 关闭;面板内可逐条切换(写 `sessions[].members` 排除集,见 §5.7)。
|
|
557
|
+
- `prefers-reduced-motion` 下取消过渡。
|
|
558
|
+
|
|
559
|
+
### 5.6 Client 模块格式
|
|
560
|
+
|
|
561
|
+
```js
|
|
562
|
+
window.__ModuleLoader__.load({
|
|
563
|
+
id: '@local/dsh-context-domains',
|
|
564
|
+
factory(require) {
|
|
565
|
+
const React = require('react')
|
|
566
|
+
const h = React.createElement
|
|
567
|
+
// …组件
|
|
568
|
+
return {
|
|
569
|
+
name: 'context-domains',
|
|
570
|
+
inject: ['slots', 'locale'],
|
|
571
|
+
apply(ctx) {
|
|
572
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'context-domains: dictionaries')
|
|
573
|
+
const t = ctx.locale.bind(NS)
|
|
574
|
+
ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register(
|
|
575
|
+
{ name: 'conversation.session.header.utilities', id: 'context-domains.strip', order: 20, locale: NS },
|
|
576
|
+
DomainStrip))
|
|
577
|
+
// …其余注册
|
|
578
|
+
},
|
|
579
|
+
}
|
|
580
|
+
},
|
|
581
|
+
})
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
约束:factory 无副作用;样式/定时器/监听器都在 `apply` 里用 `ctx.effect` 注册并返回清理;**不 import 任何 Harness Client 包**(只 `require('react')`),按钮/开关/弹层照抄宿主样式自写。
|
|
585
|
+
|
|
586
|
+
### 5.7 逐条覆盖(v1 实现,`sessions[].excluded`)
|
|
587
|
+
|
|
588
|
+
领域是批量开关,但总有人要「整块开、只关其中一个」。数据加一层:`sessions[id].excluded = { skills: [], tools: [] }`,解算时从并集里减去:
|
|
589
|
+
|
|
590
|
+
```
|
|
591
|
+
effective = (∪ loadedDomains.members − excluded) ∩ live
|
|
592
|
+
```
|
|
593
|
+
|
|
594
|
+
UI:成员面板里每行一个 switch;关掉的成员进 `excluded`。这样“整块开 + 精确关”不打架,也不需要用户回头拆领域。
|
|
595
|
+
|
|
596
|
+
---
|
|
597
|
+
|
|
598
|
+
## 6. 设置页
|
|
599
|
+
|
|
600
|
+
一页两块(`settings.section`, id `context-domains`, order 35):
|
|
601
|
+
|
|
602
|
+
1. **领域列表**:`+ 新建`、拖拽排序、`默认` 徽标、`技能 n · 工具 m`、失效计数红点;末行「未归入领域 · n skill · m tool · 走原开关」不可编辑、可一键「按前缀归集」。
|
|
603
|
+
2. **领域编辑**:名称 / 图标 / 说明 / 新会话默认开关;`技能 | 工具` 两个 tab;成员网格带搜索、全选、反向、来源标注;底部「删除领域 / 保存」。
|
|
604
|
+
3. 页脚提示:「保存后:新会话立即生效;已有会话在下一轮生效(进行中的调用不受影响)」。
|
|
605
|
+
4. 首次进入空态:扫描现有 skill 的 `provider` 与 tool 前缀,给出 3~5 个建议领域(飞书 / 记忆 / 逆向 / 办公…),点一下即创建。这是解决“从零建领域太累”的关键一步。
|
|
606
|
+
|
|
607
|
+
设置页数据走 `GET /context-domains/api/catalog` + `POST /domains`;保存后广播,所有已开会话的芯片带随之刷新。
|
|
608
|
+
|
|
609
|
+
`ctx.settings.configure({ auto: false })`:不要为本插件自动生成 Config 表单(领域不是 Config)。
|
|
610
|
+
|
|
611
|
+
---
|
|
612
|
+
|
|
613
|
+
## 7. 降级矩阵
|
|
614
|
+
|
|
615
|
+
| 缺失/失败 | 行为 | 用户可见 |
|
|
616
|
+
|---|---|---|
|
|
617
|
+
| 无 `ctx.skills` | skill 域整体禁用,只保留 tool 域 | 设置页顶部提示「本部署没有 skill 服务」 |
|
|
618
|
+
| 遮罩验证不通过 | 自动降级 `skillGate: 'coarse'` | 芯片带出现琥珀点 + 提示「该 Harness 版本无法按域过滤 skill 目录」 |
|
|
619
|
+
| `snapshot.complete === false` | 不缓存,沿用上次 good;成员标「发现中」 | 设置页横幅「skill 发现不完整」 |
|
|
620
|
+
| `restrict` 抛错(未知名/越权) | 过滤后重试一次;仍失败则该会话只用 guard(不省 token) | Toast「工具限制未生效,已降级为调用拦截」 |
|
|
621
|
+
| 状态文件损坏/不可写 | 备份坏文件、内存态继续跑 | Toast「领域配置无法保存:<原因>」 |
|
|
622
|
+
| HTTP 栅栏拒绝 | 客户端显示空态 + 重试按钮 | 「需要登录」 |
|
|
623
|
+
| 领域引用了已卸载成员 | 忽略并计入 `missing` | 设置页标红;芯片角标 `!n` |
|
|
624
|
+
|
|
625
|
+
---
|
|
626
|
+
|
|
627
|
+
## 8. 版本与兼容
|
|
628
|
+
|
|
629
|
+
- `StoreDocument.version = 1`;迁移函数表 `migrations[from] → to`,未知高版本只读不写并提示升级插件。
|
|
630
|
+
- 依赖的公开契约(`tools.restrict/definitions/guard`、`skills.registerProvider/snapshot`、槽位名)在 `lib/contract-check.js` 启动时做形状自检:方法存在 + 参数个数,缺一项就走降级矩阵,不抛到用户面前。
|
|
631
|
+
- 不读、不写别的插件的 DOM / 样式 / 内部模块。
|
|
632
|
+
|
|
633
|
+
---
|
|
634
|
+
|
|
635
|
+
## 9. 测试与验收
|
|
636
|
+
|
|
637
|
+
**单元**(node:test,不依赖 dsh)
|
|
638
|
+
|
|
639
|
+
| 用例 | 断言 |
|
|
640
|
+
|---|---|
|
|
641
|
+
| `resolveSelection` 并集/去重/默认域 | 结果集合稳定、排序稳定 |
|
|
642
|
+
| `planToolRestriction` | 未知名被过滤、protected 永不进 deny、全关时保留核心工具 |
|
|
643
|
+
| 存储 | 并发 200 次写后文件合法;坏 JSON 触发备份并回落空文档 |
|
|
644
|
+
| 遮罩候选 | `disabled` 为空返回 `[]`;`modelInvocable` 被改写、`userInvocable` 保持 |
|
|
645
|
+
|
|
646
|
+
**集成**(临时 profile,`DSH_HOME=$(mktemp -d)`)
|
|
647
|
+
|
|
648
|
+
1. 装 bundle → `list_plugins` 观察到 `context-domains` 行 `applied`。
|
|
649
|
+
2. 开两个会话 A/B:A 关「逆向」域,B 保持默认 → B 的目录仍含该域 skill,A 不含。
|
|
650
|
+
3. 关掉全部技能域 → 会话里 `skill` 工具消失、目录消息消失。
|
|
651
|
+
4. 设置页删掉一个域 → 两处界面同步,会话 effective 回落默认。
|
|
652
|
+
5. 插件卸载 → 所有限制撤销,工具集恢复。
|
|
653
|
+
|
|
654
|
+
**人工核对清单**
|
|
655
|
+
|
|
656
|
+
- [ ] 新会话页两个下拉与 preset 同排、文案本地化、无 JS 报错
|
|
657
|
+
- [ ] 芯片带在深/浅两套主题下都用 token 呈现,无硬编码色
|
|
658
|
+
- [ ] 键盘可完成一次切换
|
|
659
|
+
- [ ] 切换后下一轮:目录消息替换为该域的集合(看「系统提示词 / 工具定义」)
|
|
660
|
+
- [ ] 关掉飞书域后 `savedChars` 与目测的目录长度变化一致
|
|
661
|
+
|
|
662
|
+
---
|
|
663
|
+
|
|
664
|
+
## 10. 里程碑
|
|
665
|
+
|
|
666
|
+
| 阶段 | 内容 | 完成判据 |
|
|
667
|
+
|---|---|---|
|
|
668
|
+
| M1 | Host:Config、store、catalog、resolve、tool restrict、HTTP snapshot/toggle | curl 拿到正确 snapshot;切换后 `definitions(agent)` 变化 |
|
|
669
|
+
| M2 | Client:`DomainsRuntime` + hero 两个下拉 + 芯片带 | 页面上可见、可切、点色随 effective 变 |
|
|
670
|
+
| M3 | 设置页(列表/编辑/成员网格/空态建议) | 建域 → 新会话默认生效 |
|
|
671
|
+
| M4 | Skill 遮罩 + guard + 降级矩阵 | 关飞书域后目录消息显著变短;`/名` 仍可用 |
|
|
672
|
+
| M5 | 打磨:locale 全量、图标、键盘、逐条 excluded、成本估算、测试 | 验收清单全绿 |
|
|
673
|
+
|
|
674
|
+
M1~M2 是可交付的最小可用版本;M4 才有真正的 token 收益。
|
|
675
|
+
|
|
676
|
+
---
|
|
677
|
+
|
|
678
|
+
## 11. 风险与未决
|
|
679
|
+
|
|
680
|
+
1. **遮罩依赖“就近层覆盖同名”语义**(`dsh-skill` 的 `collectFresh` 合并顺序)。若上游改成“全局优先”或缓存不失效,遮罩静默失效 → 必须有 §4.5.2 的验证关卡,并且验证失败要显式降级,不能假装省了 token。
|
|
681
|
+
2. **`tools.restrict` 只作用于 global 工具**:preset 内注册的工具关不掉(只能 guard 拦执行,schema 仍占上下文)。设计上接受,并在设置页把这类工具标「由预设提供,不可按域关闭」。
|
|
682
|
+
3. **每次切换都会引起目录消息替换**,会在会话里留下一条替换记录;频率极高时可能干扰模型对历史的判断 → 客户端对同一域 500ms 内合并,Host 端对同一 session 串行。
|
|
683
|
+
4. **归属无法从 API 取得**:tool 前缀归组是启发式;`attribution: 'patch'` 是可选增强,不做默认。
|
|
684
|
+
5. **每会话 head 空间有限**:`barPlacement: 'dock'` 是整行方案的后备;若上游新增 transcript 前插槽,直接迁移。
|
|
685
|
+
6. **多实例**:同一 `DSH_HOME` 下两个 dsh 进程共享状态文件 → 文件锁 + 只做整文档写;不接受跨进程实时同步(`domain/changed` 类事件不可用)。
|
|
686
|
+
|
|
687
|
+
---
|
|
688
|
+
|
|
689
|
+
## 附:一次完整调用的时序
|
|
690
|
+
|
|
691
|
+
```
|
|
692
|
+
用户点芯片「飞书」
|
|
693
|
+
└─ Client: 乐观把点变绿,写队列
|
|
694
|
+
└─ POST /context-domains/api/toggle {sessionId, kind:'skill', domainId:'d_fs', enabled:true}
|
|
695
|
+
├─ fence: requestRejection(req)
|
|
696
|
+
├─ store.setSessionSelection() → 原子写盘 → 'changed'
|
|
697
|
+
├─ reapply(agent)
|
|
698
|
+
│ ├─ catalog: readSkills({scope:agent, cwd}) (缓存命中)
|
|
699
|
+
│ ├─ resolveSelection → requested.skills 增加 32 个
|
|
700
|
+
│ ├─ revoke: 上次 restrict / 遮罩 provider
|
|
701
|
+
│ ├─ mask provider: disabled 集合移除这 32 个(其实它们本来没被遮罩,改为不遮罩)
|
|
702
|
+
│ └─ tools.restrict({deny: 差集 − protected})
|
|
703
|
+
└─ 200 { ok, value: snapshot }
|
|
704
|
+
└─ Client 用返回的 effective 校正点色与计数
|
|
705
|
+
下一轮模型请求
|
|
706
|
+
└─ dsh-tool-skill: snapshot → 目录 digest 变化 → 发布替换目录(含飞书 32 条)
|
|
707
|
+
```
|
|
708
|
+
## 12. 实现记录(2026-11-09,dsh-dev 容器实测)
|
|
709
|
+
|
|
710
|
+
这一节记录「设计稿 → 跑通」之间被实测推翻或加固的三处,后续维护以本节为准。
|
|
711
|
+
|
|
712
|
+
### 12.1 遮罩不能走 provider,走 agent scope 的 runtime 注册
|
|
713
|
+
|
|
714
|
+
设计稿原本用 `skills.registerProvider()` 在 agent scope 注册一个「重投成 modelInvocable:false」的 provider。实测不可行,原因有二:
|
|
715
|
+
|
|
716
|
+
1. provider 的 `list()` 若要看到「会话视角」的 skill,必须带 `scope` 读注册表 —— 那会再次进入自己(无限递归),只能靠重入标志,还会污染注册表的收集缓存。
|
|
717
|
+
2. 不带 `scope` 读只命中**全局层**;本机 dsh 0.2.2-rc.1 里 skill 提供方挂在 **preset 层**(cordis preset 的 `skill-filesystem`),全局层是 0 条 → 遮罩候选恒为空。实测 `/probe` 返回 `providerListed: 0`。
|
|
718
|
+
|
|
719
|
+
改成:**在 agent scope 用同名 runtime 注册重投**。注册表按 `global → scope 链(远→近)` 合并、最近层赢同名,`skills.register()` 写在 agent 层正好盖住 preset/全局的原条目;注册表内部走 `scopeOf(this.ctx)`,实测 `registerVisible: true`(agent scope 可见)、`registerVisibleGlobal: false`(不污染全局)。
|
|
720
|
+
|
|
721
|
+
代价:遮罩时要先把真实定义取出来(`skills.get(name, { scope: agent })`),也就是**会读一次被关掉那些 skill 的正文**。只影响 I/O,不进上下文;换来 `/name` 手动调用仍然拿到真实正文。
|
|
722
|
+
|
|
723
|
+
### 12.2 重算必须按会话串行
|
|
724
|
+
|
|
725
|
+
领域变更(`store.onChange`)、HTTP 切换、CLI/设置页写入会并发触发重算。交错执行时后一次 `install()` 会**覆盖** `entry.disposers`,前一次的 `restrict` disposer 丢失 → 限制永远撤不掉(实测:关掉域后工具消失,再打开也回不来)。
|
|
726
|
+
|
|
727
|
+
修法:每个 entry 一条 `queue`,`revoke → 读目录 → 规划 → 注册` 全程串行;`detach` 也排进队列。
|
|
728
|
+
|
|
729
|
+
### 12.3 `restrict` 在本 build 对 preset 挂的工具同样生效
|
|
730
|
+
|
|
731
|
+
设计稿按文档假设「restrict 只管全局工具,preset 工具关不掉」。实测相反:`agent.ctx.tools.restrict({ deny })` 能让预设挂的 `workflow` / `subagent_fork` / `create_goal` 从 `definitions(agent)` 里消失(34 → 27 个工具),撤销后恢复。`guard` 仍保留作为兜底(preset scoped 注册与模型凭记忆调旧名)。
|
|
732
|
+
|
|
733
|
+
### 12.4 端到端证据(容器内 dsh 0.2.2-rc.1)
|
|
734
|
+
|
|
735
|
+
| 检查 | 结果 |
|
|
736
|
+
|---|---|
|
|
737
|
+
| 插件加载 | `/context-domains/api/health` 200,`stateFile=/data/dsh/context-domains/state.json` |
|
|
738
|
+
| 目录发现 | 36 个 tool(跨 6 个 preset)、5 个 skill,全部带 `scopes` 归属 |
|
|
739
|
+
| 新会话下拉 | `conversation.hero.options` 渲染「技能领域 1/2」「工具领域 0/3」,两个下拉互斥 |
|
|
740
|
+
| 会话头芯片带 | `conversation.session.header.utilities` 渲染 5 枚芯片 + 「已加载 2/5 · 4 skill + 29 tool」 |
|
|
741
|
+
| 工具限制 | 关掉「工作流与子智能体」域:29 → 26 个工具;再打开回 29 |
|
|
742
|
+
| skill 遮罩 | 关掉技能域后 `maskProbe` 四条全部 `modelInvocable:false`;`effective.skills` 为空 |
|
|
743
|
+
| 目录消息 | 域关闭时**整条不发布**(0 字节);域打开时发布 1920 字符的 `<available_skills>`(4 条) |
|
|
744
|
+
| 设置页 | `settings.section` 出现「上下文领域」,5 个领域 + 成员多选 + 建域建议可用 |
|
|
745
|
+
| 单元 / 集成 | `node --test test/` 13 项全绿(8 纯函数 + 5 HTTP) |
|
|
746
|
+
| 端到端脚本 | `test/e2e/mask.mjs` 通过 |
|
|
747
|
+
|
|
748
|
+
### 12.5 容器内的安装与验证
|
|
749
|
+
|
|
750
|
+
```bash
|
|
751
|
+
docker exec dsh-dev dsh plugin --profile web add link:/workspace/dsh-context-domains
|
|
752
|
+
docker compose restart dsh
|
|
753
|
+
curl -s -b <cookie> http://127.0.0.1:3080/context-domains/api/health
|
|
754
|
+
node --test test/ # 单元 + HTTP 集成
|
|
755
|
+
node test/e2e/mask.mjs http://127.0.0.1:3080 # 端到端遮罩验证(宿主侧 puppeteer)
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
无构建步骤:Host 与 Client 都是纯 ESM(Client 走 `window.__ModuleLoader__`,只 `require('react')` / `require('react-dom')`),改完重启容器即生效。
|
|
759
|
+
|
|
760
|
+
---
|
|
761
|
+
|
|
762
|
+
## 13. v0.2 原型调整:领域控制收到顶部一条带子
|
|
763
|
+
|
|
764
|
+
原型改动(OpenDesign `dsh-context-domains/index.html`,v0.2)与它对实现的约束:
|
|
765
|
+
|
|
766
|
+
1. **单一顶部注册点**:新会话与对话共用一条整窗宽横条(左:技能/工具多选入口;中:全部领域芯片;右:汇总 + 会话锁)。不再使用 `conversation.hero.options`(右对齐、与 preset 抢行)与 `conversation.session.header.utilities`(只能挤在标题行右侧)。
|
|
767
|
+
2. **横条是整窗宽的**,跨过侧栏 —— 这正是 `shell.overlay` 能做的事:`position: fixed; top: var(--dsh-frame-top-clearance); left: 0; right: 0`,在 click-through 层里自己开 `pointer-events`。它不依赖会话列几何,也不需要读别的插件的 DOM。
|
|
768
|
+
3. **下拉与成员浮层 portal 到 body**(`createPortal`),避免被 header / composer 的 overflow 裁切。
|
|
769
|
+
4. **黑白双主题**:所有颜色走 `--dsw-alias-*`,芯片的绿/灰点、选中态、弹层阴影各有一组亮暗值;原型右上角可切换验收。
|
|
770
|
+
|
|
771
|
+
对现有实现的迁移量:删掉 `conversation.hero.options` 与 `conversation.session.header.utilities` 两处注册,改为在 `shell.overlay` 注册一条横条;`DomainsRuntime`、HTTP API、限流/遮罩逻辑全部不动(它们与落点无关)。
|
|
772
|
+
|
|
773
|
+
---
|
|
774
|
+
|
|
775
|
+
## 14. v0.3 调整:会话内的领域条 + 领域不分类型
|
|
776
|
+
|
|
777
|
+
### 14.1 数据模型简化(会影响现有 state.json)
|
|
778
|
+
|
|
779
|
+
```diff
|
|
780
|
+
Domain {
|
|
781
|
+
id, name, icon, description, defaultOn, order
|
|
782
|
+
- kind: 'skill' | 'tool' | 'mixed' // 删掉:领域没有类型
|
|
783
|
+
members: { type: 'skill' | 'tool', name, source }[]
|
|
784
|
+
}
|
|
785
|
+
- sessions[id] = { skills: string[], tools: string[], excluded: {...} }
|
|
786
|
+
+ sessions[id] = { domains: string[], excluded: { skills: string[], tools: string[] } }
|
|
787
|
+
```
|
|
788
|
+
|
|
789
|
+
- 选择语义从「两串领域 id」合并成「一串领域 id」;成员类型仍在 member 上,供加载时分流。
|
|
790
|
+
- 迁移:`normalizeDocument` 读旧文档时把 `skills` 与 `tools` 合并去重成 `domains`,写回时丢弃 `kind`;
|
|
791
|
+
`excluded` 保留(成员级覆盖仍然按类型存,因为要按类型施加)。
|
|
792
|
+
- HTTP 跟着简化:`POST /toggle { sessionId, domainId, enabled }`(不再有 `kind`);
|
|
793
|
+
`/snapshot` 的 `domains[].loaded` 变成单值。
|
|
794
|
+
|
|
795
|
+
### 14.2 加载仍按类型分通道
|
|
796
|
+
|
|
797
|
+
```
|
|
798
|
+
loadedDomains → members
|
|
799
|
+
skill 子集 → agent scope 重投同名 runtime skill(modelInvocable:false)
|
|
800
|
+
tool 子集 → tools.restrict({ deny: 不在集合里的受管 tool }) + tools.guard 兜底
|
|
801
|
+
```
|
|
802
|
+
|
|
803
|
+
**这就是「概念不分类型、实现分类型」**:用户只有一个开关,通道各走各的,互不干扰。
|
|
804
|
+
未归入任何领域的成员继续走原来的逐条开关。
|
|
805
|
+
|
|
806
|
+
### 14.3 条子放在会话里
|
|
807
|
+
|
|
808
|
+
- 位置:会话头之下、正文之上,只在有会话时渲染(新会话=空白会话,同一个位置)。
|
|
809
|
+
- 内容:只有领域,`● 名称`+点色;点击即开关;悬停出成员面板。
|
|
810
|
+
- 落点:DSH 目前没有「transcript 之前」的 additive 槽位。实现取
|
|
811
|
+
`shell.overlay` 固定横条 + 在自己注册的 `conversation.session.header.utilities` 锚点读取顶边,
|
|
812
|
+
左/宽取会话列几何;测量失败时降级为「芯片内联进会话头」,功能不变、观感退一档。
|
|
813
|
+
若上游后续提供会话顶部 list 槽位,替换这一处即可。
|
|
814
|
+
|
|
815
|
+
### 14.4 需要改的代码点
|
|
816
|
+
|
|
817
|
+
| 文件 | 改动 |
|
|
818
|
+
|---|---|
|
|
819
|
+
| `lib/store.js` | `normalizeDomain` 去掉 `kind`;`normalizeSession` 合并 `domains` |
|
|
820
|
+
| `lib/resolve.js` | `selectionFor` 返回 `string[]`;`requestedMembers` 按成员类型切两半 |
|
|
821
|
+
| `lib/apply.js` | plan 只算一次成员集合,再按类型分给 `restrict` 与 skill 遮罩 |
|
|
822
|
+
| `lib/http.js` | `/toggle` 去掉 `kind`;`/snapshot` 的 loaded 单值;`/domains` 计数改为 `N 项(a 技能 / b 工具)` |
|
|
823
|
+
| `client.js` | 去掉两个 `DomainPicker` 与 hero 注册;只保留一条 `DomainStrip` |
|
|
824
|
+
|
|
825
|
+
### 14.5 成员级开关从界面去掉(v0.3.1)
|
|
826
|
+
|
|
827
|
+
悬停面板与设置页都只显示名字、按 `技能 / 工具` 两段分组,不再有逐条加载开关:
|
|
828
|
+
|
|
829
|
+
- 理由:成员是用户自己挑进领域的,多一个开关只增加误操作与视觉噪音。
|
|
830
|
+
- 影响:`POST /exclude`(会话级成员排除)保留实现但不再暴露入口,作为后续「临时豁免」的预留能力;
|
|
831
|
+
`sessions[].excluded` 仍参与解算(空集时不产生任何效果)。
|
|
832
|
+
- 设置页的行内复选框只用于「往领域里加/删成员」,不是加载开关,文案要写清楚。
|
|
833
|
+
|
|
834
|
+
---
|
|
835
|
+
|
|
836
|
+
## 15. v0.3 实现记录(容器实测)
|
|
837
|
+
|
|
838
|
+
### 15.1 两个锚点、一条条子
|
|
839
|
+
|
|
840
|
+
领域条要同时出现在「新会话」和「已有会话」,而这两态渲染的槽位不同:
|
|
841
|
+
新会话只有 `conversation.hero.options`,已有会话才有 `conversation.session.header.utilities`。
|
|
842
|
+
做法是**同一个组件注册两处**,组件本身只做两件事:交出 `sessionId`(以及一个测量用的锚点),
|
|
843
|
+
然后把真正的条子 `createPortal` 到 body、`position: fixed` 贴在会话头下沿。
|
|
844
|
+
|
|
845
|
+
`useBarOwnership()` 用模块级互斥令牌保证同一时刻只有一个实例真正画条子,另一个只留 0×0 锚点,
|
|
846
|
+
避免两态切换瞬间出现两条。
|
|
847
|
+
|
|
848
|
+
### 15.2 几何与占位
|
|
849
|
+
|
|
850
|
+
- 位置:`[data-slot="conversation.session"]` 的父元素给左边缘与宽度;`conversation.session.header`
|
|
851
|
+
给下沿(新会话没有会话头时退回会话列顶边)。
|
|
852
|
+
- 占位:条子是 fixed,会盖住正文首行,所以按几何找到会话的滚动容器
|
|
853
|
+
(与会话列同左缘、同宽、`overflow-y:auto`、高 > 300),给它加 `padding-top: 34px` 内联样式,
|
|
854
|
+
卸载时还原。**只改一个内联样式,不动 DOM 结构、不读 class 名。**
|
|
855
|
+
- 兜底:量不到几何时降级为「芯片内联进会话头」,功能不变。
|
|
856
|
+
|
|
857
|
+
### 15.3 数据模型与接口(v0.3)
|
|
858
|
+
|
|
859
|
+
- `Domain` 去掉 `kind`;`sessions[id]` 从 `{skills,tools}` 合并成 `domains: string[]`,读取旧文档时自动迁移。
|
|
860
|
+
- `POST /toggle` 去掉 `kind`;`/snapshot` 的 `session.domains` 是单串、`domains[].loaded` 是单值。
|
|
861
|
+
- 成员级开关从界面移除,`POST /exclude` 保留为预留能力。
|
|
862
|
+
- 配置里删掉 `barPlacement`(条子只有一个位置了)。
|
|
863
|
+
|
|
864
|
+
### 15.4 实测
|
|
865
|
+
|
|
866
|
+
| 检查 | 结果 |
|
|
867
|
+
|---|---|
|
|
868
|
+
| 新会话 | 条子在 `y=40`(会话列顶),5 枚芯片,只有一条 |
|
|
869
|
+
| 已有会话 | 条子在 `y=76`(会话头下沿),宽度 1158 与会话列一致 |
|
|
870
|
+
| 占位 | 正文首行不再被压住(滚动容器 padding-top=34) |
|
|
871
|
+
| 悬停 | 成员面板:`技能 4` + 名字顿号连写,`工具 0` 段自动省略 |
|
|
872
|
+
| 开关 | 点芯片 → `POST /toggle`(无 kind)→ 会话 `domains` 变化 → agent 侧 restrict/遮罩重算 |
|
|
873
|
+
| 设置页 | 左列 `4 项`,右侧 `技能 4 / 工具 0` 两段,行内只有名字 + 复选框 |
|
|
874
|
+
| 测试 | `node --test test/` 14 项全绿(含迁移用例);`test/e2e/mask.mjs` 通过 |
|
|
875
|
+
|
|
876
|
+
### 17. 第三方来源验收(做完即撤)
|
|
877
|
+
|
|
878
|
+
2026-11-09 在容器里临时挂了 `dsh-mcp-client` × 2(memory / fsdemo)、`dsh-skill-office`、`dsh-skill-badge`,
|
|
879
|
+
验证领域能同时管住 MCP 工具与外部 skill:
|
|
880
|
+
|
|
881
|
+
- 目录:13 skill + 64 tool(含 25 个 `mcp__*`)。
|
|
882
|
+
- 混装领域(3 skill + 4 `mcp__memory__*`):开启 → `effective` 两边都在;关闭 → 4 个 MCP 工具进 `deniedTools`、
|
|
883
|
+
3 个 skill `maskProbe.modelInvocable=false`。
|
|
884
|
+
- 规模:16 个 `mcp__fsdemo__*` 关闭后 visible 0 / denied 16,开启后 visible 16。
|
|
885
|
+
- 自动化:`test/e2e/mcp.mjs`(自建领域→开关→断言→自删)。
|
|
886
|
+
|
|
887
|
+
验收完按计划撤除:profile patch 的四行删掉、容器重启,`catalog` 回到 5 skill / 36 tool,`mcp__*` 为 0,
|
|
888
|
+
状态文件里也没有残留成员(`grep mcp__ state.json` 为 0);验收期间建的「记忆图谱」「文件系统 MCP」两个领域也一并删除。
|
|
889
|
+
|
|
890
|
+
顺带修掉一个冷会话缺陷:没有 live agent 时 `/snapshot` 的 `visible` 为空,导致新会话页读不到任何可见集合。
|
|
891
|
+
现在冷会话会读全局层给出 `visible`,`effective` 仍按「请求 ∩ 线上」计算。
|
|
892
|
+
|
|
893
|
+
---
|
|
894
|
+
|
|
895
|
+
## 16. 接入 Archify(架构建模)时踩到并修掉的一个真缺陷
|
|
896
|
+
|
|
897
|
+
### 16.1 现象
|
|
898
|
+
|
|
899
|
+
把本插件仓库登记成 archify 项目(`repoPath=/workspace/dsh-context-domains`)后,出图会话跑了 59 步、
|
|
900
|
+
两个图都 `failed`:`没有找到这次运行的产物文件`,5M tokens 打水漂。会话日志里唯一的错误是:
|
|
901
|
+
|
|
902
|
+
```
|
|
903
|
+
tool/result isError: true
|
|
904
|
+
content: "Error: 本会话未加载蕴含该 skill 的领域:archify"
|
|
905
|
+
```
|
|
906
|
+
|
|
907
|
+
也就是说 **我的 guard 把 archify 自己的 skill 拦了**。
|
|
908
|
+
|
|
909
|
+
### 16.2 根因
|
|
910
|
+
|
|
911
|
+
`dsh-archify-projects` 为自己的受限 agent 声明了 `preset-archify-projects`,preset 里带一份**私有的
|
|
912
|
+
skill-filesystem**(只供应 `archify` 一个 skill)与工具白名单。本插件对所有 agent 一视同仁地装
|
|
913
|
+
`tools.restrict({deny})` + `tools.guard`,于是:
|
|
914
|
+
「archify 不在我的任何领域里」→ 判成违规 → 拒绝 `skill: archify` → agent 拿不到画图口径 →
|
|
915
|
+
一路读文件但从不产出。
|
|
916
|
+
|
|
917
|
+
这是我们一方的错误:领域的职责是治理「用户自己的会话」,不是去管别人插件给自己的受限 agent
|
|
918
|
+
定好的供应口径。
|
|
919
|
+
|
|
920
|
+
### 16.3 修法:preset 治理门
|
|
921
|
+
|
|
922
|
+
```js
|
|
923
|
+
// config:默认只治理随包发布的四个内置 preset
|
|
924
|
+
managedPresets: ['standard', 'ptc', 'minimal', 'cordis']
|
|
925
|
+
manageUnknownPresets: false
|
|
926
|
+
|
|
927
|
+
// apply.manages(agent):查 agentPresets.presetForSession(agent.session)
|
|
928
|
+
// 在名单里 → 治理;不在名单里、或认不出 preset → 完全不装 restrict/guard/mask
|
|
929
|
+
```
|
|
930
|
+
|
|
931
|
+
配套:
|
|
932
|
+
- `/snapshot` 新增 `session.managed`;`status.skillGate = 'unmanaged'` 表示这个会话不归本插件管。
|
|
933
|
+
- 认不出 preset(服务缺失、查询抛错、空 id)一律按「不治理」处理 —— 宁可不管,也不能把别人的会话弄坏。
|
|
934
|
+
|
|
935
|
+
### 16.4 修复前后对照(同一个仓库,同一个 archify preset)
|
|
936
|
+
|
|
937
|
+
| | 修复前 | 修复后 |
|
|
938
|
+
|---|---|---|
|
|
939
|
+
| 工具错误 | 1(`skill: archify` 被领域 guard 拒绝) | 0 |
|
|
940
|
+
| 领域拦截 | 1 | 0 |
|
|
941
|
+
| 产物 | 无 | `structure-context-domains-20261109-155842/context-domains.html`(782 KB) |
|
|
942
|
+
| 概念表 | 无 | 23 条(领域 / DomainMember / SessionSelection / Excluded / Resolver / 工具 deny 名单…) |
|
|
943
|
+
|
|
944
|
+
结构图在页面里显示「已就绪」,点开可以看到它把 `lib/{apply,catalog,config,http,resolve,runtime,store}.js`
|
|
945
|
+
与 `client.js` 都建了节点。
|
|
946
|
+
|
|
947
|
+
### 16.5 遗留:领域图(domain)两轮都没产出
|
|
948
|
+
|
|
949
|
+
修复后 `structure` 一次成功,但 `domain` 连续两轮 `没有找到这次运行的产物文件`:
|
|
950
|
+
|
|
951
|
+
- 第一轮是 **宿主重启**打断的(跑图期间我重启了容器,`reconcileArtifacts` 把它收成 failed,属预期行为)。
|
|
952
|
+
- 第二轮跑了 15 步、**0 个工具错误**、`write` 工具在请求里确实存在,但 agent 读完 12 个文件后
|
|
953
|
+
直接 `turn/end(completed)`,没有执行写入 —— 这是**模型行为**(这一轮只思考不出件),
|
|
954
|
+
不是插件拦的。要复现只需在 Archify 页面点「更新图册」重跑,不必改这边。
|
|
955
|
+
|
|
956
|
+
结论:本插件与 archify 的冲突已消除(`managed: false`,完全不介入它的 preset);剩下的失败是
|
|
957
|
+
「模型没写文件」与「重启打断」,与领域机制无关。
|