dsh-plugin-tool-management 0.16.6 → 0.18.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 (96) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/README.md +20 -4
  3. package/README_EN.md +20 -4
  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 +59 -0
  21. package/lib/audit-log.js +6 -2
  22. package/lib/client.js +1615 -148
  23. package/lib/compat/capability-specs.js +321 -0
  24. package/lib/compat/host-probe.js +261 -0
  25. package/lib/compat/host-roots.js +168 -0
  26. package/lib/compat/probe-types.js +6 -0
  27. package/lib/compat/probe.js +54 -714
  28. package/lib/context-inject-contract.js +114 -0
  29. package/lib/context-inject-frame.js +147 -0
  30. package/lib/context-inject-select.js +127 -0
  31. package/lib/context-inject-surface.js +97 -0
  32. package/lib/context-inject.js +57 -459
  33. package/lib/host-names.js +150 -0
  34. package/lib/http-api.js +229 -0
  35. package/lib/hub.js +84 -11
  36. package/lib/imports/parsers.js +40 -15
  37. package/lib/index.js +235 -843
  38. package/lib/mcp/inspect-probe.js +199 -0
  39. package/lib/mcp/loader-token.js +26 -0
  40. package/lib/mcp/manager.js +267 -558
  41. package/lib/mcp/patch-yaml.js +207 -40
  42. package/lib/mcp/secret-guard.js +122 -16
  43. package/lib/mcp/tool-visibility.js +388 -0
  44. package/lib/memories/archive-engine.js +163 -16
  45. package/lib/memories/archive.js +10 -1
  46. package/lib/memories/discovery.js +261 -0
  47. package/lib/memories/export-plan.js +42 -0
  48. package/lib/memories/index-io.js +125 -12
  49. package/lib/memories/memories-types.js +1 -0
  50. package/lib/memories/service.js +167 -78
  51. package/lib/memories/snapshot.js +41 -260
  52. package/lib/op-registry.js +72 -8
  53. package/lib/ops/compat.js +17 -2
  54. package/lib/ops/prompts.js +18 -5
  55. package/lib/ops/quick-prompts.js +26 -0
  56. package/lib/ops/scene-records.js +157 -10
  57. package/lib/ops/scene-sync.js +71 -1
  58. package/lib/ops/sessions.js +63 -13
  59. package/lib/ops/snapshot.js +123 -24
  60. package/lib/ops/trash.js +45 -9
  61. package/lib/prompts/quick-service.js +211 -0
  62. package/lib/prompts/service.js +7 -2
  63. package/lib/request-gate.js +115 -56
  64. package/lib/scene-linkage.js +184 -0
  65. package/lib/scene-prompt-sync.js +46 -7
  66. package/lib/scenes/candidates.js +21 -3
  67. package/lib/sessions/archive-schemas.js +121 -0
  68. package/lib/sessions/path-key.js +46 -0
  69. package/lib/sessions/workspace.js +45 -168
  70. package/lib/settings-sidecars.js +143 -0
  71. package/lib/skills/core-types.js +8 -0
  72. package/lib/skills/core-util.js +81 -0
  73. package/lib/skills/core.js +97 -2297
  74. package/lib/skills/dir-watch.js +71 -0
  75. package/lib/skills/frontmatter.js +96 -0
  76. package/lib/skills/homes.js +14 -0
  77. package/lib/skills/import-pipeline.js +231 -0
  78. package/lib/skills/manager-state.js +800 -0
  79. package/lib/skills/official.js +269 -0
  80. package/lib/skills/provider-registration.js +89 -0
  81. package/lib/skills/service.js +4 -234
  82. package/lib/skills/skill-roots.js +287 -0
  83. package/lib/skills/skill-trash.js +265 -0
  84. package/lib/skills/system-trash.js +97 -0
  85. package/lib/skills/upload-pipeline.js +374 -0
  86. package/lib/slash-settings.js +25 -0
  87. package/lib/subagents/persona-parse.js +312 -0
  88. package/lib/subagents/persona-types.js +5 -0
  89. package/lib/subagents/persona-write.js +93 -0
  90. package/lib/subagents/run-provider.js +159 -0
  91. package/lib/subagents/service.js +20 -639
  92. package/lib/subagents/tool-filter.js +98 -0
  93. package/lib/token-config.js +240 -0
  94. package/lib/tool-table-switch.js +163 -0
  95. package/lib/tools/scene.js +40 -9
  96. package/package.json +3 -2
@@ -8,13 +8,17 @@
8
8
  //
9
9
  // 对 service.ts 只做 type-only 引用(`import type`),避免与它形成运行时循环依赖。
10
10
  import { readdirSync, statSync } from 'node:fs';
11
- import { cp, lstat, mkdir, readFile, readdir, realpath, rename, rm } from 'node:fs/promises';
12
- import { dirname, join, resolve } from 'node:path';
13
- import { parseSkillDoc, resolveDshHome, unquote } from '../skills/core.js';
14
- import { MAX_SOURCE_DEPTH, MAX_DIRECTORIES, MAX_ENTRIES, MAX_DESCRIPTION_LENGTH, DEFAULT_ORDER, DEFAULT_GROUP_ORDER, GLOBAL_SCENE, TRUNCATION_MARKER, DROPPED_HEADING, SCENE_MEMORY_NOTE, LEGACY_BUNDLE_DOC, bundleDocName, SEGMENT_RULE_HINT, SHARED_GROUP, byteLen, message, isValidGroupSegment } from './constants.js';
11
+ import { readFile } from 'node:fs/promises';
12
+ import { dirname, join } from 'node:path';
13
+ import { parseSkillDoc } from '../skills/core.js';
14
+ import { MAX_SOURCE_DEPTH, MAX_DIRECTORIES, MAX_ENTRIES, DEFAULT_ORDER, DEFAULT_GROUP_ORDER, GLOBAL_SCENE, TRUNCATION_MARKER, DROPPED_HEADING, SCENE_MEMORY_NOTE, LEGACY_BUNDLE_DOC, bundleDocName, SHARED_GROUP, byteLen, message, isValidGroupSegment } from './constants.js';
15
15
  import { ensureSceneRecords, resolveActiveScenes, signatureOfIndex, sceneLabel, sceneHeading, sceneLine, compareSceneBuckets, memoryBlock } from './projection.js';
