dsh-plugin-tool-management 0.13.0 → 0.15.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 (62) hide show
  1. package/CHANGELOG.md +560 -474
  2. package/README.md +91 -123
  3. package/README_EN.md +99 -126
  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 +391 -337
  21. package/lib/audit-log.js +157 -0
  22. package/lib/client.js +4204 -2454
  23. package/lib/compat/patch-dialect.js +3 -1
  24. package/lib/compat/preset-reach.js +43 -9
  25. package/lib/compat/probe.js +149 -7
  26. package/lib/context-inject.js +91 -35
  27. package/lib/index.js +479 -114
  28. package/lib/mcp/loader-token.js +53 -28
  29. package/lib/mcp/manager.js +149 -21
  30. package/lib/mcp/secret-guard.js +21 -0
  31. package/lib/mcp/state-section.js +13 -11
  32. package/lib/memories/archive-engine.js +70 -2
  33. package/lib/memories/archive.js +8 -1
  34. package/lib/memories/constants.js +72 -8
  35. package/lib/memories/index-io.js +7 -1
  36. package/lib/memories/projection.js +67 -31
  37. package/lib/memories/service.js +17 -2
  38. package/lib/memories/snapshot.js +113 -22
  39. package/lib/op-registry.js +26 -6
  40. package/lib/ops/candidates.js +11 -0
  41. package/lib/ops/compat.js +113 -31
  42. package/lib/ops/scene-records.js +34 -0
  43. package/lib/ops/scene-sync.js +12 -1
  44. package/lib/ops/sessions.js +24 -9
  45. package/lib/ops/snapshot.js +596 -0
  46. package/lib/prompts/service.js +25 -2
  47. package/lib/scenes/candidates.js +66 -5
  48. package/lib/sessions/bridge.js +5 -0
  49. package/lib/sessions/workspace.js +17 -5
  50. package/lib/skills/core.js +105 -3
  51. package/lib/skills/service.js +34 -2
  52. package/lib/subagents/service.js +37 -4
  53. package/lib/subagents/tools.js +11 -4
  54. package/lib/tools/deps.js +16 -1
  55. package/lib/tools/mcp.js +291 -50
  56. package/lib/tools/memory.js +131 -80
  57. package/lib/tools/prompt.js +27 -13
  58. package/lib/tools/scene.js +351 -0
  59. package/lib/tools/skills.js +93 -17
  60. package/lib/tools/subagent.js +56 -45
  61. package/lib/tools/table.js +208 -15
  62. package/package.json +24 -20
@@ -6,7 +6,7 @@
6
6
  // 解析失败 = DSH **下次启动直接起不来**(`CHANGELOG.md` 里记过这次事故)。三条写入路径
7
7
  // (`profiles/<名>/cordis.patch.yml`、`~/.dsh/cordis.patch.yml`、bundle 的 `cordis.patch.yml`)
8
8
  // 走的是同一份方言(前者 `loadOverlayPatches`、后者 `loadOptionalPatches` → `parsePatchList`;
9
- // `userPatchesSchema === entryListSchema`,实测于 dsh-app-boot 0.1.5-rc.2)。
9
+ // `userPatchesSchema === entryListSchema`,实测于 dsh-app-boot 0.1.5-rc.2,0.1.7-rc.2 复核未变)。
10
10
  //
11
11
  // 官方**没有**导出这份 schema(导出清单见同文件 :1575,无 `entryListSchema`),所以只能复刻。
12
12
  // 复刻**必然会过期**(官方加方言 / 换 js-yaml 大版本),因此判定策略是**非对称**的:
@@ -135,6 +135,8 @@ function record(report) {
135
135
  fallback: 'inform-only',
136
136
  detail: (report.kind === 'no-dep' ? '校验依赖不可用' : '复刻可能已过期')
137
137
  + `:${report.detail}(写入照常进行,仅少一道「把启动配置写坏」的拦截)`,
138
+ detailKey: report.kind === 'no-dep' ? 'patch-write-guard.no-dep' : 'patch-write-guard.stale',
139
+ params: { detail: report.detail },
138
140
  });
