dsh-plugin-tool-management 0.12.1 → 0.14.0

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/README.md +90 -114
  3. package/README_EN.md +98 -119
  4. package/docs/images/1-EN.png +0 -0
  5. package/docs/images/1.png +0 -0
  6. package/docs/images/2-EN.png +0 -0
  7. package/docs/images/2.png +0 -0
  8. package/docs/images/3-EN.png +0 -0
  9. package/docs/images/3.png +0 -0
  10. package/docs/images/4-EN.png +0 -0
  11. package/docs/images/4.png +0 -0
  12. package/docs/images/5-EN.png +0 -0
  13. package/docs/images/5.png +0 -0
  14. package/docs/images/6-EN.png +0 -0
  15. package/docs/images/6.png +0 -0
  16. package/docs/images/7-EN.png +0 -0
  17. package/docs/images/7.png +0 -0
  18. package/docs/images/8-EN.png +0 -0
  19. package/docs/images/8.png +0 -0
  20. package/docs/update.md +63 -0
  21. package/lib/client.js +1086 -95
  22. package/lib/compat/preset-reach.js +6 -3
  23. package/lib/context-inject.js +183 -59
  24. package/lib/index.js +501 -49
  25. package/lib/mcp/manager.js +118 -71
  26. package/lib/mcp/secret-guard.js +21 -0
  27. package/lib/mcp/state-section.js +19 -14
  28. package/lib/memories/archive-engine.js +199 -0
  29. package/lib/memories/constants.js +72 -8
  30. package/lib/memories/index-io.js +1 -1
  31. package/lib/memories/projection.js +67 -31
  32. package/lib/memories/service.js +17 -2
  33. package/lib/memories/snapshot.js +123 -22
  34. package/lib/op-registry.js +279 -0
  35. package/lib/ops/candidates.js +11 -0
  36. package/lib/ops/compat.js +23 -2
  37. package/lib/ops/sessions.js +1 -1
  38. package/lib/ops/state-doctor.js +115 -0
  39. package/lib/prompts/service.js +25 -2
  40. package/lib/request-gate.js +21 -78
  41. package/lib/scene-settings.js +17 -0
  42. package/lib/scenes/candidates.js +56 -0
  43. package/lib/skills/catalog.js +10 -5
  44. package/lib/skills/core.js +102 -0
  45. package/lib/skills/service.js +34 -2
  46. package/lib/subagents/catalog.js +9 -5
  47. package/lib/subagents/service.js +37 -4
  48. package/lib/subagents/tools.js +19 -5
  49. package/lib/tools/deps.js +15 -0
  50. package/lib/tools/mcp.js +291 -50
  51. package/lib/tools/memory.js +170 -19
  52. package/lib/tools/prompt.js +28 -14
  53. package/lib/tools/scene.js +351 -0
  54. package/lib/tools/skills.js +168 -14
  55. package/lib/tools/subagent.js +129 -3
  56. package/lib/tools/table.js +290 -0
  57. package/package.json +2 -2