16
- import { isIndexQuarantined, readIndex, writeIndex, pathExists, readFileIfExistsSync } from './index-io.js';
17
- export const identity = (p) => (process.platform === 'win32' ? p.toLowerCase() : p);
16
+ import { isIndexQuarantined, readIndex, writeIndex, readFileOrThrowSync, isAbsentError } from './index-io.js';
17
+ // 来源发现与投影(扫目录读成条目 / frontmatter 投成规则 / 老布局搬家)2026-10-01 整段搬到
18
+ // ./discovery.js,正文一字未改(moved-verify 逐字对拍)。snapshot.js 的公开面照旧:搬走的
19
+ // 九件全部从这里再出口 —— memories/service.ts 按 ./snapshot.js 引它们。
20
+ export { deriveDescription, deriveFromDoc, discover, identity, projectRule, relocateLegacyLayout, } from './discovery.js';
21
+ import { deriveFromDoc, discover, projectRule, } from './discovery.js';
18
22
  // ── 文件工具 ───────────────────────────────────────────────────────────────
19
23
  /**
20
24
  * BFS 发现(realpath 防环、深度/目录/条目预算),与 readonly-discovery 同构,
@@ -27,213 +31,10 @@ export const identity = (p) => (process.platform === 'win32' ? p.toLowerCase() :
27
31
  * 都没有则它是场景/子分类,继续遍历其下 .md(flat,任意层级)
28
32
  * 与子目录。同名 flat 与 bundle 冲突时 bundle 优先,flat 记入 shadowed。
29
33
  */
30
- export async function discover(memoriesRoot) {
31
- const entries = new Map();
32
- const shadowed = [];
33
- const groups = new Set();
34
- const scenes = [];
35
- const warnings = [];
36
- const rootPath = resolve(memoriesRoot);
37
- try {
38
- const st = await lstat(rootPath);
39
- if (!st.isDirectory() || st.isSymbolicLink())
40
- return { entries, shadowed, groups, scenes, warnings, truncated: false };
41
- }
42
- catch {
43
- return { entries, shadowed, groups, scenes, warnings, truncated: false };
44
- }
45
- const queue = [
46
- { path: rootPath, group: '', depth: 0, ancestors: new Set() },
47
- ];
48
- let directories = 0;
49
- let itemCount = 0;
50
- let truncated = false;
51
- const addEntry = (entry) => {
52
- const existing = entries.get(entry.id);
53
- if (!existing) {
54
- entries.set(entry.id, entry);
55
- return;
56
- }
57
- if (entry.kind === 'bundle' && existing.kind === 'flat') {
58
- shadowed.push({ ...existing, shadowed: true });
59
- entries.set(entry.id, entry);
60
- return;
61
- }
62
- if (entry.kind === 'flat' && existing.kind === 'bundle') {
63
- shadowed.push({ ...entry, shadowed: true });
64
- }
65
- };
66
- for (let cursor = 0; cursor < queue.length; cursor++) {
67
- if (directories >= MAX_DIRECTORIES || itemCount >= MAX_ENTRIES) {
68
- truncated = true;
69
- break;
70
- }
71
- const current = queue[cursor];
72
- let realDirectory;
73
- let dirents = [];
74
- try {
75
- realDirectory = await realpath(current.path);
76
- if (current.ancestors.has(identity(realDirectory)))
77
- continue;
78
- const dir = await readdir(current.path, { withFileTypes: true });
79
- directories++;
80
- for (const item of dir) {
81
- if (itemCount >= MAX_ENTRIES) {
82
- truncated = true;
83
- break;
84
- }
85
- itemCount++;
86
- dirents.push(item);
87
- }
88
- }
89
- catch {
90
- continue; // 失效链接或不可读目录不阻断其他来源
91
- }
92
- // bundle 检查:当前目录含 `<目录名>.md`(或旧版 `SKILL.md`)→ 它是规则叶子,父路径为场景。
93
- //
94
- // **根下的一级目录恒为场景**(`parentGroup === ''` 时不做 bundle 判断)——与
95
- // `probeSceneFilesSync`(注入段)同口径。场景 `1` 里一条叫 `1` 的记忆(`memories/1/1.md`)
96
- // 与「根层的 bundle `1`」在磁盘上同形,认成后者会把整个场景目录吃成叶子:同场景其余
97
- // 记忆全从列表消失、多出一条「未归属场景」的同名记忆,新建记忆还会被误报「同名 bundle
98
- // 遮蔽」(2026-09-16 用户实测)。根层因此没有 bundle,只有 legacy 裸 .md(见 rulesCheck 的 noScene)。
99
- if (current.group !== '') {
100
- const sepIdx = current.group.lastIndexOf('/');
101
- const parentGroup = sepIdx >= 0 ? current.group.slice(0, sepIdx) : '';
102
- const name = sepIdx >= 0 ? current.group.slice(sepIdx + 1) : current.group;
103
- let docPath = '';
104
- if (parentGroup !== '') {
105
- for (const candidate of [bundleDocName(name), LEGACY_BUNDLE_DOC]) {
106
- const p = join(current.path, candidate);
107
- try {
108
- const st = await lstat(p);
109
- if (st.isFile() && !st.isSymbolicLink()) {
110
- docPath = p;
111
- break;
112
- }
113
- }
114
- catch { /* 该候选名不存在 → 试下一个 */ }
115
- }
116
- }
117
- if (docPath !== '') {
118
- const id = parentGroup ? `${parentGroup}/${name}` : name;
119
- addEntry({ id, group: parentGroup, name, kind: 'bundle', docPath, entryPath: current.path });
120
- continue;
121
- }
122
- }
123
- if (current.group !== '') {
124
- groups.add(current.group);
125
- // 一级目录 = 场景名(多段场景名用 '/' 连接,见 isValidGroupPath)。
126
- if (current.depth === 1)
127
- scenes.push(current.group);
128
- }
129
- dirents.sort((a, b) => a.name.localeCompare(b.name));
130
- const ancestors = new Set(current.ancestors).add(identity(realDirectory));
131
- for (const item of dirents) {
132
- if (item.name.startsWith('.'))
133
- continue; // 隐藏目录/文件跳过
134
- const path = join(current.path, item.name);
135
- try {
136
- const st = await lstat(path);
137
- // 符号链接一律不跟随:注入路径(`probeSceneFilesSync`)本来就不跟随,两端必须同口径 ——
138
- // 否则列表里会出现**永远注入不进去**的记忆(用户打开开关却什么也没发生)。顺带也关掉了
139
- // 「链接指向根外、写操作顺着链接穿透出去」这条路(写入侧另有 realpath 断言兜底)。
140
- if (st.isSymbolicLink())
141
- continue;
142
- if (st.isDirectory()) {
143
- if (!isValidGroupSegment(item.name)) {
144
- warnings.push(`跳过非法目录名(${SEGMENT_RULE_HINT}):${current.group ? current.group + '/' : ''}${item.name}`);
145
- continue;
146
- }
147
- if (current.depth >= MAX_SOURCE_DEPTH || queue.length >= MAX_DIRECTORIES) {
148
- truncated = true;
149
- continue;
150
- }
151
- queue.push({
152
- path,
153
- group: current.group ? `${current.group}/${item.name}` : item.name,
154
- depth: current.depth + 1,
155
- ancestors,
156
- });
157
- continue;
158
- }
159
- if (!st.isFile())
160
- continue;
161
- if (!item.name.toLowerCase().endsWith('.md'))
162
- continue; // 只认 .md
163
- const name = item.name.slice(0, -3);
164
- const id = current.group ? `${current.group}/${name}` : name;
165
- addEntry({ id, group: current.group, name, kind: 'flat', docPath: path, entryPath: path });
166
- }
167
- catch {
168
- /* 来源在扫描期间失效时跳过 */
169
- }
170
- }
171
- }
172
- return { entries, shadowed, groups, scenes, warnings, truncated };
173
- }
174
34
  // ── 规则解析 + 派生 ────────────────────────────────────────────────────────