139
141
  pendingWarnings.push(report.kind === 'no-dep'
140
142
  ? '补丁写入未做解析校验(依赖不可用):' + report.detail
@@ -47,7 +47,7 @@
47
47
  * nothing about the servers behind them — no names, no tool counts, no
48
48
  * enablement, and none of the user's notes.
49
49
  *
50
- * Reading never mounts. `list()`/`read(id)` are roster reads, so building the
50
+ * Reading never mounts. `list()`/`read(id)`/`readDocument(id)` are roster reads, so building the
51
51
  * matrix cannot activate a preset early — the same guarantee
52
52
  * `compositionInventory()` gives the plugin-listing surfaces.
53
53
  *
@@ -57,6 +57,7 @@
57
57
  * point at the switch that overrides it.
58
58
  */
59
59
  import { MCP_CLIENT_MODULE } from '../host-names.js';
60
+ import { noteRuntime } from './runtime-notes.js';
60
61
  /** Whether a preset declares "no extra text": persona `complete: true` or runtime context off. */
61
62
  export function isSuppressingPreset(facts) {
62
63
  return facts.personaComplete === true || facts.includeRuntimeContext === false;
@@ -225,7 +226,7 @@ export function deriveReach(facts, ctx) {
225
226
  const forceUnderSuppressing = ctx?.inject?.underSuppressingPresets === true;
226
227
  const domainOff = (key) => ctx?.inject?.domains?.[key] === false;
227
228
  /**
228
- * 本插件自己的文本(场景和记忆 / MCP 服务器与备注 / 技能目录 / 子智能体目录 / 提示词)
229
+ * 本插件自己的文本(场景 / 记忆 / MCP 服务器与备注 / 技能目录 / 子智能体目录 / 提示词)
229
230
  * 走 `agent/pre-step` 注入消息(src/context-inject.ts)—— 不再依赖系统提示词段,所以
230
231
  * `persona complete` 压不到它。可达性只看两件事:域开关有没有关、预设压制时有没有开
231
232
  * 「仍然注入」。预设信息读不到(`personaUnknown` 且无压制信号)时按可达处理。
@@ -237,6 +238,7 @@ export function deriveReach(facts, ctx) {
237
238
  return 'suppressed';
238
239
  return 'ok';
239
240
  };
241
+ const scene = pluginText('scene');
240
242
  const memory = pluginText('memory');
241
243
  const mcp = pluginText('mcp');
242
244
  /**
@@ -275,7 +277,37 @@ export function deriveReach(facts, ctx) {
275
277
  const subagent = pluginText('subagents');
276
278
  // MCP 列答的是"服务器清单与备注到不到得了":宿主工具数不再参与 —— 清单为空时注入出去
277
279
  // 也是空段(没什么可看的),"有几台 server"由插件页面回答,不是这一列的事。
278
- return { memory, agentsMd, skillCatalog, subagent, mcp };
280
+ return { scene, memory, agentsMd, skillCatalog, subagent, mcp };
281
+ }
282
+ /**
283
+ * 读一个预设的组合文本。两代宿主同一个出口:
284
+ * `read(id)`(0.1.5 直返文本)或 `readDocument(id).content`(0.1.7 文档对象)。
285
+ * 两者都不可用时抛错,由调用方决定降级口径 —— reason 文案由此保持单一来源。
286
+ *
287
+ * 兜底路径的可见性(0.15.0):兜底成功**不留运行时上报** —— 上报一律渲染成问题行,
288
+ * 而「read 改名 readDocument 后走兜底」是 0.1.7 的正常形态,不是降级(琥珀只留给故障,
289
+ * 2026-09-28 用户反馈)。走的哪条路由探测表 `preset.roster-surface` 的健康行说明承担;
290
+ * 这里只在**真异常**(readDocument 在场但文档缺 `.content`)时留痕。同 id 覆盖,不堆积。
291
+ */
292
+ export async function readCompositionText(roster, presetId) {
293
+ if (typeof roster.read === 'function') {
294
+ return String((await roster.read(presetId)) ?? '');
295
+ }
296
+ if (typeof roster.readDocument === 'function') {
297
+ const doc = (await roster.readDocument(presetId));
298
+ if (doc === null || typeof doc !== 'object' || doc.content === undefined) {
299
+ // 文档形状的第二次漂移:readDocument 在、`.content` 不在 —— 当年 read 改名的翻版。
300
+ noteRuntime({
301
+ id: 'preset.roster-read-route',
302
+ label: '预设名册读取',
303
+ kind: 'read',
304
+ fallback: 'inform-only',
305
+ detail: `readDocument(${presetId}) 返回的文档没有 content 字段:组合文本按空处理,注入边界将显示「无法判断」。宿主的名册文档契约可能又变了。`,
306
+ });
307
+ }
308
+ return String(doc?.content ?? '');
309
+ }
310
+ throw new Error('预设名单未提供 read()/readDocument():无法读取组合文件');
279
311
  }
280
312
  /** Narrow the roster off a cordis context without throwing. */
281
313
  export function presetRosterOf(ctx) {
@@ -300,7 +332,7 @@ async function composeRow(roster, meta, ctx) {
300
332
  isDefault,
301
333
  ...(broken === undefined ? {} : { broken }),
302
334
  };
303
- if (typeof roster.read !== 'function') {
335
+ if (typeof roster.read !== 'function' && typeof roster.readDocument !== 'function') {
304
336
  return {
305
337
  ...base,
306
338
  personaComplete: 'unknown',
@@ -308,17 +340,18 @@ async function composeRow(roster, meta, ctx) {
308
340
  agentInstructions: 'absent',
309
341
  toolSkill: 'absent',
310
342
  suppressing: false,
343
+ scene: 'unknown',
311
344
  memory: 'unknown',
312
345
  agentsMd: 'unknown',
313
346
  skillCatalog: 'unknown',
314
347
  subagent: 'unknown',
315
348
  mcp: 'unknown',
316
- reason: '预设名单未提供 read():无法读取组合文件',
349
+ reason: '预设名单未提供 read()/readDocument():无法读取组合文件',
317
350
  };
318
351
  }
319
352
  let text;
320
353
  try {
321
- text = String((await roster.read(presetId)) ?? '');
354
+ text = await readCompositionText(roster, presetId);
322
355
  }
323
356
  catch (error) {
324
357
  return {
@@ -328,6 +361,7 @@ async function composeRow(roster, meta, ctx) {
328
361
  agentInstructions: 'absent',
329
362
  toolSkill: 'absent',
330
363
  suppressing: false,
364
+ scene: 'unknown',
331
365
  memory: 'unknown',
332
366
  agentsMd: 'unknown',
333
367
  skillCatalog: 'unknown',
@@ -435,7 +469,7 @@ export function reachNoticeFor(presetId, facts, inject) {
435
469
  const domainOff = (key) => inject?.domains?.[key] === false;
436
470
  if (isSuppressingPreset(facts) && inject?.underSuppressingPresets !== true) {
437
471
  parts.push(`预设「${presetId}」声明只要它自己的文本(persona complete / 关闭运行时上下文):` +
438
- '本插件注入的 —— 场景和记忆、MCP、技能、子智能体、提示词 —— 默认不注入,' +
472
+ '本插件注入的 —— 场景、记忆、MCP、技能、子智能体、提示词 —— 默认不注入,' +
439
473
  '需要时用对应的 list / read 工具按需读取;不要假设你已经看到它们。' +
440
474
  '(想让它在这类预设下也注入:插件的「兼容」页 → 注入。)');
441
475
  }
@@ -467,10 +501,10 @@ export async function reachNoticeForAgent(roster, agentCtx, inject) {
467
501
  catch {
468
502
  return '';
469
503
  }
470
- if (presetId === '' || typeof roster.read !== 'function')
504
+ if (presetId === '' || (typeof roster.read !== 'function' && typeof roster.readDocument !== 'function'))
471
505
  return '';
472
506
  try {
473
- return reachNoticeFor(presetId, readCompositionFacts(String((await roster.read(presetId)) ?? '')), inject);
507
+ return reachNoticeFor(presetId, readCompositionFacts(await readCompositionText(roster, presetId)), inject);
474
508
  }
475
509
  catch {
476
510
  return '';
@@ -30,7 +30,7 @@
30
30
  * leave the rest working.
31
31
  */
32
32
  import { createRequire } from 'node:module';
33
- import { existsSync, readFileSync, realpathSync } from 'node:fs';
33
+ import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from 'node:fs';
34
34
  import { dirname, join } from 'node:path';
35
35
  /** Packages whose physical module identity matters to this plugin. */
36
36
  export const IDENTITY_PACKAGES = [
@@ -41,10 +41,15 @@ export const IDENTITY_PACKAGES = [
41
41
  '@deepseek-ai/dsh-storage-domain',
42
42
  '@deepseek-ai/dsh-spill-local',
43
43
  ];
44
- /** Peers this plugin was written and verified against(只声明最低版本,无上界)。 */
45
- export const EXPECTED_PEER_RANGE = '>=0.1.5-rc.2';
44
+ /**
45
+ * peer range 的下界与已验证版本。官方全程走 prerelease 渠道(x.y.z-rc.N),semver 的
46
+ * 预发布规则(prerelease 版本只匹配同 [major,minor,patch] 元组的比较器)意味着**每一代
47
+ * rc 都要显式列进范围**:`>=0.1.5-rc.2` 匹配不了 `0.1.7-rc.2`(实测),所以范围是逐代
48
+ * 枚举的并集。上界依旧不设:宿主跨代升级由运行时能力探测兜底(见下)。
49
+ */
50
+ export const EXPECTED_PEER_RANGE = '^0.1.5-rc.2 || ^0.1.7-rc.2';
46
51
  /** The release this plugin's adapters were last verified against. */
47
- export const VERIFIED_HOST_VERSION = '0.1.5-rc.2';
52
+ export const VERIFIED_HOST_VERSION = '0.1.7-rc.2';
48
53
  /**
49
54
  * peer range 的下界 —— 界面展示用。
50
55
  *
@@ -54,7 +59,7 @@ export const VERIFIED_HOST_VERSION = '0.1.5-rc.2';
54
59
  * 而不是在安装期拒绝整包。展示时只应把「最低要求版本」当事实。
55
60
  * 从 EXPECTED_PEER_RANGE 派生,避免两处手写漂移。
56
61
  */
57
- export const EXPECTED_MIN_HOST_VERSION = EXPECTED_PEER_RANGE.match(/^>=\s*([^\s]+)/)?.[1] ?? VERIFIED_HOST_VERSION;
62
+ export const EXPECTED_MIN_HOST_VERSION = EXPECTED_PEER_RANGE.match(/(\d+\.\d+\.\d+(?:-rc\.\d+)?)/)?.[1] ?? VERIFIED_HOST_VERSION;
58
63
  const isFn = (value) => typeof value === 'function';
59
64
  /** Resolve a package the way this plugin resolves it, without throwing. */
60
65
  function safeResolve(specifier) {
@@ -151,10 +156,17 @@ const PROJECTION_CLASS = { pkg: '@deepseek-ai/dsh-session-projection-cache', tar
151
156
  // `announce` live. Without this entry the doctor could not see the two
152
157
  // capabilities the delete route depends on.
153
158
  const SESSIONS_CLASS = { pkg: '@deepseek-ai/dsh-session', target: 'SessionStore' };
159
+ // 0.15.0 新增的两块契约面 —— 恰是 0.1.7 升级咬人的两处(prepareDocument 返回语义变了、
160
+ // read 改名 readDocument),此前不在表里,故障只能靠用户实测发现。类名按官方安装源码核对
161
+ // (dsh-settings 导出 SettingsForms;dsh-agent-preset-registry 导出 AgentPresetRegistry)。
162
+ const SETTINGS_CLASS = { pkg: '@deepseek-ai/dsh-settings', target: 'SettingsForms' };
163
+ const ROSTER_CLASS = { pkg: '@deepseek-ai/dsh-agent-preset-registry', target: 'AgentPresetRegistry' };
154
164
  const OWNER_CLASSES = {
155
165
  workspace: WORKSPACE_CLASS,
156
166
  projectionCache: PROJECTION_CLASS,
157
167
  sessions: SESSIONS_CLASS,
168
+ settings: SETTINGS_CLASS,
169
+ presetRoster: ROSTER_CLASS,
158
170
  };
159
171
  /** The named export's prototype, or undefined when the package is absent/trimmed. */
160
172
  function classPrototypeOf(ref) {
@@ -221,6 +233,14 @@ function referencePrototypes() {
221
233
  const sessions = classPrototypeOf(SESSIONS_CLASS);
222
234
  if (sessions !== undefined)
223
235
  out.sessions = sessions;
236
+ // 两块新契约面的参考副本:包不在 devDeps/宿主锚点里时优雅缺省(textMatch 记 undefined),
237
+ // 存在性探测不依赖它们。
238
+ const settings = classPrototypeOf(SETTINGS_CLASS);
239
+ if (settings !== undefined)
240
+ out.settings = settings;
241
+ const roster = classPrototypeOf(ROSTER_CLASS);
242
+ if (roster !== undefined)
243
+ out.roster = roster;
224
244
  return out;
225
245
  }
226
246
  /**
@@ -455,6 +475,67 @@ const CAPABILITY_SPECS = [
455
475
  return undefined;
456
476
  },
457
477
  },
478
+ // ---- settings document path(0.1.7 的两处适配面之一)----------------------
479
+ // `prepareDocument()` 在 0.1.7 把返回值从「主目录下的设置文档路径」改成「profile 补丁
480
+ // 路径」(`configEditor.documentPath`)—— 方法一直在、语义变了,方法存在性探测抓不住
481
+ // (0.15.0 的 MCP 页事故就是这么静默发生的)。这一条能拦的是「方法消失 / 签名漂移到
482
+ // 同步抛 TypeError」;**返回值形状**是异步结果,同步探针看不到,那一半由 ensurePaths
483
+ // 的 await 后自检负责(src/index.ts:认 profile 形状上溯 + 主目录不变量检查,异常走
484
+ // noteRuntime)。两半合起来才是这个契约的完整探测。
485
+ {
486
+ id: 'settings.document-path',
487
+ label: '设置文档路径(主目录推导源)',
488
+ kind: 'read',
489
+ owner: 'settings',
490
+ fallback: 'refuse-operation',
491
+ methods: ['prepareDocument'],
492
+ // 零副作用真调:官方实现就是 `Promise.resolve(this.documentPath)`,纯读。拿不到
493
+ // 异步结果没关系 —— 同步 TypeError(签名漂移)才是这一层要拦的。
494
+ probe: (t) => callableWithSentinel(t, 'prepareDocument'),
495
+ // 健康行也常显观测路径(官方 SettingsForms 有同步的 `documentPath` getter,
496
+ // prepareDocument 就是它的 Promise 包装):语义再变,页面上这行字一眼见底。
497
+ describe: (t) => {
498
+ try {
499
+ const p = t.documentPath;
500
+ return typeof p === 'string' && p !== '' ? `观测路径:${p}` : undefined;
501
+ }
502
+ catch {
503
+ return undefined;
504
+ }
505
+ },
506
+ },
507
+ // ---- agent preset roster(0.1.7 的两处适配面之二)-------------------------
508
+ // 0.1.7 把 `read(id)`(直返组合文本)改名成 `readDocument(id)`(返回文档对象,组合
509
+ // YAML 在 `.content`)。读取口是「read 优先、readDocument 兜底」双入口
510
+ // (preset-reach.ts 的 readCompositionText),两个名字**任一在场即可** —— 这正是
511
+ // methods 列表表达不了的 either-or,用 probe 写。list / composedPreset 缺一个,
512
+ // 注入边界矩阵与边界提示就瞎一半,同为必需。
513
+ {
514
+ id: 'preset.roster-surface',
515
+ label: '预设名册读取面',
516
+ kind: 'read',
517
+ owner: 'presetRoster',
518
+ fallback: 'inform-only',
519
+ probe: (t) => {
520
+ const o = t;
521
+ const hasRead = isFn(o.read);
522
+ const hasDoc = isFn(o.readDocument);
523
+ if (!hasRead && !hasDoc)
524
+ return 'read 与 readDocument 都缺失:组合文本读不到,注入边界矩阵与极简兜底注入失明';
525
+ const missing = ['list', 'composedPreset'].filter((name) => !isFn(o[name]));
526
+ if (missing.length > 0)
527
+ return `名册缺少 ${missing.join(', ')}:注入边界矩阵不完整`;
528
+ return undefined;
529
+ },
530
+ // 健康行附注实际走的读取路。0.1.7 起 `read` 改名 `readDocument`,兜底成功是**正常形态**
531
+ // 而非降级 —— 这句话只出现在绿色行上(运行时上报会渲染成问题行,那里不放)。
532
+ describe: (t) => {
533
+ const o = t;
534
+ if (!isFn(o.read) && isFn(o.readDocument))
535
+ return '读取走 readDocument(宿主 0.1.7 起的形态)';
536
+ return undefined;
537
+ },
538
+ },
458
539
  ];
459
540
  /**
460
541
  * The capability sets each operation depends on, grouped by how the plugin
@@ -564,10 +645,21 @@ function inspectCapability(spec, target, reference) {
564
645
  textMatch,
565
646
  };
566
647
  }
648
+ // describe 是健康行也带的观测值(如 settings 的 documentPath);只挂 ok 路径,
649
+ // 摸宿主属性一律 try/catch —— 观测失败就少一句后缀,不把好端端的能力报成问题。
650
+ let described = '';
651
+ if (spec.describe !== undefined) {
652
+ try {
653
+ const extra = spec.describe(target);
654
+ if (typeof extra === 'string' && extra !== '')
655
+ described = `;${extra}`;
656
+ }
657
+ catch { /* 观测值拿不到就算了 */ }
658
+ }
567
659
  return {
568
660
  ...base,
569
661
  state: 'ok',
570
- detail: textMatch === false ? '成员齐备(实现文本与本插件适配的版本不同,按能力使用)' : '成员齐备',
662
+ detail: (textMatch === false ? '成员齐备(实现文本与本插件适配的版本不同,按能力使用)' : '成员齐备') + described,
571
663
  missing: [],
572
664
  ...(textMatch === undefined ? {} : { textMatch }),
573
665
  };
@@ -647,12 +739,18 @@ export function assessHost(ctx) {
647
739
  const registry = unwrap(get('workspaceRegistry'));
648
740
  const cache = unwrap(get('sessionProjectionCache'));
649
741
  const sessions = unwrap(ctx.sessions !== undefined ? ctx.sessions : get('sessions'));
742
+ // settings 走 ctx 属性优先(插件 inject 清单里的正式服务),roster 走服务名查找
743
+ // ('agentPresets' 与 preset-reach.ts 的 presetRosterOf 同名 —— 注入矩阵实际用的就是它)。
744
+ const settings = unwrap(ctx.settings !== undefined ? ctx.settings : get('settings'));
745
+ const roster = unwrap(get('agentPresets'));
650
746
  const references = referencePrototypes();
651
747
  const targets = {
652
748
  workspace: { target: registry, reference: references.workspace },
653
749
  projectionCache: { target: cache, reference: references.cache },
654
750
  sessions: { target: sessions, reference: references.sessions },
655
751
  persistence: { target: unwrap(get('sessionPersistence')), reference: undefined },
752
+ settings: { target: settings, reference: references.settings },
753
+ presetRoster: { target: roster, reference: references.roster },
656
754
  };
657
755
  const findings = CAPABILITY_SPECS.map((spec) => {
658
756
  const slot = targets[spec.owner];
@@ -736,7 +834,7 @@ export function assessHost(ctx) {
736
834
  */
737
835
  function hostPackageRoot() {
738
836
  // 与 doctor 的 `findHost` **同一套策略**(先看 `$DSH_HOME/profiles/node_modules/@deepseek-ai`,
739
- // 再从插件自身位置逐级上溯,并确认那一层里真有 `dsh/package.json`)。此前运行时只按"插件
837
+ // 再从插件自身位置逐级上溯,最后扫 npx 缓存,并确认那一层里真有 `dsh/package.json`)。此前运行时只按"插件
740
838
  // 自己解析到的包"上溯,dev 布局下会把仓库里的副本当成宿主锚点、比出假的 `true` —— 于是
741
839
  // 界面说 ok、doctor 说 SEPARATE COPY(V8)。两处口径分裂本身就是缺陷。
742
840
  const home = process.env.DSH_HOME || join(process.env.USERPROFILE || process.env.HOME || '', '.dsh');
@@ -759,12 +857,56 @@ function hostPackageRoot() {
759
857
  dir = parent;
760
858
  }
761
859
  }
860
+ // 0.1.7 起宿主不再维护 `profiles/node_modules` 那棵 junction 树(link-backend 已移除),
861
+ // `dsh web` 经 npx 跑在 `<npm 缓存>/_npx/<hash>/node_modules` 里 —— 缓存扫描是 dev 场景
862
+ // (上溯只找到 checkout 自己的副本)下最后的宿主发现手段。多命中时取最新(npx 升级会
863
+ // 换 hash 目录、删旧目录)。
864
+ candidates.push(...npxCacheHostRoots());
762
865
  for (const candidate of candidates) {
763
866
  if (existsSync(join(candidate, 'dsh', 'package.json')))
764
867
  return candidate;
765
868
  }
766
869
  return null;
767
870
  }
871
+ /**
872
+ * npx 缓存里的宿主 `@deepseek-ai` 目录,按目录 mtime 新到旧排。npm 缓存位置依次看
873
+ * `npm_config_cache`、`~/.npmrc` 的 `cache=`、`%LOCALAPPDATA%\npm-cache` —— probe 全程同步,
874
+ * 不起子进程,读不到就当没有这批候选。
875
+ */
876
+ function npxCacheHostRoots() {
877
+ const home = process.env.USERPROFILE || process.env.HOME || '';
878
+ let cache = process.env.npm_config_cache || '';
879
+ if (!cache && home) {
880
+ try {
881
+ const rc = readFileSync(join(home, '.npmrc'), 'utf8');
882
+ cache = rc.match(/^\s*cache\s*=\s*(.+?)\s*$/m)?.[1] ?? '';
883
+ }
884
+ catch { /* no .npmrc — defaults below */ }
885
+ }
886
+ if (!cache && process.env.LOCALAPPDATA)
887
+ cache = join(process.env.LOCALAPPDATA, 'npm-cache');
888
+ if (!cache)
889
+ return [];
890
+ const roots = [];
891
+ const npxRoot = join(cache, '_npx');
892
+ let entries = [];
893
+ try {
894
+ entries = readdirSync(npxRoot);
895
+ }
896
+ catch {
897
+ return [];
898
+ }
899
+ for (const entry of entries) {
900
+ const dir = join(npxRoot, entry, 'node_modules', '@deepseek-ai');
901
+ if (!existsSync(join(dir, 'dsh', 'package.json')))
902
+ continue;
903
+ try {
904
+ roots.push({ dir, mtime: statSync(dir).mtimeMs });
905
+ }
906
+ catch { /* raced — skip */ }
907
+ }
908
+ return roots.sort((left, right) => right.mtime - left.mtime).map((root) => root.dir);
909
+ }
768
910
  /** Human-readable summary line for logs and the settings page header. */
769
911
  export function summarize(assessment) {
770
912
  const total = assessment.findings.length;
@@ -15,8 +15,9 @@
15
15
  // 形态(2026-09-16 第二版,用户裁定):**每个域一条自己的消息**,不再是一条大快照 ——
16
16
  // 与官方 skill-catalog 同款:各自的来源 kind(轨迹里各自一行、各显各的名字)、各自的
17
17
  // form、各自的去重。好处是"只改了一个域就只重发那一条";代价是进场景这类多域同时变的
18
- // 时刻会一次发几条(每条带一句自己的引导语)。五个域与轨迹行名:
19
- // memory → scene-memory-manager-catalog(场景和记忆)
18
+ // 时刻会一次发几条(每条带一句自己的引导语)。各域与轨迹行名(清单见下 INJECT_DOMAIN_KEYS):
19
+ // scene → scene-manager-catalog(场景:启用的场景 + 场景说明,约定)
20
+ // memory → memory-manager-catalog(记忆:各场景下的条目,记录)
20
21
  // mcp → mcp-manager-catalog
21
22
  // skills → skill-manager-catalog(不叫 skill-catalog:那是官方那条行的名字)
22
23
  // subagents → subagent-manager-catalog
@@ -34,7 +35,7 @@
34
35
  // 免得旧目录继续被当成现状。
35
36
  //
36
37
  // 2026-09-17 第二版加的两件事:
37
- // - **按深度抑制人设目录**:宿主平面注册意味着子代理派生的会话也收到五域。而人设目录
38
+ // - **按深度抑制人设目录**:宿主平面注册意味着子代理派生的会话也收到全部域。而人设目录
38
39
  // 要不要出现在某个深度的会话里,由人设的 `catalogDepth`(默认 1 = 只在顶层注入)决定;
39
40
  // 判定放在域声明的 `applicableTo` 上,通道只负责问一句 —— "哪个域对哪类会话不成立"
40
41
  // 是域的语义,不是通道的机制。
@@ -74,17 +75,23 @@ import { SessionSeq } from '@deepseek-ai/dsh-session';
74
75
  import { clearRuntimeNote, noteRuntime } from './compat/runtime-notes.js';
75
76
  /**
76
77
  * 权威域顺序(界面勾选、注入消息先后都按它)。
77
- * 场景和记忆排第一:它是"当前模式"的框架,先给框架再给内容。
78
+ * 场景排第一、记忆紧跟:场景是"当前模式"的**框架**(有哪些场景、各自是什么约定),
79
+ * 记忆是各场景下的**内容** —— 先给框架再给内容。
78
80
  */
79
- export const INJECT_DOMAIN_KEYS = ['memory', 'mcp', 'skills', 'subagents', 'prompt'];
81
+ export const INJECT_DOMAIN_KEYS = ['scene', 'memory', 'mcp', 'skills', 'subagents', 'prompt'];
80
82
  /**
81
83
  * 域 → 消息来源 kind(轨迹行标签,也是去重时的身份)。
82
84
  *
83
85
  * 命名对齐官方 `*-catalog` 风格与本插件的工具族(`*_manager_*`);改名等于换身份,
84
86
  * 旧消息会被当成"不在上下文里"而重发一次,所以这几个字符串是稳定契约。
87
+ *
88
+ * ⚠️ 0.14.0 把 `memory` 的 kind 从 `scene-memory-manager-catalog` 改成
89
+ * `memory-manager-catalog`,并把场景拆成独立的 `scene` 域 —— 升级后每个会话**会重发一次**
90
+ * 这两段(旧消息认不出来)。一次性代价,换来的是两段能各自开关、各自去重。
85
91
  */
86
92
  export const INJECT_KIND_OF = {
87
- memory: 'scene-memory-manager-catalog',
93
+ scene: 'scene-manager-catalog',
94
+ memory: 'memory-manager-catalog',
88
95
  mcp: 'mcp-manager-catalog',
89
96
  skills: 'skill-manager-catalog',
90
97
  subagents: 'subagent-manager-catalog',
@@ -98,22 +105,30 @@ export const INJECT_KIND_OF = {
98
105
  * 「用了」长得一模一样。有了映射,就能把「投递 N 次 / 调用 M 次」并排摆出来,
99
106
  * 措辞与形式的调整才有依据(否则改文案就是猜)。
100
107
  *
101
- * 前缀而不是精确名:`memory_manager_*` 有 list/read/write/delete 等,任何一个都
102
- * 说明模型确实在读这一域。改工具名等于换身份,这五个字符串是稳定契约。
108
+ * 前缀而不是精确名:`memory_manager_*` 有 list/read/switch/save 等,任何一个
109
+ * 都说明模型确实在读这一域。改工具名等于换身份,这几个字符串是稳定契约。
110
+ *
111
+ * 为什么值是**数组**:工具族与注入域不是一一对应的概念 —— 域是"给模型看的信息分组"
112
+ * (界面上的勾选),族是"操作哪类对象"。0.14.0 里场景与记忆就一度同域两族
113
+ * (`memory_manager_*` + `scene_manager_*`),拆开后现在每个域各一个前缀。留着数组是
114
+ * 为了让"一个域挂多个族"在类型上成立,将来加族不必改类型。顺序不影响判定。
103
115
  */
104
116
  export const DOMAIN_TOOL_PREFIX = {
105
- memory: 'memory_manager_',
106
- mcp: 'mcp_manager_',
107
- skills: 'skill_manager_',
108
- subagents: 'subagent_manager_',
109
- prompt: 'prompt_manager_',
117
+ scene: ['scene_manager_'],
118
+ memory: ['memory_manager_'],
119
+ mcp: ['mcp_manager_'],
120
+ skills: ['skill_manager_'],
121
+ subagents: ['subagent_manager_'],
122
+ prompt: ['prompt_manager_'],
110
123
  };
111
124
  /** 工具名 → 域(`undefined` = 不是本插件的域工具)。纯函数,便于单独推理。 */
112
125
  export function domainOfTool(toolName) {
113
126
  const name = String(toolName ?? '');
114
127
  for (const key of INJECT_DOMAIN_KEYS) {
115
- if (name.startsWith(DOMAIN_TOOL_PREFIX[key]))
116
- return key;
128
+ for (const prefix of DOMAIN_TOOL_PREFIX[key]) {
129
+ if (name.startsWith(prefix))
130
+ return key;
131
+ }
117
132
  }
118
133
  return undefined;
119
134
  }
@@ -170,7 +185,7 @@ const CARRIER_FACT_OF = {
170
185
  };
171
186
  export const DEFAULT_INJECT_SETTINGS = {
172
187
  underSuppressingPresets: false,
173
- domains: { memory: true, mcp: true, skills: true, subagents: true, prompt: true },
188
+ domains: { scene: true, memory: true, mcp: true, skills: true, subagents: true, prompt: true },
174
189
  };
175
190
  /** 把任意输入夹成合法设置(缺项/类型不对一律退回默认;默认从不阻止注入)。 */
176
191
  export function normalizeInjectSettings(raw) {
@@ -276,31 +291,59 @@ export function explainInjections(domains, settings, facts, agent) {
276
291
  }
277
292
  return reasons;
278
293
  }
294
+ /**
295
+ * 权威声明的统一句(用户 2026-09-23 看到实际注入后要求精简)。
296
+ *
297
+ * 此前五个域各写一份 —— `本份场景取代…同类场景` / `本份记忆取代…同类记忆` /
298
+ * `本份状态…` / `本份目录…` —— 说的是**同一条规则**却用了四种措辞,模型读到四条不同的句子
299
+ * 还得自己判断它们是不是一条。统一成一句:被取代的是"同类内容",与域无关。
300
+ *
301
+ * 为什么不能并进 `cue`(那能省下整整一行 ≈16 tok/段):用户 2026-09-18 定过"权威声明单独
302
+ * 成句" —— 它和动作句是两种东西(一句说"什么时候用它",一句说"以哪份为准"),合并后容易
303
+ * 被一眼带过。所以这里的收益只有约 6 tok/轮,**主要收益是消除四种措辞**,不是省字节。
304
+ */
305
+ const SUPERSEDE_NOTE = '本份取代本次会话中更早注入的同类内容。';
279
306
  const DOMAIN_FRAME = {
307
+ scene: {
308
+ // 场景段只有标题 + 正文 + 权威声明(**没有 cue 是六个域的共同决定**,理由见 `DomainFrame`)。
309
+ //
310
+ // 这一段的演进值得记下来,因为每一次都是被实际注入推着改的:
311
+ // ① 最早它把「场景说明」当**约定**授权("一律照办,覆盖你的默认做法")—— 而它的实例
312
+ // 是「写代码」这种**标签**,让模型"照办一个标签",这正是它读不懂这段的原因;
313
+ // ② 去掉授权后换成一句定义("场景是用户给这台机器配的工作模式")—— 定义不是动作,
314
+ // 模型读完还是不知道该拿它做什么;
315
+ // ③ 再加一句因果("下面四段都已按它筛过")—— 用户 2026-09-23 看到渲染效果后给了
316
+ // **原则**:「上下文注入就是当前的情况,目的是让 agent 知道现在的情况,不需要它
317
+ // 知道没用的信息,反推更是浪费 token」。于是三句全删。
318
+ //
319
+ // 现在这一段只回答一个问题:**当前处在哪个场景、它是什么**(`**「代码」—— 写代码**`)。
320
+ // 它还比别的段少一层:因果句也删了 —— 它解释的是"另外四段是怎么产生的"(机制),
321
+ // 不是当前情况本身,而且"清单里没有 ≠ 本机没有"这层反推被用户明确判为浪费。
322
+ title: '本机当前的场景',
323
+ supersede: SUPERSEDE_NOTE,
324
+ },
280
325
  memory: {
281
- title: '本机当前的场景和记忆',
282
- cue: '在回答涉及本机的事之前,先核对这里。',
283
- supersede: '本份记忆取代本次会话中更早注入的同类记忆。',
326
+ title: '本机当前的记忆',
327
+ supersede: SUPERSEDE_NOTE,
284
328
  },
285
329
  mcp: {
286
330
  title: '本机 MCP 服务器的当前状态',
287
- cue: '要用某个 MCP 工具前,先在这里确认这台服务器在不在、开没开。',
288
- how: '工具名是 `mcp__<服务器>__<工具>`;带「用户提示:」的行是用户写给这台服务器的决策提示,选服务器之前先看一眼。',
289
- supersede: '本份状态取代本次会话中更早注入的同类状态。',
331
+ // how 只剩"怎么用备注"这半句:前半个分句「工具名是 `mcp__<服务器>__<工具>`」删掉了
332
+ // —— 模型自己的工具表里就是这个命名(`mcp__context7__xxx`),告诉它格式是零信息量。
333
+ how: '带「用户提示:」的行是用户写给这台服务器的决策提示,选服务器之前先看一眼。',
334
+ supersede: SUPERSEDE_NOTE,
290
335
  },
291
336
  skills: {
292
337
  title: '本机技能目录',
293
- cue: '需要某项能力时,先在这里找。',
294
338
  // 两句都只在预设没挂官方 `skill` 工具时出现,差别只在点名不点名那个取正文的工具
295
339
  // (工具被用户在兼容页关掉时不点名 —— 点名一个模型手里没有的工具只会让它去猜名字)。
296
340
  how: (toolHidden) => toolHidden('skill_manager_read')
297
341
  ? '本预设没有官方 `skill` 工具:目录只有摘要,读完再照做。'
298
342
  : '本预设没有官方 `skill` 工具:要正文用 `skill_manager_read`(按名字直接给正文与路径);目录只有摘要,读完再照做。',
299
- supersede: '本份目录取代本次会话中更早注入的同类目录;只列当前可调用的技能。',
343
+ supersede: `${SUPERSEDE_NOTE.slice(0, -1)};只列当前可调用的技能。`,
300
344
  },
301
345
  subagents: {
302
346
  title: '可委派的子智能体',
303
- cue: '在决定自己做还是委派之前,先在这里选人设。',
304
347
  // 分界规则(2026-09-17 方案 C,本机实测 session-ee722e23 逼出来的):官方那两个
305
348
  // 委派工具(`subagent` / `subagent_fork`)不带人设,而此前没有任何一句话说明何时该
306
349
  // 用谁 —— 模型在"审查刚读过的 README"时选了 `subagent_fork`(fork 能继承已读内容、
@@ -310,19 +353,17 @@ const DOMAIN_FRAME = {
310
353
  how: (toolHidden) => toolHidden('subagent_manager_run')
311
354
  ? '本会话没有带人设的委派工具;官方 `subagent` / `subagent_fork` 不带人设,只在没有人设贴合、或要后台跑时用。'
312
355
  : '贴合人设的任务一律用 `subagent_manager_run`(要它看到本次会话就开 `inherit`);官方 `subagent` / `subagent_fork` 不带人设,只在没有人设贴合、或要后台跑时用。',
313
- supersede: '本份目录取代本次会话中更早注入的同类目录。',
356
+ supersede: SUPERSEDE_NOTE,
314
357
  },
315
358
  prompt: {
316
359
  title: '本机提示词',
317
- cue: '动手之前先按它对齐,与它冲突的默认做法一律让位。',
318
- supersede: '本份提示词取代本次会话中更早注入的同类提示词。',
360
+ supersede: SUPERSEDE_NOTE,
319
361
  },
320
362
  };
321
363
  /** 域声明里没登记的 key(理论上到不了这里):给一个不出错的通用框架。 */
322
364
  const fallbackFrame = (label) => ({
323
365
  title: `本机的${label}`,
324
- cue: `需要这台机器的${label}时,先核对这里。`,
325
- supersede: '本份内容取代本次会话中更早注入的同类内容。',
366
+ supersede: SUPERSEDE_NOTE,
326
367
  });
327
368
  const domainFrame = (key, label) => DOMAIN_FRAME[key] ?? fallbackFrame(label);
328
369
  /**
@@ -347,7 +388,7 @@ export function escapeFrameBody(body) {
347
388
  }
348
389
  /**
349
390
  * 一条注入消息的正文(纯函数):`<system-reminder>` 里 = 框架(标题 + 动作 + 补充 + 权威
350
- * 声明)+ 空行 + 域正文。**五个域一律带框架**,没有例外。
391
+ * 声明)+ 空行 + 域正文。**所有域一律带框架**,没有例外。
351
392
  *
352
393
  * 历史(每一版都是被具体毛病逼出来的,别把结论当套话读):
353
394
  * - 第一版(2026-09-17 前):五域共用「以下是本机插件的X(取代…)。」——「本机插件」是实现
@@ -367,20 +408,33 @@ export function escapeFrameBody(body) {
367
408
  */
368
409
  export function renderDomainText(section, toolHidden = () => false) {
369
410
  const frame = domainFrame(section.key, section.label);
370
- const lines = [FRAME_OPEN, `## ${frame.title}`, `**${frame.cue}**`];
411
+ // 层级(2026-09-23 用户看到实际注入后指出「记忆内的场景怎么都是 ## 标题」):**`#` 一级给板块**
412
+ // (场景 / 记忆 / MCP / 技能 / 子智能体 / 提示词),域正文里的 `##` 才是它的下一层
413
+ // (记忆段的 `## 场景:X`、超预算时的 `## 未注入的参考信息`)。此前标题也是 `##`,两者平级,
414
+ // 模型读不出主次 —— 而"哪些内容归在哪个板块/场景下"正是它做判断时要用的结构。
415
+ //
416
+ // 开标签后**必须空一行**:markdown 里 `#` 紧跟在一行文字后面只是**段落续行**,不会被渲染成
417
+ // 标题 —— 用户截图里 `## 本机当前的场景` 就是这么被吞掉的(和 `<system-reminder>` 挤成一段)。
418
+ // 收尾同理:正文末尾先归一成单个空行,免得 `</system-reminder>` 粘在最后一行上。
419
+ // 结构:标题 → 补充说明(有才发)→ 权威声明 → 正文。没有 cue 那一行(见 `DomainFrame` 的注释)。
420
+ const lines = [FRAME_OPEN, '', `# ${frame.title}`];
371
421
  const how = typeof frame.how === 'function' ? frame.how(toolHidden) : frame.how;
372
422
  if (how !== undefined)
373
423
  lines.push(how);
374
- lines.push(frame.supersede, '', escapeFrameBody(section.text), FRAME_CLOSE);
424
+ lines.push(frame.supersede, '', escapeFrameBody(section.text).replace(/\n+$/, ''), '', FRAME_CLOSE);
375
425
  return lines.join('\n');
376
426
  }
377
427
  /** 「已清空」通知正文:某个域曾经注入过、现在没有内容时发一条(纯函数,测试用)。 */
378
428
  export function clearedDomainText(key, label) {
379
429
  const frame = domainFrame(key, label);
430
+ // 形状与 `renderDomainText` 一致(开标签后空行、板块 `#`、收尾空行)—— 这两条都是同一个
431
+ // 通道发出去的消息,层级与留白不该有两套。
380
432
  return [
381
433
  FRAME_OPEN,
382
- `## ${frame.title}`,
434
+ '',
435
+ `# ${frame.title}`,
383
436
  '**已清空** —— 本次会话中此前注入的同类内容不再有效。',
437
+ '',
384
438
  FRAME_CLOSE,
385
439
  ].join('\n');
386
440
  }
@@ -455,6 +509,8 @@ function suppressionVerdict(active, dropped, counter) {
455
509
  kind: 'read',
456
510
  fallback: 'inform-only',
457
511
  detail: `关掉的注入域已连续 ${counter.turns} 轮没有拦到任何官方消息(自检):可能是瀑布注册顺序变了导致拦截失效,也可能是官方那两条注入行这几轮本来就没有内容。可到「注入实况」对照模型实际收到的内容。`,
512
+ detailKey: 'official-suppression',
513
+ params: { turns: counter.turns },
458
514
  });
459
515
  }
460
516
  /** 实况展示用的 kind 表:本插件的五条 + 官方两条。 */
@@ -621,7 +677,7 @@ export function createContextInjector(deps) {
621
677
  return;
622
678
  const at = Date.now();
623
679
  // 记在**发起这次调用的那个会话**的账上。`exec.agent` 与 pre-step 的 `payload.agent`
624
- // 是同一个对象(dsh-scope 的不变量要求,见 review/后续方向.md §2 末),所以这里
680
+ // 是同一个对象(`dsh-scope` 的不变量要求),所以这里
625
681
  // 落账的会话与上面 `liveDomainsByAgent` 记现场的会话必然一致。
626
682
  let owner = typeof agent === 'object' && agent !== null ? agent : undefined;
627
683
  if (owner === undefined) {