@@ -0,0 +1,279 @@
1
+ // HTTP op 登记表 —— 每个 op 一条判据声明,机器可数的那一份。
2
+ //
3
+ // 为什么要有这个文件:门禁今天靠**五张手写清单**(`WRITE_OPS`、`SENSITIVE_OPS`、
4
+ // `guardLockedOps`、`syncSceneArchiveOnSwitch`、`annotateLocked` 的 op 列表)。它们各自
5
+ // 回答一个不同的问题,作用在同一批 op 名字上,中间没有任何约束 —— 新加一个 op 只写
6
+ // handler,编译器一声不吭。这条纪律反复失守过四次(0.6.0 / 0.7.0 / 0.10.0 两次),
7
+ // 症状都不是报错,而是"没配令牌也能写"或"锁定的场景能被改"。
8
+ //
9
+ // 判据(一个 op 可以同时占多条,所以这是**标志集合**不是单一分类):
10
+ // serviceWrite 写门禁的一部分,但**由所属 service 自报**(`writeOps`)。这里登记一份
11
+ // 是为了让对账能双向查:service 加了写 op 而登记表没条目 → 红;登记表有条目
12
+ // 而 service 不再自报 → 也红。两边谁改了都必须动另一边。
13
+ // write 需要访问令牌。判据是"会不会改**宿主或外部系统**的状态" —— 注意这**不是**
14
+ // "会不会写盘":见下方 readonly 那一段。
15
+ // sensitive 会泄露明文凭据 / 完整配置。**必须**带对的令牌,没配令牌就一律拒绝。
16
+ // frozen 任一场景 locked=true 时整体拒绝(五个管理域冻结)。
17
+ // frozenScope frozen 的三种口径,默认 'all':
18
+ // all 只要有场景锁着就拒。
19
+ // self-scene 只挡"被锁的那个场景"自己(别的场景照常)。
20
+ // active-scene 只在"锁着的场景正在生效"时拒(它能把当前场景清空)。
21
+ // syncsArchive 成功后把改动同步进当前场景档案。与 frozen **正交**:锁定冻结全部写操作,
22
+ // 这条只是把页面开关的意图也写进档案。
23
+ // annotatesLock 响应里附加 anyLocked / activeScene(界面横幅的数据源)。
24
+ // readonly 明确声明"这条就是只读,上面几项都不需要"。**归不进上面任何一条的必须显式
25
+ // 写这一条** —— 漏登记和"确实只读"在编译器眼里长得一样,这正是四次失守的成因。
26
+ //
27
+ // readonly 的判据边界(原样保留在 request-gate.ts 的注释里,这里重申一遍):
28
+ // 有若干只读 op **会写盘** —— `history-list` / `history-sessions` 每次写工作区快照,
29
+ // `rules-list` / `rules-read` / `rules-diagnose` 整份覆盖写 `memories-index.json`,
30
+ // `mcpm-list` / `mcpm-tools` 回写 `mcp-known-tools.json`。它们都**不**算写操作:判据是
31
+ // "会不会改宿主或外部系统的状态",而上面那些写的是插件自己的侧车(丢了可重建)。
32
+ // 想要它们也带令牌就显式改成 write,别靠"读操作带副作用"这句话推。
33
+ export const OP_REGISTRY = Object.freeze({
34
+ // ── MCP 域(16)────────────────────────────────────────────────────────────
35
+ 'mcpm-list': { annotatesLock: true },
36
+ // reveal 返回**未打码**的凭据。它是读操作,但按写门禁 —— 局域网暴露的端口上,令牌对
37
+ // 明文凭据必须是最后一道防线,而不只对着写操作。
38
+ 'mcpm-reveal': { write: true, sensitive: true, annotatesLock: true },
39
+ 'mcpm-export': { write: true, sensitive: true },
40
+ 'mcpm-add': { write: true, frozen: true },
41
+ 'mcpm-edit': { write: true, frozen: true },
42
+ 'mcpm-remove': { write: true, frozen: true },
43
+ 'mcpm-import': { write: true, frozen: true },
44
+ 'mcpm-compact': { write: true, frozen: true },
45
+ 'mcpm-note': { write: true, frozen: true },
46
+ 'mcpm-settings': { write: true, frozen: true },
47
+ 'mcpm-set-enabled': { write: true, frozen: true, syncsArchive: true },
48
+ 'mcpm-set-all': { write: true, frozen: true, syncsArchive: true },
49
+ 'mcpm-tool-enabled': { write: true, frozen: true, syncsArchive: true },
50
+ // restart 不是"只重连":实现里两次 `writePatch`(先强制停用、轮询、再按重启前状态恢复),
51
+ // 恢复失败会停在停用态。锁定期间它是唯一能落盘改补丁的入口,所以按写门禁、但**不冻结**
52
+ // —— 它同时是"卡住了重连一下"这条恢复路径。
53
+ 'mcpm-restart': { write: true },
54
+ // 为了拿实时工具表会临时启用目标服务器、结束后恢复原状(两次 writePatch),恢复失败还
55
+ // 会停在启用态 —— 是写不是读。(0.6.0 / 0.7.0 各有漏列前科,见文件头。)
56
+ 'mcpm-tools-refresh': { write: true },
57
+ 'mcpm-tools': { readonly: true },
58
+ // ── 技能域(22,含内联的 skill-open)───────────────────────────────────────
59
+ 'skill-state': { annotatesLock: true },
60
+ 'skill-detail': { readonly: true },
61
+ 'skill-browse': { readonly: true },
62
+ 'skill-enable': { serviceWrite: true, frozen: true, syncsArchive: true },
63
+ 'skill-disable': { serviceWrite: true, frozen: true, syncsArchive: true },
64
+ 'skill-set-all': { serviceWrite: true, frozen: true, syncsArchive: true },
65
+ 'skill-source-enable': { serviceWrite: true, frozen: true, syncsArchive: true },
66
+ 'skill-source-disable': { serviceWrite: true, frozen: true, syncsArchive: true },
67
+ // 移除 / 恢复来源会改写本地来源状态(候选清单随之变化,模型侧的技能目录也变),是写。
68
+ 'skill-source-remove': { serviceWrite: true, frozen: true },
69
+ 'skill-source-restore': { serviceWrite: true, frozen: true },
70
+ 'skill-prefer': { serviceWrite: true, frozen: true },
71
+ 'skill-unprefer': { serviceWrite: true, frozen: true },
72
+ 'skill-create': { serviceWrite: true, frozen: true },
73
+ // 改写 hub 里已存在的那一份技能(`skill_manager_save` 的改分支)。与 create 同规格:
74
+ // 都是往 hub 落文件。它**不动**官方根里的技能 —— 那层边界在 service 的胜出者判定里。
75
+ 'skill-update': { serviceWrite: true, frozen: true },
76
+ 'skill-import': { serviceWrite: true, frozen: true },
77
+ 'skill-upload': { serviceWrite: true, frozen: true },
78
+ 'skill-delete': { serviceWrite: true, frozen: true },
79
+ 'skill-trash-restore': { serviceWrite: true, frozen: true },
80
+ 'skill-trash-delete': { serviceWrite: true, frozen: true },
81
+ 'skill-custom-add': { serviceWrite: true, frozen: true },
82
+ 'skill-custom-remove': { serviceWrite: true, frozen: true },
83
+ // 打开系统编辑器改文件:不改宿主状态,但它是"界面按钮"性质的写入口,按写门禁。
84
+ 'skill-open': { write: true },
85
+ // ── 记忆与场景域(27:memories 24 + archive-engine 3)──────────────────────
86
+ 'rules-list': { readonly: true },
87
+ 'rules-read': { readonly: true },
88
+ 'rules-budget': { readonly: true },
89
+ 'rules-diagnose': { readonly: true },
90
+ 'rules-trash-list': { readonly: true },
91
+ 'rules-create': { serviceWrite: true, frozen: true },
92
+ 'rules-update': { serviceWrite: true, frozen: true },
93
+ 'rules-remove': { serviceWrite: true, frozen: true },
94
+ 'rules-restore': { serviceWrite: true, frozen: true },
95
+ 'rules-toggle': { serviceWrite: true, frozen: true },
96
+ 'rules-import': { serviceWrite: true, frozen: true },
97
+ 'rules-attach': { serviceWrite: true, frozen: true },
98
+ 'rules-detach': { serviceWrite: true, frozen: true },
99
+ 'rules-trash-remove': { serviceWrite: true, frozen: true },
100
+ 'rules-set-index': { serviceWrite: true, frozen: true },
101
+ // 场景启停本身要可用(否则进不去也出不来),但它能把**当前场景清空** —— 而"当前场景已
102
+ // 锁定"是模型侧 `lockedSceneGuard` 唯一的判据,场景一空它就返回 null,四个写工具全放开,
103
+ // 运行时却仍是那个场景的档案态(2026-09-19 审计 T-32)。所以单独一刀,见 frozenScope。
104
+ 'rules-set-active': { serviceWrite: true, frozen: true, frozenScope: 'active-scene' },
105
+ 'rules-remove-scene': { serviceWrite: true, frozen: true, frozenScope: 'self-scene' },
106
+ 'rules-rebind-prompt': { serviceWrite: true },
107
+ // 锁定开关自己**不能**被冻结挡住,否则锁上了解不开。
108
+ 'rules-scene-lock': { serviceWrite: true },
109
+ // ⚠️ 口径不一致,登记时才看得出来:`rules-remove-scene` 冻结,而 `rules-create-scene` /
110
+ // `rules-update-scene` 不冻结 —— 都是改场景集合,三缺一。今天按现状登记(A1 只登记不改
111
+ // 行为),要统一得单独判一次,记在这里别丢。
112
+ 'rules-create-scene': { serviceWrite: true },
113
+ 'rules-update-scene': { serviceWrite: true },
114
+ 'scene-trash-list': { readonly: true },
115
+ 'scene-trash-restore': { serviceWrite: true },
116
+ 'scene-trash-delete': { serviceWrite: true },
117
+ 'scene-mode-get': { readonly: true },
118
+ // 进入场景前的「会改什么」预览:与 `scene-mode-set` 走同一批纯计划函数,但只读现状、
119
+ // 不落盘也不应用 —— 登记成 readonly 就是这条承诺的机器可查版本(改这个 op 的人
120
+ // 若往里加了 apply* / saveSlice,登记表与实现就对不上了)。
121
+ 'scene-mode-preview': { readonly: true },
122
+ 'scene-archive-save': { serviceWrite: true, frozen: true, frozenScope: 'self-scene' },
123
+ 'scene-mode-set': { serviceWrite: true },
124
+ // ── 子智能体域(10)────────────────────────────────────────────────────────
125
+ 'subagent-list': { annotatesLock: true },
126
+ 'subagent-get': { readonly: true },
127
+ 'subagent-trash-list': { readonly: true },
128
+ 'subagent-create': { serviceWrite: true, frozen: true },
129
+ 'subagent-update': { serviceWrite: true, frozen: true },
130
+ 'subagent-delete': { serviceWrite: true, frozen: true },
131
+ 'subagent-import': { serviceWrite: true, frozen: true },
132
+ 'subagent-toggle': { serviceWrite: true, frozen: true, syncsArchive: true },
133
+ 'subagent-trash-restore': { serviceWrite: true, frozen: true },
134
+ 'subagent-trash-delete': { serviceWrite: true, frozen: true },
135
+ // ── 提示词域(11)──────────────────────────────────────────────────────────
136
+ 'agentsmd-list': { annotatesLock: true },
137
+ 'agentsmd-read': { readonly: true },
138
+ 'agentsmd-get-current': { readonly: true },
139
+ 'agentsmd-trash-list': { readonly: true },
140
+ 'agentsmd-create': { write: true, frozen: true },
141
+ 'agentsmd-update': { write: true, frozen: true },
142
+ // apply 写全局 AGENTS.md = 换掉生效基线。
143
+ 'agentsmd-apply': { write: true, frozen: true },
144
+ 'agentsmd-remove': { write: true, frozen: true },
145
+ 'agentsmd-import': { write: true, frozen: true },
146
+ 'agentsmd-trash-restore': { write: true, frozen: true },
147
+ 'agentsmd-trash-delete': { write: true, frozen: true },
148
+ // ── 历史会话域(16)────────────────────────────────────────────────────────
149
+ 'history-list': { readonly: true },
150
+ 'history-sessions': { readonly: true },
151
+ 'history-export-defaults': { readonly: true },
152
+ 'history-retention-get': { readonly: true },
153
+ 'dir-list': { readonly: true },
154
+ 'history-archive': { write: true },
155
+ 'history-unarchive': { write: true },
156
+ 'history-delete': { write: true },
157
+ 'history-archive-batch': { write: true },
158
+ 'history-unarchive-batch': { write: true },
159
+ 'history-delete-batch': { write: true },
160
+ 'history-retention-set': { write: true },
161
+ 'history-import': { write: true },
162
+ 'history-export': { write: true },
163
+ // 通用导出:往**用户指定的目录**写文件,按写操作门禁。
164
+ 'bundle-export': { write: true },
165
+ // 新增一条宿主工作区登记 —— 改的是宿主侧的登记,不是插件自己的侧车。
166
+ 'history-workspace-register': { write: true },
167
+ // ── 兼容 / 注入 / 备份域(7)───────────────────────────────────────────────
168
+ 'compat-status': { readonly: true },
169
+ 'feature-overview': { readonly: true },
170
+ 'preset-reach': { readonly: true },
171
+ // 五个域当前正文的**全文**都从这里出去。今天不带令牌就能读,是这批只读里披露面最大的一条;
172
+ // 要不要上门禁是产品决定,登记在这里是为了让它可数。
173
+ 'injection-live': { readonly: true },
174
+ 'backups-list': { readonly: true },
175
+ // 注入设置(五域开关 / 压制型预设口径)写侧车。它是"配置"不是"宿主状态",但改的是
176
+ // 投递语义,按写操作门禁。
177
+ 'inject-settings': { write: true },
178
+ // 模型工具表(哪些工具根本不发给模型)也写侧车,改的是每轮请求的内容 —— 同上按写门禁。
179
+ 'tool-table': { write: true },
180
+ // 场景页的界面设置(进入场景前要不要弹「会改什么」的预览卡):写侧车,按写门禁。
181
+ // **刻意不冻结**:它只是界面提示,锁着场景的人在场景页照样该能关掉提醒 —— 冻结的是五个
182
+ // 管理域的改动,不是这个页面的显示偏好(判据见文件头 write/frozen 两段的边界)。
183
+ 'scene-settings': { write: true },
184
+ // 清理 patch 备份:删磁盘文件(备份里含明文凭据副本),按写操作门禁。
185
+ 'backups-clean': { write: true },
186
+ // ── 场景候选源(4)与内联(1)──────────────────────────────────────────────
187
+ 'model-candidates': { readonly: true },
188
+ // 思考强度档位:要问 adapter(`llm.resolveModelInfo`,异步、可能联网),但只读不写任何状态
189
+ // —— 与 model-candidates 同一档。它不进 scene-inventory,是**按需**拉取的。
190
+ 'model-reasoning': { readonly: true },
191
+ 'scene-inventory': { readonly: true },
192
+ // 跨域悬空引用体检:只读对账(索引走 readIndexSync,权威集合各读一次)。
193
+ 'state-doctor': { readonly: true },
194
+ // preset-tools 是只读枚举,但枚举会为预设建立 standing mount(官方语义:每进程只挂一次)。
195
+ // 未授权调用者不该触发挂载 —— 按写门禁。
196
+ 'preset-tools': { write: true },
197
+ 'plugin-version': { readonly: true },
198
+ });
199
+ /** 登记表里所有带某个标志的 op(按登记顺序,不去排序 —— 顺序稳定便于比对 diff)。 */
200
+ export function opsWith(flag) {
201
+ const out = [];
202
+ for (const [op, cls] of Object.entries(OP_REGISTRY))
203
+ if (cls[flag] === true)
204
+ out.push(op);
205
+ return out;
206
+ }
207
+ /** 冻结类 op。`scope` 省略 = 全部三种口径都要挡。 */
208
+ export function frozenOps(scope) {
209
+ const out = [];
210
+ for (const [op, cls] of Object.entries(OP_REGISTRY)) {
211
+ if (cls.frozen !== true)
212
+ continue;
213
+ if (scope !== undefined && (cls.frozenScope ?? 'all') !== scope)
214
+ continue;
215
+ out.push(op);
216
+ }
217
+ return out;
218
+ }
219
+ /**
220
+ * 对账:真实 op 表里**没在登记表出现**的键。
221
+ *
222
+ * 这就是 A1 要的那台机器 —— 新加一个 op 而忘了归类,这里立刻数得出来。判据是"要么登记过,
223
+ * 要么所属 service 自报过写 op 且登记过",两者都指向同一个条件:登记表里有这一条。
224
+ */
225
+ export function unclassifiedOps(opNames) {
226
+ return [...opNames].filter((op) => OP_REGISTRY[op] === undefined).sort();
227
+ }
228
+ /** 反方向:登记表里有条目、真实 op 表里却没有 → 改名或删 op 时忘了同步(幽灵条目)。 */
229
+ export function staleRegistryEntries(opNames) {
230
+ const live = new Set(opNames);
231
+ return Object.keys(OP_REGISTRY).filter((op) => !live.has(op)).sort();
232
+ }
233
+ /**
234
+ * `serviceWrite` 的双向核对:登记表说"这条由 service 自报",service 真自报了吗?
235
+ * 反过来,service 自报的写 op 都在登记表里吗?两个方向都查,谁改了必须动另一边。
236
+ */
237
+ export function serviceWriteMismatch(reported) {
238
+ const declared = new Set(Object.entries(OP_REGISTRY).filter(([, cls]) => cls.serviceWrite === true).map(([op]) => op));
239
+ const actual = new Set(reported);
240
+ return {
241
+ missing: [...actual].filter((op) => !declared.has(op)).sort(),
242
+ extra: [...declared].filter((op) => !actual.has(op)).sort(),
243
+ };
244
+ }
245
+ /** 自相矛盾的条目:既声明只读又声明要令牌 / 泄露明文。(登记时手滑的兜底。) */
246
+ export function contradictoryEntries() {
247
+ return Object.entries(OP_REGISTRY)
248
+ .filter(([, cls]) => cls.readonly === true && (cls.write === true || cls.sensitive === true))
249
+ .map(([op]) => op)
250
+ .sort();
251
+ }
252
+ /**
253
+ * 一次性对账(纯函数,无副作用):把登记表与**真实** op 表四个方向都比一遍。
254
+ *
255
+ * 为什么用真实 op 表而不是再抄一份清单:登记表本身也可能烂。只有拿 `handlers` 的键集合来
256
+ * 对,"加了 op 忘了登记"才会当场暴露 —— 这正是 F-4 那四次失守的共同形状。
257
+ */
258
+ export function auditOpRegistry(opNames, serviceWriteOps) {
259
+ const names = [...opNames];
260
+ const mismatch = serviceWriteMismatch(serviceWriteOps);
261
+ return {
262
+ unclassified: unclassifiedOps(names),
263
+ stale: staleRegistryEntries(names),
264
+ serviceWriteMissing: mismatch.missing,
265
+ serviceWriteExtra: mismatch.extra,
266
+ contradictory: contradictoryEntries(),
267
+ total: names.length,
268
+ };
269
+ }
270
+ /** 对账结果里所有问题项(空数组 = 全绿)。 */
271
+ export function auditProblems(audit) {
272
+ return [
273
+ ...audit.unclassified.map((op) => op + '(未归类)'),
274
+ ...audit.stale.map((op) => op + '(幽灵条目)'),
275
+ ...audit.serviceWriteMissing.map((op) => op + '(service 自报写、登记表未标)'),
276
+ ...audit.serviceWriteExtra.map((op) => op + '(登记表标了写、service 未自报)'),
277
+ ...audit.contradictory.map((op) => op + '(只读与写自相矛盾)'),
278
+ ];
279
+ }
@@ -16,6 +16,17 @@ export function buildCandidateOps(deps) {
16
16
  }),