175
35
  /** 派生 description:首个 `#` 标题 → 首个非空行;截断 500 字符。 */
176
- export function deriveDescription(body) {
177
- const text = String(body ?? '').trim();
178
- if (!text)
179
- return '';
180
- const heading = /^#\s+(.+)$/m.exec(text);
181
- const raw = heading && heading[1].trim() ? heading[1].trim() : text.split(/\r?\n/, 1)[0].trim();
182
- return raw.slice(0, MAX_DESCRIPTION_LENGTH);
183
- }
184
36
  /** 从 frontmatter/正文派生字段(派生只存在于内存投影,不写回文件)。 */
185
- export function deriveFromDoc(entry, doc) {
186
- const declaredName = doc.map.name != null && String(doc.map.name).trim() !== '' ? unquote(String(doc.map.name)).trim() : '';
187
- const name = declaredName || entry.name;
188
- let description = doc.map.description != null ? unquote(String(doc.map.description)).trim() : '';
189
- let descriptionDerived = false;
190
- if (description === '') {
191
- description = deriveDescription(doc.body);
192
- descriptionDerived = true;
193
- }
194
- const globs = doc.map.globs != null && String(doc.map.globs).trim() !== '' ? String(doc.map.globs).trim() : undefined;
195
- let whenToUse;
196
- if (doc.map.whenToUse != null && String(doc.map.whenToUse).trim() !== '') {
197
- whenToUse = String(doc.map.whenToUse).trim();
198
- }
199
- else if (globs) {
200
- whenToUse = `适用路径:${globs}`;
201
- }
202
- const metadata = doc.map.metadata !== undefined ? doc.map.metadata : undefined;
203
- return { name, description, descriptionDerived, whenToUse, globs, metadata };
204
- }
205
37
  /** 合并索引字段(enabled/order 等以索引为准;无记录走默认投影)。 */
206
- export function projectRule(entry, derived, idxEntry) {
207
- // 体积按**正文文件**算(bundle 的附件不计:附件不进注入正文)。一次 stat 一条记忆,
208
- // 结果随快照的 1s TTL 缓存一起复用;读不到就不带这个字段,别把"不知道"报成 0 B。
209
- let size;
210
- try {
211
- size = statSync(entry.docPath).size;
212
- }
213
- catch {
214
- size = undefined;
215
- }
216
- const rule = {
217
- id: entry.id,
218
- group: entry.group,
219
- name: derived.name,
220
- form: entry.kind,
221
- path: entry.docPath,
222
- ...(size !== undefined ? { bytes: size } : {}),
223
- description: derived.description,
224
- ...(derived.descriptionDerived ? { descriptionDerived: true } : {}),
225
- ...(derived.whenToUse !== undefined ? { whenToUse: derived.whenToUse } : {}),
226
- ...(derived.globs !== undefined ? { globs: derived.globs } : {}),
227
- ...(derived.metadata !== undefined ? { metadata: derived.metadata } : {}),
228
- enabled: idxEntry?.enabled ?? true,
229
- order: idxEntry?.order ?? DEFAULT_ORDER,
230
- tags: Array.isArray(idxEntry?.tags) ? idxEntry.tags : [],
231
- pinned: idxEntry?.pinned === true,
232
- note: typeof idxEntry?.note === 'string' ? idxEntry.note : '',
233
- ...(typeof idxEntry?.updatedAt === 'string' ? { updatedAt: idxEntry.updatedAt } : {}),
234
- };
235
- return rule;
236
- }
237
38
  // ── 侧车文件(索引 / 场景)─────────────────────────────────────────────────
238
39
  /** 把旧布局搬进 `tool-management/`(每个进程只尝试一次,幂等)。
239
40
  *
@@ -246,49 +47,6 @@ export function projectRule(entry, derived, idxEntry) {
246
47
  * 源目录**保留**(内容已搬走,留空壳不影响正确性,也方便用户核对)。
247
48
  * 目标已存在同名项 → 保留目标、跳过该项(绝不覆盖新数据)。
248
49
  */
249
- export async function relocateLegacyLayout(memoriesRoot) {
250
- const home = resolveDshHome();
251
- const legacyRoots = [join(home, 'scene-memory'), join(home, 'rules')];
252
- for (const legacyRoot of legacyRoots) {
253
- if (!(await pathExists(legacyRoot)))
254
- continue;
255
- if (identity(resolve(legacyRoot)) === identity(resolve(memoriesRoot)))
256
- continue; // 自定义 roots 落在旧路径 → 不自我搬移
257
- let items;
258
- try {
259
- items = await readdir(legacyRoot, { withFileTypes: true });
260
- }
261
- catch {
262
- continue;
263
- }
264
- for (const item of items) {
265
- if (item.name.startsWith('.'))
266
- continue;
267
- const src = join(legacyRoot, item.name);
268
- // 根层 .md = 旧的「全局」桶 → 搬进 `global/`,文件名保留(否则多套一层目录)。
269
- // 其余条目(场景目录)整体搬进 memories/,目录名保留。
270
- const isRootMd = item.isFile() && item.name.toLowerCase().endsWith('.md');
271
- const destDir = isRootMd ? join(memoriesRoot, GLOBAL_SCENE) : memoriesRoot;
272
- const dest = join(destDir, item.name);
273
- try {
274
- if (await pathExists(dest))
275
- continue;
276
- await mkdir(destDir, { recursive: true });
277
- try {
278
- await rename(src, dest);
279
- }
280
- catch {
281
- // 跨设备/被占用 → 复制后删源;复制失败则保留源文件(宁可重复,不可丢失)。
282
- await cp(src, dest, { recursive: true });
283
- await rm(src, { recursive: true, force: true });
284
- }
285
- }
286
- catch {
287
- /* 单项失败不阻断其余项(如文件被外部程序锁住,下次启动再试) */
288
- }
289
- }
290
- }
291
- }
292
50
  // ── 快照(发现 + 索引合并 + 索引清理)──────────────────────────────────────