17
17
  // model-candidates:宿主 LLM 目录里的 (provider, model) 对(不发网络请求)。
18
18
  'model-candidates': async () => ({ ok: true, ...(await deps.modelCandidates()) }),
19
+ // model-reasoning:某个 (provider, model) 支持的思考强度档位(人设表单的「思考强度」下拉)。
20
+ // 为什么单独一个 op:档位清单要问 adapter(`llm.resolveModelInfo`,异步、可能联网),而
21
+ // model-candidates 是"不发网络请求"的本地目录 —— 捆在一次调用里会让"打开人设表单"这件事
22
+ // 忽然变成可能联网的操作。这里只做参数归一与转发,判定留给调用方(界面按 ok 分两态显示)。
23
+ 'model-reasoning': async (args) => {
24
+ const provider = String((args && args.provider) || '').trim();
25
+ const model = String((args && args.model) || '').trim();
26
+ if (!provider || !model)
27
+ return { ok: false, error: 'provider 与 model 都要给(档位跟模型走)' };
28
+ return deps.modelReasoning(provider, model);
29
+ },
19
30
  // 场景档案勾选器数据源 v2:全部 MCP 服务器(含未运行)+ 技能全集 + 人设清单。
20
31
  'scene-inventory': async () => {
21
32
  const [rowsR, tools, skills, subs] = await Promise.all([deps.mcpmListView(), deps.toolStates(), deps.skillRows(), deps.subagentList()]);
package/lib/ops/compat.js CHANGED
@@ -110,6 +110,10 @@ export function buildCompatOps(deps) {
110
110
  },
111
111
  // 注入设置(读 / 写):压制型预设下是否仍然注入 + 各域开关。界面在「兼容」页。
112
112
  'inject-settings': (args) => deps.injectSettingsOp(args),
113
+ // 模型工具表(读 / 写):哪些工具**根本不发**给模型。工具表按每个请求付钱,关掉的
114
+ // 整份不进请求(与 MCP 停用那半边的区别:那些是宿主工具、关掉仍留在表里;这些是
115
+ // 我们自己的工具,关掉两边一起生效)。返回里带分组体积,界面据此显示 ≈token。
116
+ 'tool-table': (args) => deps.toolTableOp(args),
113
117
  // 功能总览(B6):**按功能点**回答"现在每一项到底能不能用",三层合成 ——
114
118
  // ① 装配层(listener / provider / 适配是否真的挂上,来自运行时上报通道)
115
119
  // ② 宿主能力层(compat 探测的路由判定)
@@ -141,7 +145,7 @@ export function buildCompatOps(deps) {
141
145
  ['official-suppression', '官方注入的关域拦截', 'compat'],
142
146
  ['skills-provider', '技能 provider 装配', 'skills'],
143
147
  ['projection-cache-adapter', '投影缓存删除屏障', 'sessions'],
144
- ['mcp-tool-visibility', '停用工具的可见性', 'mcp'],
148
+ ['mcp-tool-visibility', '工具表可见性(停用 / 关掉的工具不下发)', 'mcp'],
145
149
  ];
146
150
  for (const [noteId, label, tab] of assemblyRows) {
147
151
  const note = notes.get(noteId);
@@ -164,6 +168,23 @@ export function buildCompatOps(deps) {
164
168
  : token.accepted ? '已生效且本次启动已通过验证' : '已生效,本次启动尚未验证:写操作与对话会被拦住,到本页下方填写令牌');
165
169
  push('scene-lock', '场景锁定', 'scenes', lockedScenes.length === 0 ? 'ok' : 'locked', lockedScenes.length === 0 ? '无锁定场景' : `锁定中:${lockedScenes.join('、')}(写门禁按锁定场景生效)`);
166
170
  push('mcp-tools', 'MCP 工具停用', 'mcp', disabledTools === 0 ? 'ok' : 'partial', disabledTools === 0 ? '没有停用的工具' : `${disabledTools} 个工具处于停用态(执行拦截 + 可见性摘除)`);
171
+ // 工具表按每个请求付钱:这一行回答"这一轮实际发出去多少"。关掉的工具整份不进请求,
172
+ // 但代价是模型调不到它们(本插件的面板不受影响)——所以是 partial,不是 ok。
173
+ // **出厂默认关掉的那几条不算**:那是插件替用户做的一个可逆选择,不是用户关出了
174
+ // 一个缺口。把默认态报成 partial 违背本页口径(琥珀只留给"该做却没做"),也永远
175
+ // 无法消掉 —— 用户打开它们反而会被罚一个 ok。
176
+ // 末尾那句是**逐会话**的差额:官方 `skill` 工具在场的会话里我们那份加载器会再让位
177
+ // 一个(见 index.ts 的 CARRIER_DUPLICATES),本表的数字是全局口径、不含它。
178
+ const table = deps.toolTableReport();
179
+ const carrierNote = ';官方 `skill` 工具在场的会话,`skill_manager_read` 还会自动让位一份';
180
+ const userOff = table.hiddenCount - table.defaultHiddenCount;
181
+ const offPart = table.hiddenCount === 0
182
+ ? `${table.totalCount} 个工具全部下发(≈${table.totalTok} tok/轮)`
183
+ : (userOff === 0
184
+ ? `出厂默认关掉 ${table.defaultHiddenCount}/${table.totalCount} 个`
185
+ : `关掉 ${table.hiddenCount}/${table.totalCount} 个(含出厂默认 ${table.defaultHiddenCount} 个)`)
186
+ + `:一轮少发 ≈${table.hiddenTok} tok(现在 ≈${table.visibleTok} tok/轮,到「兼容」页的「模型工具表」可逐条打开;面板不受影响)`;
187
+ push('tool-table', '模型工具表', 'compat', userOff === 0 ? 'ok' : 'partial', offPart + carrierNote);
167
188
  push('native-delete', '宿主原生删除入口', 'compat', notes.has('workspace.delete-native') ? 'partial' : 'ok', notes.get('workspace.delete-native')?.detail ?? '宿主未提供原生删除入口(本插件自有完整序列)');
168
189
  // 身份 / 版本:与 compat-status 同一份数据。
169
190
  const identity = assessment?.identity;
@@ -209,7 +230,7 @@ export function buildCompatOps(deps) {
209
230
  ...(r.failed.length ? { failedCount: r.failed.length, failedNames: r.failed } : {}),
210
231
  };
211
232
  },
212
- // 注入实况(只读):最近活跃会话里模型**真正看到**的五域文本 + 本次运行的投递统计。
233
+ // 注入实况(只读):最近活跃会话里模型**真正看到**的五域文本 + 那一段对话的投递统计。
213
234
  // 回答"勾了开关到底送没送到"——界面配置与实际注入不一致时,这里一眼可见。
214
235
  'injection-live': async () => {
215
236
  const live = deps.getContextInjectorLive();
@@ -388,7 +388,7 @@ export function buildSessionOps(deps) {
388
388
  continue;
389
389
  }
390
390
  entries.push({ zip: `${id}/AGENTS.md`, abs: docAbs });
391
- // 描述侧车(「只给使用者看」的那句)随预设一起走:导出再导入不该把它丢掉。
391
+ // 描述侧车(预设那句说明)随预设一起走:导出再导入不该把它丢掉。
392
392
  const metaAbs = join(deps.promptsDir, id, 'meta.json');
393
393
  try {
394
394
  if ((await stat(metaAbs)).isFile())
@@ -0,0 +1,115 @@
1
+ // src/ops/state-doctor.ts —— 跨域**悬空引用**体检(只读)。
2
+ //
3
+ // 为什么需要它:这个插件到处在存"别人的名字" —— 场景档案存 MCP 服务器名、技能 key、
4
+ // 人设名,场景记录存提示词预设 id,模式快照存进场景前的那一整套。名字会过期:改名与删除
5
+ // 之后,存着旧名的那一条**不会报错,只是再也不匹配任何东西**。界面因此骗人:
6
+ // 「档案里还写着 代码审查」→ 看着像绑着,委派时按新名找不到,设置像在、其实不生效。
7
+ // 这一类已经真出过两次(0.9.1 人设改名、0.10.0 预设改名),所以缺的不是又一份同步代码,
8
+ // 是一个把全部悬空引用一次数出来的出口。
9
+ //
10
+ // 为什么是一个 op 而不是一份"引用表":那要求所有写路径都改成先登记再引用,是一次大动作,
11
+ // 而且要改官方 provider 的写入语义。这里只做**读侧对账**:拿现有的权威集合
12
+ // (配置里的服务器 / 实时发现的技能 / 人设 / 预设清单)去比现有的存储,比不上的说出来。
13
+ //
14
+ // 只读纪律:本模块不写任何文件。索引走 `readIndexSync`(契约见 memories/index-io.ts 的注释:
15
+ // 返回的可能是缓存实例,只许读);**不走** `rules-list` / `snapshot()` —— 那两个会做悬空
16
+ // 记忆清理并整份覆盖索引,那样一来"体检"自己就成了改动。
17
+ //
18
+ // 判据与界面共用:这里的"存在"与场景页 / 档案编辑器同一套口径(`configuredServers` /
19
+ // `knownSkillKeys` / `knownPersonas` / `promptsService.list`),不另起一套。
20
+ const toSet = (list) => (list === null ? null : new Set(list));
21
+ /** 跑一次体检。纯读,任何一域的权威集合读不到就跳过那一类并如实记下来。 */
22
+ export async function runStateDoctor(deps) {
23
+ const findings = [];
24
+ const skipped = [];
25
+ const index = deps.index() || {};
26
+ const scenes = index.scenes || {};
27
+ const archives = index.archives || {};
28
+ const presets = toSet(await deps.presetIds());
29
+ if (presets === null)
30
+ skipped.push('prompts');
31
+ const servers = toSet(await deps.serverNames());
32
+ if (servers === null)
33
+ skipped.push('mcp');
34
+ const skills = await deps.skillKeys();
35
+ if (skills === null)
36
+ skipped.push('skills');
37
+ const personas = await deps.personaNames();
38
+ if (personas === null)
39
+ skipped.push('subagents');
40
+ // ① 场景记录 → 提示词预设。界面上那条 `scenes.prompt.missing` 标签今天**永远不亮**
41
+ // (宿主把"绑的预设读不到正文"当成"没绑",见 memories/service.ts 的 resolveScenePreset),
42
+ // 所以改名留下的悬空绑定在别处没有任何地方说得出来 —— 这一类是本 op 的主要新增价值。
43
+ if (presets !== null) {
44
+ for (const scene of Object.keys(scenes)) {
45
+ const bound = String(scenes[scene]?.prompt || '');
46
+ if (bound !== '' && !presets.has(bound)) {
47
+ findings.push({ where: `场景「${scene}」绑定的提示词预设`, kind: 'prompt', name: bound });
48
+ }
49
+ }
50
+ }
51
+ // ② 档案三段 + 备注段:勾选集里的名字逐个对权威集合。
52
+ //
53
+ // (这里原本还想查"档案表里孤儿场景键"。**没做**,因为它要的权威集合是"哪些场景存在",
54
+ // 而那份只能从 `snapshot()` 拿 —— 那个函数会顺手清理悬空记忆并整份覆盖索引,
55
+ // 一个"体检"op 自己先把状态改了是不可接受的。少一个检查远比误报一条好:
56
+ // 索引没镜像到目录场景时,报"这份档案没人读"是假话。)
57
+ for (const scene of Object.keys(archives)) {
58
+ const archive = archives[scene] || {};
59
+ if (servers !== null) {
60
+ for (const server of Object.keys(archive.mcp || {})) {
61
+ if (!servers.has(server))
62
+ findings.push({ where: `场景「${scene}」的 MCP 勾选`, kind: 'mcp', name: server });
63
+ }
64
+ for (const server of Object.keys(archive.mcpNotes || {})) {
65
+ if (!servers.has(server))
66
+ findings.push({ where: `场景「${scene}」的场景备注`, kind: 'mcpNote', name: server });
67
+ }
68
+ }
69
+ if (skills !== null) {
70
+ for (const key of Array.isArray(archive.skills) ? archive.skills : []) {
71
+ if (!skills.has(String(key)))
72
+ findings.push({ where: `场景「${scene}」的技能勾选`, kind: 'skill', name: String(key) });
73
+ }
74
+ }
75
+ if (personas !== null) {
76
+ for (const name of Array.isArray(archive.subagents) ? archive.subagents : []) {
77
+ if (!personas.has(String(name)))
78
+ findings.push({ where: `场景「${scene}」的人设勾选`, kind: 'persona', name: String(name) });
79
+ }
80
+ }
81
+ }
82
+ // ⑥ 模式快照:退出场景按它整体回写,所以快照里的悬空名字**比档案里的更贵** ——
83
+ // 档案的悬空项在应用时被补集成 stale 名单,快照则会在退出时原样写回去。
84
+ const snapshot = index.mode && index.mode.snapshot;
85
+ if (snapshot) {
86
+ const modeScene = String((index.mode && index.mode.scene) || '(当前场景)');
87
+ if (skills !== null) {
88
+ for (const key of Object.keys(snapshot.skills || {})) {
89
+ if (!skills.has(key))
90
+ findings.push({ where: `模式快照(退出「${modeScene}」时要回写的技能)`, kind: 'snapshot', name: key });
91
+ }
92
+ }
93
+ if (servers !== null) {
94
+ for (const server of Object.keys(snapshot.mcp || {})) {
95
+ if (!servers.has(server))
96
+ findings.push({ where: `模式快照(退出「${modeScene}」时要回写的 MCP 停用表)`, kind: 'snapshot', name: server });
97
+ }
98
+ }
99
+ if (personas !== null) {
100
+ const personaNames = new Set([
101
+ ...Object.keys(snapshot.subagentsAll || {}),
102
+ ...(Array.isArray(snapshot.subagents) ? snapshot.subagents : []),
103
+ ...(Array.isArray(snapshot.subagentsOn) ? snapshot.subagentsOn : []),
104
+ ]);
105
+ for (const name of personaNames) {
106
+ if (!personas.has(String(name)))
107
+ findings.push({ where: `模式快照(退出「${modeScene}」时要回写的人设)`, kind: 'snapshot', name: String(name) });
108
+ }
109
+ }
110
+ }
111
+ const counts = {};
112
+ for (const row of findings)
113
+ counts[row.kind] = (counts[row.kind] || 0) + 1;
114
+ return { findings, counts, skipped };
115
+ }
@@ -18,8 +18,29 @@ import { createHash } from 'node:crypto';
18
18
  import { isValidPresetId, LAST_APPLIED_PRESET_ID, normalizePresetId } from './preset-id.js';
19
19
  import { listTrashEntries, moveOutOfTrash, moveToTrash, purgeTrashEntry, readTrashEntry } from '../hub.js';
20
20
  const FILENAME = 'AGENTS.md';
21
- /** 描述侧车:与 AGENTS.md 同目录,避免把「只给使用者看」的文字注入提示词。 */
21
+ /** 描述侧车:与 AGENTS.md 同目录,避免描述的文字被当成提示词正文注入。 */
22
22
  const META_FILE = 'meta.json';
23
+ /**
24
+ * 预设描述的字数上限。界面上那两枚输入框(提示词页的新建 / 编辑弹窗)用同一个数当
25
+ * `maxLength` 与字数计数的分母,客户端镜像在 `42-shared-ui.js` 的 `PRESET_DESC_MAX`
26
+ * —— 改这里要一起改它。
27
+ *
28
+ * 为什么需要它(0.14.0):描述此前是"只给使用者看"的,一个字都不进模型上下文,所以没有
29
+ * 上限也说得过去。现在 `prompt_manager_list` 会把它打给模型(否则模型无法在预设之间做
30
+ * 选择),它就成了**常驻成本**,必须有个预算。300 与 MCP 备注同数(那一条也是"给模型看的
31
+ * 一句用户提示"),但那是两个各自独立的预算,别当成同一个常量共用。
32
+ */
33
+ export const DEFAULT_PRESET_DESC_MAX_LENGTH = 300;
34
+ /**
35
+ * 压成一行并按上限截断。与 `normalizeMcpNote`(`../mcp/state-section.ts`)同形:两处都是
36
+ * "给模型看的一句用户文字",口径必须一致,否则同一句在界面、段、工具里会是三个样子。
37
+ */
38
+ export function normalizePresetDescription(value, maxLength = DEFAULT_PRESET_DESC_MAX_LENGTH) {
39
+ const flat = String(value ?? '').replaceAll(/\s+/g, ' ').trim();
40
+ if (!flat)
41
+ return '';
42
+ return flat.length <= maxLength ? flat : `${flat.slice(0, maxLength - 3)}...`;
43
+ }
23
44
  const LAST_APPLIED_ID = LAST_APPLIED_PRESET_ID;
24
45
  /**
25
46
  * 「最近一次应用」的记录侧车。是**文件**不是目录,所以 `list()` / `ensureInit()` 的
@@ -46,9 +67,11 @@ export function createPromptsService(_ctx, deps) {
46
67
  return null;
47
68
  }
48
69
  }
49
- // ── 描述(「只给使用者看」)────────────────────────────────────────────────
70
+ // ── 描述(给人看,也进模型清单)────────────────────────────────────────────
50
71
  // 存在预设目录的 `meta.json` 里,**不写进 AGENTS.md**:那个文件的正文会被原样注入
51
72
  // 系统提示词,把「这份预设是干什么的」写进去等于凭空给模型加了一段说明。
73
+ // 0.14.0 起另有一条通道会把它带给模型 —— `prompt_manager_list` 的清单行(模型要在预设
74
+ // 之间做选择,而 id 本身不说明用途),所以它有了 `DEFAULT_PRESET_DESC_MAX_LENGTH` 这条预算。
52
75
  // 读写都 best-effort:描述坏掉/写不进去不该让「保存预设」失败(正文才是本体)。
53
76
  async function readDescription(id) {
54
77
  try {