293
51
  export async function buildSnapshot(memoriesRoot, stateDir) {
294
52
  const discovery = await discover(memoriesRoot);
@@ -365,13 +123,23 @@ export async function buildSnapshot(memoriesRoot, stateDir) {
365
123
  .sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
366
124
  return { rules, groups, scenes: discovery.scenes, warnings, truncated: discovery.truncated, entries, bodies, index };
367
125
  }
126
+ /**
127
+ * `mtime:size` 指纹;**不存在**返回 `'missing'`,其余 IO 失败**抛异常**。
128
+ *
129
+ * 为什么抛(2026-09-30 审查 F7):探测阶段这个值同时用来判「bundle 的正文文件在不在」。
130
+ * 把 EACCES/EBUSY 一并吞成 `'missing'`,等于把"读不动"读成"没有这条记忆" —— 段里少一条,
131
+ * 而注入通道只看到"内容变了/变空了",最坏时发出一条假的「已清空」。抛出去之后,注入侧
132
+ * 报 `error` 并**保留上一轮内容**,界面侧退回上一轮投影(见 service.ts 的同名注释)。
133
+ */
368
134
  export function fileStampSync(path) {
369
135
  try {
370
136
  const st = statSync(path);
371
137
  return `${st.mtimeMs}:${st.size}`;
372
138
  }
373
- catch {
374
- return 'missing';
139
+ catch (e) {
140
+ if (isAbsentError(e))
141
+ return 'missing';
142
+ throw e;
375
143
  }
376
144
  }
377
145
  /**
@@ -381,6 +149,11 @@ export function fileStampSync(path) {
381
149
  * 根层的裸 .md **不再是记忆**(旧的「全局」桶已迁入 `global/`,见 relocateLegacyLayout),
382
150
  * 因此这里不再扫描根层文件——把文件丢在 memories/ 根下不会静默生效,也不会被投影。
383
151
  * `signature` 不变 ⇒ 上次渲染结果可原样复用(零正文 IO、零重排)。
152
+ *
153
+ * **读失败抛异常、不存在才算空**(2026-09-30 审查 F7):根目录与子目录的 `readdirSync` 只在
154
+ * `ENOENT`(目录不存在 = 全新用户 / 刚删掉)时退化成空段;其余 IO 错误(Windows 上杀软 /
155
+ * 索引器 / 备份软件占住会报 EACCES/EBUSY)一律抛给调用方 —— 把"读不动"当成"没有记忆"会让
156
+ * 注入通道发出假的「已清空」。
384
157
  */
385
158
  export function probeSceneFilesSync(memoriesRoot, index,
386
159
  // 索引已损坏时置真:索引是开关的唯一真相源,它没了就无法知道用户开过哪些记忆,
@@ -395,8 +168,11 @@ forceDisabled = false) {
395
168
  try {
396
169
  rootEntries = readdirSync(memoriesRoot, { withFileTypes: true });
397
170
  }
398
- catch {
399
- // 目录不存在/不可读 → 空段(不报错)。签名含固定前缀,便于与"空树"区分。
171
+ catch (e) {
172
+ // 目录不存在(全新用户 / 刚删掉)→ 空段。签名含固定前缀,便于与"空树"区分。
173
+ // 其余 IO 错误必须抛:见函数头注释(F7)。
174
+ if (!isAbsentError(e))
175
+ throw e;
400
176
  return { refs: [], scenes: [], truncated: false, signature: `∅|${signatureOfIndex(index)}` };
401
177
  }
402
178
  const add = (ref) => {
@@ -415,8 +191,11 @@ forceDisabled = false) {
415
191
  try {
416
192
  entries = readdirSync(dir, { withFileTypes: true });
417
193
  }
418
- catch {
419
- return;
194
+ catch (e) {
195
+ // 子目录在遍历中途被删 → 跳过(正常竞态);其余 IO 失败照函数头的口径抛出(F7)。
196
+ if (isAbsentError(e))
197
+ return;
198
+ throw e;
420
199
  }
421
200
  budget.dirs++;
422
201
  entries.sort((a, b) => a.name.localeCompare(b.name));
@@ -504,9 +283,11 @@ forceDisabled = false) {
504
283
  export function renderSceneMemory(probe, index, maxBytes) {
505
284
  const files = [];
506
285
  for (const ref of probe.refs) {
507
- const text = readFileIfExistsSync(ref.path);
286
+ const text = readFileOrThrowSync(ref.path);
287
+ // 探测与读取之间被删 → 跳过(下次指纹变化会再校正)。读不动**不**走这条:那会静默
288
+ // 少一条记忆,最坏时让注入通道发假的「已清空」(F7)—— 抛出去由调用方决定处置。
508
289
  if (text === null)
509
- continue; // 探测与读取之间被删:跳过(下次指纹变化会再校正)
290
+ continue;
510
291
  const doc = parseSkillDoc(text);
511
292
  const derived = deriveFromDoc({ id: ref.id, group: ref.scene, name: ref.name, kind: ref.kind, docPath: ref.path, entryPath: ref.path }, doc);
512
293
  files.push({
@@ -12,6 +12,8 @@
12
12
  // 而 service 不再自报 → 也红。两边谁改了都必须动另一边。
13
13
  // write 需要访问令牌。判据是"会不会改**宿主或外部系统**的状态" —— 注意这**不是**
14
14
  // "会不会写盘":见下方 readonly 那一段。
15
+ // writeWhen 仅对"两态 op"(一个名字兼读与写)生效:**这次调用**算不算写。判据不成立
16
+ // = 纯读,免令牌也不记流水。没带这一项的 op,标了 write 就一律按写处理。
15
17
  // sensitive 会泄露明文凭据 / 完整配置。**必须**带对的令牌,没配令牌就一律拒绝。
16
18
  // frozen 任一场景 locked=true 时整体拒绝(五个管理域冻结)。
17
19
  // frozenScope frozen 的三种口径,默认 'all':
@@ -43,7 +45,7 @@ export const OP_REGISTRY = Object.freeze({
43
45
  'mcpm-import': { write: true, frozen: true },
44
46
  'mcpm-compact': { write: true, frozen: true },
45
47
  'mcpm-note': { write: true, frozen: true },
46
- 'mcpm-settings': { write: true, frozen: true },
48
+ 'mcpm-settings': { write: true, frozen: true, writeWhen: (a) => a.set === true },
47
49
  'mcpm-set-enabled': { write: true, frozen: true, syncsArchive: true },
48
50
  'mcpm-set-all': { write: true, frozen: true, syncsArchive: true },
49
51
  'mcpm-tool-enabled': { write: true, frozen: true, syncsArchive: true },
@@ -56,7 +58,10 @@ export const OP_REGISTRY = Object.freeze({
56
58
  'mcpm-tools-refresh': { write: true },
57
59
  'mcpm-tools': { readonly: true },
58
60
  // 配置体检:只读补丁文件 + 一次 PATH 查询(`where`/`which`,不执行配置里的命令)。
59
- // 返回的是检查项 id,不回任何字段值 —— 与 `mcpm-list` 同档,不带令牌。
61
+ // 回的是检查项 id + 少量**打码后**的字段值(URL 查询串走 `maskUrlQuery`,与 `mcpm-list`
62
+ // 同口径;`command` 明文,理由同 `mcpm-list`:命令名不是凭据,`args` 两边都不回)。
63
+ // 与 `mcpm-list` 同档,不带令牌 —— 0.16.6 之前这里回的是 URL 原文,等于让免令牌的请求
64
+ // 拿到 `?api_key=…`,绕过了 `mcpm-reveal` 那道防线(审查 P0-5,0.17.0 修)。
60
65
  'mcpm-inspect': { readonly: true },
61
66
  // ── 技能域(22,含内联的 skill-open)───────────────────────────────────────
62
67
  'skill-state': { annotatesLock: true },
@@ -148,6 +153,22 @@ export const OP_REGISTRY = Object.freeze({
148
153
  'agentsmd-import': { write: true, frozen: true },
149
154
  'agentsmd-trash-restore': { write: true, frozen: true },
150
155
  'agentsmd-trash-delete': { write: true, frozen: true },
156
+ // ── 快捷提示词域(8)───────────────────────────────────────────────────────
157
+ // 只改插件自己的侧车(`hub/quick-prompts/`),不写 AGENTS.md、不进场景、不进注入;
158
+ // 但"改用户存好的东西"按本文件口径就是写操作(判据不是"会不会写盘",见文件头)。
159
+ // frozen 与五个管理域同一条:**锁定期间整体只读**(用户裁定 2026-09-30,不给它开例外,
160
+ // 免得"锁定"这件事要分两套话术解释)。
161
+ 'quickprompt-list': { readonly: true },
162
+ 'quickprompt-trash-list': { readonly: true },
163
+ 'quickprompt-create': { write: true, frozen: true },
164
+ 'quickprompt-update': { write: true, frozen: true },
165
+ // 那颗开关改的是"这条在不在对话框 `/` 菜单里出现",动的仍是用户自己的东西 ——
166
+ // 与 create/update 同档,不因为它不碰正文就免了令牌。`syncsArchive`:场景绑了快捷词时,
167
+ // 在场景里手动开关一条要跟着改进那份勾选集(未绑定的场景不跟,见 index.ts 那一支的注释)。
168
+ 'quickprompt-toggle': { write: true, frozen: true, syncsArchive: true },
169
+ 'quickprompt-remove': { write: true, frozen: true },
170
+ 'quickprompt-trash-restore': { write: true, frozen: true },
171
+ 'quickprompt-trash-delete': { write: true, frozen: true },
151
172
  // ── 历史会话域(16)────────────────────────────────────────────────────────
152
173
  'history-list': { readonly: true },
153
174
  'history-sessions': { readonly: true },
@@ -187,14 +208,24 @@ export const OP_REGISTRY = Object.freeze({
187
208
  'injection-live': { readonly: true },
188
209
  'backups-list': { readonly: true },
189
210
  // 注入设置(各域开关 / 压制型预设口径)写侧车。它是"配置"不是"宿主状态",但改的是
190
- // 投递语义,按写操作门禁。
191
- 'inject-settings': { write: true },
211
+ // 投递语义,按写操作门禁。读侧(不带 `set:true`)只回当前设置,是纯读。
212
+ 'inject-settings': { write: true, writeWhen: (a) => a.set === true },
192
213
  // 模型工具表(哪些工具根本不发给模型)也写侧车,改的是每轮请求的内容 —— 同上按写门禁。
193
- 'tool-table': { write: true },
214
+ // 三条写触发:`set` 换名单;`presetSave` / `presetDelete` 只动 `presets`、**不动 `hidden`**
215
+ // —— 后两条不经过 `set`,只认 `set` 的判据会把它们当成读放行(审查 F2)。
216
+ 'tool-table': {
217
+ write: true,
218
+ writeWhen: (a) => a.set === true || a.presetSave !== undefined || a.presetDelete !== undefined,
219
+ },
194
220
  // 场景页的界面设置(进入场景前要不要弹「会改什么」的预览卡):写侧车,按写门禁。
195
221
  // **刻意不冻结**:它只是界面提示,锁着场景的人在场景页照样该能关掉提醒 —— 冻结的是五个
196
222
  // 管理域的改动,不是这个页面的显示偏好(判据见文件头 write/frozen 两段的边界)。
197
- 'scene-settings': { write: true },
223
+ 'scene-settings': { write: true, writeWhen: (a) => a.set === true },
224
+ // 斜杠命令入口的开关(侧车 `slash-settings.json`):与 `scene-settings` 同规格 —— 写侧车所以
225
+ // 按写门禁,但**刻意不冻结**(关掉一个界面入口不属于五个管理域的改动)。读侧不带 `set` 是纯读:
226
+ // 客户端 boot 时必问一次决定挂不挂 `/` 菜单,那一次不能要令牌,否则没配令牌的宿主上这个功能
227
+ // 永远出不来。
228
+ 'slash-settings': { write: true, writeWhen: (a) => a.set === true },
198
229
  // 清理 patch 备份:删磁盘文件(备份里含明文凭据副本),按写操作门禁。
199
230
  'backups-clean': { write: true },
200
231
  // ── 场景候选源(4)与内联(1)──────────────────────────────────────────────
@@ -230,6 +261,27 @@ export function frozenOps(scope) {
230
261
  }
231
262
  return out;
232
263
  }
264
+ /**
265
+ * 这次调用是不是「读侧」(免令牌、不记流水)。
266
+ *
267
+ * 判据只有一个来源:登记表的 `writeWhen`。**没带 `writeWhen` 的 op 一律返回 false**
268
+ * —— 即"标了 write 就是写",不知道就别放行。
269
+ *
270
+ * 判据抛错也返回 false(按写处理):一个会抛的谓词说明这次调用的形状没人设计过,
271
+ * 那种时候**要求令牌**才是安全的方向 —— 反过来会让"某个畸形 args 恰好免门禁"成为漏洞。
272
+ */
273
+ export function isReadCall(op, args) {
274
+ const cls = OP_REGISTRY[op];
275
+ if (!cls || cls.writeWhen === undefined)
276
+ return false;
277
+ const bag = (args && typeof args === 'object' ? args : {});
278
+ try {
279
+ return cls.writeWhen(bag) !== true;
280
+ }
281
+ catch {
282
+ return false;
283
+ }
284
+ }
233
285
  /**
234
286
  * 对账:真实 op 表里**没在登记表出现**的键。
235
287
  *
@@ -256,10 +308,20 @@ export function serviceWriteMismatch(reported) {
256
308
  extra: [...declared].filter((op) => !actual.has(op)).sort(),
257
309
  };
258
310
  }
259
- /** 自相矛盾的条目:既声明只读又声明要令牌 / 泄露明文。(登记时手滑的兜底。) */
311
+ /** 自相矛盾的条目:既声明只读又声明要令牌 / 泄露明文 / 带写判据。(登记时手滑的兜底。) */
260
312
  export function contradictoryEntries() {
261
313
  return Object.entries(OP_REGISTRY)
262
- .filter(([, cls]) => cls.readonly === true && (cls.write === true || cls.sensitive === true))
314
+ .filter(([, cls]) => cls.readonly === true && (cls.write === true || cls.sensitive === true || cls.writeWhen !== undefined))
315
+ .map(([op]) => op)
316
+ .sort();
317
+ }
318
+ /**
319
+ * 标了 `writeWhen` 却没标 `write` 的条目:写判据写了,可这条 op 根本不进写门禁
320
+ * —— 那份判据永远不会被问一次,等于没写(静默失效,正是 F2 的形状)。
321
+ */
322
+ export function writeWhenUnwritten() {
323
+ return Object.entries(OP_REGISTRY)
324
+ .filter(([, cls]) => cls.writeWhen !== undefined && cls.write !== true)
263
325
  .map(([op]) => op)
264
326
  .sort();
265
327
  }
@@ -278,6 +340,7 @@ export function auditOpRegistry(opNames, serviceWriteOps) {
278
340
  serviceWriteMissing: mismatch.missing,
279
341
  serviceWriteExtra: mismatch.extra,
280
342
  contradictory: contradictoryEntries(),
343
+ writeWhenUnwritten: writeWhenUnwritten(),
281
344
  total: names.length,
282
345
  };
283
346
  }
@@ -289,5 +352,6 @@ export function auditProblems(audit) {
289
352
  ...audit.serviceWriteMissing.map((op) => op + '(service 自报写、登记表未标)'),
290
353
  ...audit.serviceWriteExtra.map((op) => op + '(登记表标了写、service 未自报)'),
291
354
  ...audit.contradictory.map((op) => op + '(只读与写自相矛盾)'),
355
+ ...audit.writeWhenUnwritten.map((op) => op + '(有写判据却没标写)'),
292
356
  ];
293
357
  }
package/lib/ops/compat.js CHANGED
@@ -8,6 +8,7 @@
8
8
  // 守卫仍作用在组装后的整张表上,见 index.ts)。
9
9
  import { EXPECTED_MIN_HOST_VERSION, EXPECTED_PEER_RANGE, VERIFIED_HOST_VERSION, isSourceInstall, routeFor, summarize, } from '../compat/probe.js';
10
10
  import { INJECT_DOMAIN_KEYS } from '../context-inject.js';
11
+ import { MCP_TOOL_PREFIX } from '../host-names.js';
11
12
  import { assessPresetReach } from '../compat/preset-reach.js';
12
13
  import { runtimeNotes } from '../compat/runtime-notes.js';
13
14
  import { pluginLog } from '../skills/service.js';
@@ -99,7 +100,7 @@ export function buildCompatOps(deps) {
99
100
  let mcpTools;
100
101
  try {
101
102
  const schemas = (await deps.tools.schemas());
102
- mcpTools = schemas.filter((s) => String(s && s.name || '').startsWith('mcp__')).length;
103
+ mcpTools = schemas.filter((s) => String(s && s.name || '').startsWith(MCP_TOOL_PREFIX)).length;
103
104
  }
104
105
  catch {
105
106
  mcpTools = undefined;
@@ -175,7 +176,10 @@ export function buildCompatOps(deps) {
175
176
  ['mcp-tool-visibility', '工具表可见性(停用 / 关掉的工具不下发)', 'mcp'],
176
177
  ];
177
178
  for (const [noteId, label, tab] of assemblyRows) {
178
- const note = notes.get(noteId);
179
+ // 注入通道有**两类**上报:装配失败(`context-injection`)与运行期失败
180
+ // (`context-injection-runtime`,2026-09-30 P1-8:某域取数抛异常 / 整段注入抛异常)。
181
+ // 合并到同一行 —— 用户关心的是"这条通道现在好不好",不是"哪一类失败"。
182
+ const note = notes.get(noteId) ?? (noteId === 'context-injection' ? notes.get('context-injection-runtime') : undefined);
179
183
  push({
180
184
  key: noteId, label, tab, state: note === undefined ? 'ok' : 'degraded',
181
185
  detail: note === undefined ? '装配正常,无降级上报' : note.detail,
@@ -255,6 +259,17 @@ export function buildCompatOps(deps) {
255
259
  key: 'tool-table', label: '模型工具表', tab: 'compat', state: 'ok', detail: offPart,
256
260
  hidden: table.hiddenCount, total: table.totalCount, defaults: table.defaultHiddenCount,
257
261
  });
262
+ // 斜杠命令入口(`/` 菜单里的「工具」与「快捷提示词」两段,各一颗开关)。服务端只知道
263
+ // **开关开没开**:真正挂没挂上是浏览器那半的事(宿主有没有 `inputTriggers` 服务),那一句
264
+ // 由兼容页就地报,不冒充服务端探测(见 client 48-slash.js 的挂载状态)。两个布尔一起带上,
265
+ // "开着哪一段"由界面按语言说。
266
+ const slash = await deps.readSlashSettings();
267
+ push({
268
+ key: 'slash-commands', label: '斜杠命令入口', tab: 'compat',
269
+ state: slash.tools || slash.quickPrompts ? 'ok' : 'disabled',
270
+ detail: slash.tools || slash.quickPrompts ? '开关已开(在对话输入框打 / 唤出已打开的那几段)' : '两段都已关闭(本页可重新打开)',
271
+ slashTools: slash.tools, slashQuickPrompts: slash.quickPrompts,
272
+ });
258
273
  const nativeNote = notes.get('workspace.delete-native');
259
274
  push({
260
275
  key: 'native-delete', label: '宿主原生删除入口', tab: 'compat',
@@ -76,14 +76,27 @@ export function buildPromptOps(deps) {
76
76
  // 场景绑定(含恒常的 `_shared` / `global`)、`~/.dsh/AGENTS.md` 的当前内容、
77
77
  // 进场景前保存的基线(退出场景后要恢复的那一份)。拒绝时逐条说明「谁在用」,
78
78
  // 用户知道该先改哪里。引用清单由 scene-prompt-sync 与显示/注入同源算出。
79
- // 探测失败(refs() 返回 null)时放行并记 warn:删预设不动 ~/.dsh/AGENTS.md,
80
- // 最坏是少一份副本,可重建 —— 不因一次探测故障把删除堵死。
79
+ //
80
+ // 探测**失败**时改为拒绝(2026-09-30 审查 F13)。原来的取舍是「放行并记 warn:删预设不动
81
+ // ~/.dsh/AGENTS.md,最坏是少一份副本,可重建」—— 这对「副本」成立,对**场景态**不成立:
82
+ // 删掉场景绑定的那一份,场景驱动会因为找不到预设而静默失效(`driver()` 返回 null),
83
+ // 而 AGENTS.md 停在场景态,用户看到的是「没场景驱动所以什么都没做」。
84
+ // `refs()` 返回 null 只代表**三类探测里至少一类读盘失败**(全新环境没有场景时它返回的是
85
+ // **空表**而不是 null),所以拒绝不会堵住正常路径。文案必须说清是「探测失败」而不是
86
+ // 「有引用」—— 否则用户会去改一堆并不存在的引用。
81
87
  'agentsmd-remove': async (args) => {
82
88
  const id = String((args && args.id) || '');
83
89
  const refs = await deps.scenePromptSync.refs();
84
- if (refs === null)
85
- deps.warn('prompts: reference probe failed; remove allowed');
86
- const why = refs ? refs.get(id) || [] : [];
90
+ if (refs === null) {
91
+ deps.warn('prompts: reference probe failed; remove refused');
92
+ return {
93
+ ok: false,
94
+ code: 'error.agentsMd.refProbeFailed',
95
+ error: `无法确认「${id}」是否被引用,已拒绝删除:引用探测失败(场景绑定 / AGENTS.md 当前内容 / 进场景前的基线,这三类里至少有一类没读到)。请稍后重试。`,
96
+ params: { id },
97
+ };
98
+ }
99
+ const why = refs.get(id) || [];
87
100
  if (why.length) {
88
101
  const refsText = why.map(deps.promptRefReason).join(';');
89
102
  return {
@@ -0,0 +1,26 @@
1
+ // 快捷提示词域的 HTTP ops(实现见 ../prompts/quick-service.ts)。
2
+ //
3
+ // 与 `ops/prompts.ts` 的差别不是"少写了几条",而是**这个域没有宿主状态**:不写
4
+ // `~/.dsh/AGENTS.md`、不参与场景同步、没有"生效中"。所以这里既不需要 `withAgentsMdSync`
5
+ // 也不需要引用探测 —— 反过来,正因为它是纯侧车读写,才更要老老实实进 `op-registry`
6
+ // 的写门禁清单(配了令牌的宿主上,免令牌请求不该能改用户存好的文字)。
7
+ export function buildQuickPromptOps(quick) {
8
+ const text = (args, key) => args && typeof args[key] === 'string' ? String(args[key]) : undefined;
9
+ return {
10
+ // 列表连正文一起回(客户端点用时手里就得有那段字,见 quick-service.ts 头注)。
11
+ 'quickprompt-list': async () => quick.list(),
12
+ 'quickprompt-create': (args) => quick.create(String((args && args.id) || ''), text(args, 'content') ?? '', text(args, 'description')),
13
+ // 改名不牵连任何别处:快捷提示词不被场景绑定、不被引用,改完就是改完了。
14
+ 'quickprompt-update': (args) => quick.update(String((args && args.id) || ''), text(args, 'content') ?? '', args && args.nextId !== undefined ? String(args.nextId) : undefined, text(args, 'description')),
15
+ 'quickprompt-remove': (args) => quick.remove(String((args && args.id) || '')),
16
+ // 启停只改侧车的 `enabled`(在不在对话框 `/` 菜单里出现),正文一个字不动。
17
+ // `enabled` 必须是真布尔:省略它不叫"打开",而是调用方写错了,静默按某一种处理会
18
+ // 让界面与落盘各说各话。
19
+ 'quickprompt-toggle': (args) => (args && typeof args.enabled === 'boolean')
20
+ ? quick.setEnabled(String((args && args.id) || ''), args.enabled === true)
21
+ : Promise.resolve({ ok: false, error: '缺少 enabled(true / false)' }),
22
+ 'quickprompt-trash-list': () => quick.trashList(),
23
+ 'quickprompt-trash-restore': (args) => quick.trashRestore(String((args && args.id) || '')),
24
+ 'quickprompt-trash-delete': (args) => quick.trashDelete(String((args && args.id) || '')),
25
+ };
26
+ }