dsh-plugin-tool-management 0.5.0 → 0.6.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 (43) hide show
  1. package/README.md +300 -236
  2. package/README_EN.md +300 -290
  3. package/cordis.patch.yml +9 -55
  4. package/docs/Changelog.md +108 -435
  5. package/docs/images/1/345/234/272/346/231/257.png +0 -0
  6. package/docs/images/1/345/234/272/346/231/257_en.png +0 -0
  7. package/docs/images/2MCP.png +0 -0
  8. package/docs/images/2MCP_en.png +0 -0
  9. package/docs/images/3/346/212/200/350/203/275.png +0 -0
  10. package/docs/images/3/346/212/200/350/203/275_en.png +0 -0
  11. package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223.png +0 -0
  12. package/docs/images/4/345/255/220/346/231/272/350/203/275/344/275/223_en.png +0 -0
  13. package/docs/images/5/346/217/220/347/244/272/350/257/215.png +0 -0
  14. package/docs/images/5/346/217/220/347/244/272/350/257/215_en.png +0 -0
  15. package/docs/images/6/350/256/260/345/277/206.png +0 -0
  16. package/docs/images/6/350/256/260/345/277/206_en.png +0 -0
  17. package/docs/images/7/344/274/232/350/257/235.png +0 -0
  18. package/docs/images/7/344/274/232/350/257/235_en.png +0 -0
  19. package/docs/images/8/345/205/274/345/256/271.png +0 -0
  20. package/docs/images/8/345/205/274/345/256/271_en.png +0 -0
  21. package/docs/update.md +54 -0
  22. package/lib/agents-md/preset-id.js +49 -0
  23. package/lib/agents-md/service.js +138 -56
  24. package/lib/client.js +4142 -3489
  25. package/lib/compat/probe.js +665 -0
  26. package/lib/history/bridge.js +293 -0
  27. package/lib/history/workspace.js +498 -52
  28. package/lib/http-fence.js +73 -0
  29. package/lib/index.js +511 -126
  30. package/lib/rules/provider.js +3 -3
  31. package/lib/rules/service.js +264 -30
  32. package/lib/scene-prompt-sync.js +112 -0
  33. package/lib/skills/core.js +112 -35
  34. package/lib/skills/service.js +6 -1
  35. package/package.json +5 -3
  36. package/screenshots.json +8 -7
  37. package/docs/images/MCP.png +0 -0
  38. package/docs/images//344/274/232/350/257/235.png +0 -0
  39. package/docs/images//345/234/272/346/231/257.png +0 -0
  40. package/docs/images//345/255/220/346/231/272/350/203/275/344/275/223.png +0 -0
  41. package/docs/images//346/212/200/350/203/275.png +0 -0
  42. package/docs/images//346/217/220/347/244/272/350/257/215.png +0 -0
  43. package/docs/images//350/256/260/345/277/206.png +0 -0
@@ -37,7 +37,7 @@ function systemPromptOf(scope) {
37
37
  return undefined;
38
38
  }
39
39
  }
40
- /** 注册全局 + agent-scope 的场景记忆段。 */
40
+ /** 注册全局 + agent-scope 的场景记忆段与场景提示词段。 */
41
41
  export function createRuleProviderRegistrar(ctx, facade) {
42
42
  const registrations = new Map();
43
43
  const install = (scope, key) => {
@@ -57,8 +57,8 @@ export function createRuleProviderRegistrar(ctx, facade) {
57
57
  registrations.set(key, { scope, dispose: typeof dispose === 'function' ? dispose : () => { } });
58
58
  }
59
59
  catch (e) {
60
- // 同层重名注册会抛错:降级为"该 scope 不注入",不影响其余 scope。
61
- console.error('[dsh-plugin-tool-management] scene-memory section registration failed:', String((e && e.message) || e));
60
+ // 同层重名注册会抛错:降级为"该段不注入",不影响其余 scope。
61
+ console.error(`[dsh-plugin-tool-management] ${SECTION_NAME} section registration failed:`, String((e && e.message) || e));
62
62
  }
63
63
  };
64
64
  const uninstall = (key) => {
@@ -32,6 +32,7 @@ import { basename, dirname, join, resolve } from 'node:path';
32
32
  import { parseSkillDoc, resolveDshHome, unquote } from '../skills/core.js';
33
33
  import { expandUploads, planMemoryImport } from '../imports/upload.js';
34
34
  import { createRuleProviderRegistrar } from './provider.js';
35
+ import { normalizePresetId } from '../agents-md/preset-id.js';
35
36
  import { normalizeArchive, memoryAllowed } from './archive.js';
36
37
  // ── 常量 ───────────────────────────────────────────────────────────────────
37
38
  const MAX_SOURCE_DEPTH = 64; // 与 core.js 一致
@@ -336,6 +337,11 @@ function parseSceneEntry(raw) {
336
337
  out.label = obj.label;
337
338
  if (typeof obj.description === 'string' && obj.description !== '')
338
339
  out.description = obj.description;
340
+ if (typeof obj.prompt === 'string') {
341
+ const prompt = normalizeScenePromptId(obj.prompt);
342
+ if (prompt)
343
+ out.prompt = prompt;
344
+ }
339
345
  if (typeof obj.order === 'number' && Number.isFinite(obj.order))
340
346
  out.order = obj.order;
341
347
  if (typeof obj.createdAt === 'string' && obj.createdAt !== '')
@@ -457,10 +463,20 @@ function sceneRecordOf(name, entry) {
457
463
  name,
458
464
  ...(name === GLOBAL_SCENE ? { label: e.label || GLOBAL_SCENE_LABEL } : (e.label ? { label: e.label } : {})),
459
465
  ...(e.description ? { description: e.description } : {}),
466
+ ...(e.prompt ? { prompt: e.prompt } : {}),
460
467
  order: e.order ?? (name === GLOBAL_SCENE ? 0 : DEFAULT_GROUP_ORDER),
461
468
  ...(e.createdAt ? { createdAt: e.createdAt } : {}),
462
469
  };
463
470
  }
471
+ /** 提示词预设 id 的口径与 agents-md 服务**同源**(用户裁定:id 什么都能写)。 */
472
+ /** 校验场景要绑定的提示词预设 id;返回 `''` 表示解绑,`null` 表示非法。 */
473
+ function normalizeScenePromptId(value) {
474
+ const id = String(value ?? '').trim();
475
+ if (id === '')
476
+ return '';
477
+ const result = normalizePresetId(id);
478
+ return result.ok ? result.id : null;
479
+ }
464
480
  /** 路径是否存在(不跟随符号链接;用于迁移前置判断)。 */
465
481
  async function pathExists(path) {
466
482
  try {
@@ -561,12 +577,14 @@ function normalizeActive(raw) {
561
577
  return out;
562
578
  }
563
579
  /**
564
- * 活动场景解析(§4 对照表):
565
- * - `index.active` 缺失 / null → **全部场景启用**(默认;保证"丢进去就有用")
566
- * - `index.active` 为数组 → 只有列出的场景启用(显式收窄)
580
+ * 活动场景解析(用户裁定 2026-09-15:**除「全局」外同时只能启用一个场景**):
581
+ * - `index.active` 为数组(新写入的唯一形态)→ 至多一个非保留场景启用;
582
+ * - `index.active` 缺失 / null → 历史默认("全部场景启用");**不再产生**新值,
583
+ * 新建场景时会被收敛成显式数组(见 `collapseActiveForNewScene`),
584
+ * 因此"新建即启用"不会发生;存量数据仍按老语义读,避免升级后注入范围突变。
567
585
  * - `_shared` 恒常启用(公共基线),不受开关影响
568
586
  * - 保留场景 `global` 恒常启用:它的记忆对任何对话都成立
569
- * 缺失 rules-index.json 一律按默认值运行("全部场景启用"),不抛错(§9.3)。
587
+ * 缺失 rules-index.json 一律按默认值运行,不抛错(§9.3)。
570
588
  * 场景生效与否只由 index.active 决定(场景档案/模式也写这一份)。
571
589
  */
572
590
  function resolveActiveScenes(index, knownScenes) {
@@ -577,6 +595,30 @@ function resolveActiveScenes(index, knownScenes) {
577
595
  active.add(GLOBAL_SCENE);
578
596
  return { active, mode };
579
597
  }
598
+ /** 当前启用的**非保留**场景(单选模型下至多一个;多个时按场景顺序取第一个)。 */
599
+ function enabledSceneOf(index) {
600
+ const names = Object.keys(index.scenes || {});
601
+ const { active } = resolveActiveScenes(index, names);
602
+ const enabled = names
603
+ .filter((n) => n !== SHARED_GROUP && n !== GLOBAL_SCENE && active.has(n))
604
+ .sort((a, b) => sceneOrderOf(index, a) - sceneOrderOf(index, b) || a.localeCompare(b));
605
+ return enabled[0] ?? null;
606
+ }
607
+ /**
608
+ * 新建场景前把历史默认(`active = null` = 全部启用)收敛成显式数组,保证
609
+ * **新场景默认不启动**、且收敛后仍满足"至多一个非保留场景启用":
610
+ * - 已存在其它非保留场景 → 取顺序第一个作为启用场景(其余收敛掉,返回 `collapsed: true`)
611
+ * - 不存在 → 空数组(什么都不启用)
612
+ * @returns 是否发生了收敛(供 UI 如实提示)。
613
+ */
614
+ function collapseActiveForNewScene(index) {
615
+ if (normalizeActive(index.active) !== null)
616
+ return false;
617
+ const names = Object.keys(index.scenes || {}).filter((n) => n !== SHARED_GROUP && n !== GLOBAL_SCENE);
618
+ const first = names.sort((a, b) => sceneOrderOf(index, a) - sceneOrderOf(index, b) || a.localeCompare(b))[0];
619
+ index.active = first === undefined ? [] : [first];
620
+ return true;
621
+ }
580
622
  /** 场景名 = 分组路径的第一段(`web/frontend` 属于场景 `web`)。 */
581
623
  function sceneOf(group) {
582
624
  const idx = String(group || '').indexOf('/');
@@ -1054,6 +1096,122 @@ export function createRulesService(ctx, deps) {
1054
1096
  sceneCache = { signature: probe.signature, value };
1055
1097
  return value;
1056
1098
  }
1099
+ // ── 场景提示词段(绑定预设正文;只读,绝不写 ~/.dsh/AGENTS.md)──────────────
1100
+ //
1101
+ // 语义(用户裁定 2026-09-15):
1102
+ // - 场景可绑定**一个**提示词预设(`scenes[].prompt` → `agents-md/<id>/AGENTS.md`);
1103
+ // - 除保留场景 `global` 外**同时只能启用一个场景**,所以同一时刻至多一份提示词在场;
1104
+ // - 没有启用其它场景时才轮到 `global` 自己的绑定(它恒常生效,覆盖"永远在场的提示词");
1105
+ // - 预设文件不存在 / 未绑定 → 返回 `''`(renderPrompt 会删掉空段,不产生空标题)。
1106
+ //
1107
+ // 为什么不用 fs.watch 也不用每请求读盘:段契约要求**同步返回**且逐字节稳定,
1108
+ // 因此按 `mtimeMs + size` 做一次 stat 缓存 —— 命中时零读盘,外部编辑器改了预设
1109
+ // 也会在下一个请求自动刷新(与记忆段的"两相扫描"同一哲学)。
1110
+ const promptFileCache = new Map();
1111
+ /** 读一个预设正文(同步、带 stat 指纹缓存);不存在返回 `''`。 */
1112
+ function readPresetTextSync(id) {
1113
+ const file = join(stateDir, 'agents-md', id, 'AGENTS.md');
1114
+ let key;
1115
+ try {
1116
+ const st = statSync(file);
1117
+ if (!st.isFile())
1118
+ return '';
1119
+ key = `${st.mtimeMs}:${st.size}`;
1120
+ }
1121
+ catch {
1122
+ promptFileCache.delete(id);
1123
+ return '';
1124
+ }
1125
+ const cached = promptFileCache.get(id);
1126
+ if (cached && cached.key === key)
1127
+ return cached.text;
1128
+ let text = '';
1129
+ try {
1130
+ text = readFileSync(file, 'utf8');
1131
+ }
1132
+ catch {
1133
+ return '';
1134
+ }
1135
+ promptFileCache.set(id, { key, text });
1136
+ return text;
1137
+ }
1138
+ /** 预设文件是否存在(同步;绑定前校验用,避免悬空绑定)。 */
1139
+ function presetExistsSync(id) {
1140
+ try {
1141
+ return statSync(join(stateDir, 'agents-md', id, 'AGENTS.md')).isFile();
1142
+ }
1143
+ catch {
1144
+ return false;
1145
+ }
1146
+ }
1147
+ /**
1148
+ * 读当前 `~/.dsh/AGENTS.md`(同步、带 stat 指纹缓存)。
1149
+ * 用途只有一个:**去重**——绑定的预设与文件内容逐字节相同时不再注入第二遍正文。
1150
+ */
1151
+ let globalAgentsCache = null;
1152
+ function readGlobalAgentsMdSync() {
1153
+ const file = join(resolveDshHome(), 'AGENTS.md');
1154
+ let key;
1155
+ try {
1156
+ const st = statSync(file);
1157
+ if (!st.isFile())
1158
+ return '';
1159
+ key = `${st.mtimeMs}:${st.size}`;
1160
+ }
1161
+ catch {
1162
+ globalAgentsCache = null;
1163
+ return '';
1164
+ }
1165
+ if (globalAgentsCache && globalAgentsCache.key === key)
1166
+ return globalAgentsCache.text;
1167
+ let text = '';
1168
+ try {
1169
+ text = readFileSync(file, 'utf8');
1170
+ }
1171
+ catch {
1172
+ return '';
1173
+ }
1174
+ globalAgentsCache = { key, text };
1175
+ return text;
1176
+ }
1177
+ /**
1178
+ * 当前生效的场景提示词:启用场景的绑定优先,其次 `global` 的绑定;都没有则 `''`。
1179
+ * 绑定内容与 `~/.dsh/AGENTS.md` 相同时标记 `duplicate` 且返回空段 —— 那份正文已经在
1180
+ * 基线里了,再注入一遍只会把同样的内容塞进上下文两次。
1181
+ */
1182
+ function scenePrompt() {
1183
+ const index = readIndexSync(stateDir);
1184
+ const names = Object.keys(index.scenes || {});
1185
+ const { active } = resolveActiveScenes(index, names);
1186
+ const globalText = readGlobalAgentsMdSync().trim();
1187
+ /** 绑定 → 投影;与文件一致时是 duplicate(不注入),否则原样注入。 */
1188
+ const project = (scene, id) => {
1189
+ const text = readPresetTextSync(id);
1190
+ if (text.trim() === '')
1191
+ return { scene, presetId: id, text: '', missing: true };
1192
+ if (globalText !== '' && text.trim() === globalText)
1193
+ return { scene, presetId: id, text: '', missing: false, duplicate: true };
1194
+ return { scene, presetId: id, text, missing: false };
1195
+ };
1196
+ // 单选:取唯一一个非保留启用场景(resolveActiveScenes 已保证 ≤1,这里仍做确定性排序兜底)。
1197
+ const enabled = names
1198
+ .filter((n) => n !== SHARED_GROUP && n !== GLOBAL_SCENE && active.has(n))
1199
+ .sort((a, b) => sceneOrderOf(index, a) - sceneOrderOf(index, b) || a.localeCompare(b));
1200
+ for (const scene of enabled) {
1201
+ const id = index.scenes?.[scene]?.prompt;
1202
+ if (!id)
1203
+ continue;
1204
+ const projected = project(scene, id);
1205
+ // 绑了但预设为空/已删 → 继续找下一个(不注入空段)。
1206
+ if (projected.missing)
1207
+ continue;
1208
+ return projected;
1209
+ }
1210
+ const globalId = index.scenes?.[GLOBAL_SCENE]?.prompt;
1211
+ if (globalId)
1212
+ return project(GLOBAL_SCENE, globalId);
1213
+ return { scene: null, presetId: null, text: '', missing: false };
1214
+ }
1057
1215
  const providerInvalidators = new Set();
1058
1216
  /** 通知已注册的 provider 提示词已变化(best-effort)。 */
1059
1217
  const invalidateProviders = () => {
@@ -1205,6 +1363,7 @@ export function createRulesService(ctx, deps) {
1205
1363
  }
1206
1364
  const known = [...names];
1207
1365
  const { active } = resolveActiveScenes(index, known);
1366
+ const enabledScene = enabledSceneOf(index);
1208
1367
  return known
1209
1368
  .map((name) => ({
1210
1369
  name,
@@ -1215,6 +1374,9 @@ export function createRulesService(ctx, deps) {
1215
1374
  shared: name === SHARED_GROUP,
1216
1375
  global: name === GLOBAL_SCENE,
1217
1376
  description: index.scenes?.[name]?.description || '',
1377
+ prompt: index.scenes?.[name]?.prompt || '',
1378
+ // 单选模型下"能不能点开":已被别的场景占用时,这个开关要置灰。
1379
+ selectable: name !== SHARED_GROUP && name !== GLOBAL_SCENE && (enabledScene === null || enabledScene === name),
1218
1380
  }))
1219
1381
  .sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
1220
1382
  }
@@ -1229,6 +1391,7 @@ export function createRulesService(ctx, deps) {
1229
1391
  const groups = groupFilter ? snap.groups.filter((g) => g.name === groupFilter) : snap.groups;
1230
1392
  const scenes = sceneRows(snap, index);
1231
1393
  const projection = sceneMemory();
1394
+ const promptProjection = scenePrompt();
1232
1395
  return {
1233
1396
  ok: true,
1234
1397
  rules,
@@ -1237,7 +1400,17 @@ export function createRulesService(ctx, deps) {
1237
1400
  // 场景 = 显式记录(含保留场景 global 与尚无记忆的空场景);active = 是否参与注入。
1238
1401
  scenes,
1239
1402
  activeMode: normalizeActive(index.active) === null ? 'all' : 'custom',
1403
+ // 单选模型:当前启用的那个场景(null = 只留 `_shared` 与 `global`)。
1404
+ activeScene: enabledSceneOf(index),
1240
1405
  sceneMemory: { usedBytes: projection.bytes, maxBytes: projection.maxBytes, truncated: projection.truncated, dropped: projection.dropped },
1406
+ // 当前生效的场景提示词(绑定的预设正文;与 ~/.dsh/AGENTS.md 一致时不重复注入)。
1407
+ scenePrompt: {
1408
+ scene: promptProjection.scene,
1409
+ presetId: promptProjection.presetId,
1410
+ missing: promptProjection.missing,
1411
+ duplicate: promptProjection.duplicate === true,
1412
+ bytes: Buffer.byteLength(promptProjection.text, 'utf8'),
1413
+ },
1241
1414
  // 路径供 UI 显示「文件在哪」;不再让界面硬编码 ~/.dsh/scene-memory。
1242
1415
  paths: { memories: rulesRoot, scenes: scenesRoot, hub: stateDir },
1243
1416
  stats: {
@@ -1884,35 +2057,45 @@ export function createRulesService(ctx, deps) {
1884
2057
  return { ok: true, rule: await buildProjected(id) };
1885
2058
  }
1886
2059
  /**
1887
- * 设置"启用场景"集合(全局持久化,切换后**下一个请求即生效**,§4 对照表)。
1888
- * - `args.all === true` → 清空显式集合,回到"全部启用"默认(新建场景自动生效)
1889
- * - `args.scenes` 数组 → 显式收窄(`_shared` 恒常,无需列出)
2060
+ * 设置"启用场景"(全局持久化,切换后**下一个请求即生效**,§4 对照表)。
2061
+ * - `args.scenes` 数组 → 启用集合;**至多一个非保留场景**(用户裁定:除「全局」外
2062
+ * 同时只能启用一个),`[]` = 全部关闭(只留恒常的 `_shared` 与 `global`)
2063
+ * - `args.all === true` → 旧的"全部启用"形态,与新模型冲突,明确拒绝(不猜)
1890
2064
  * 场景名按放宽后的规则校验;不存在的场景名也允许保存(目录随后创建即可生效)。
1891
2065
  */
1892
2066
  async function rulesSetActive(args) {
1893
- const index = await readIndex(stateDir);
1894
- if (args && args.all === true) {
1895
- index.active = null;
2067
+ // 参数必须显式二选一。旧实现把「没传参数」「传了未知参数名」都落进 else 分支、
2068
+ // 用空数组覆盖 active,于是 `rules-set-active {}` 会静默把模式切成 custom 且零激活场景
2069
+ // (用户以为自己只是"没改动",实际已经改了注入范围)。这里显式拒绝。
2070
+ const hasAll = !!(args && args.all === true);
2071
+ const hasScenes = Array.isArray(args && args.scenes);
2072
+ if (!hasAll && !hasScenes) {
2073
+ return fail('error.rules.invalidArgs', '缺少参数:需要 scenes:[...](启用集合,至多一个场景)');
1896
2074
  }
1897
- else {
1898
- const raw = Array.isArray(args && args.scenes) ? args.scenes : [];
1899
- const names = [];
1900
- const seen = new Set();
1901
- for (const item of raw) {
1902
- const name = String(item == null ? '' : item).trim();
1903
- // 恒常启用的保留场景不入显式集合:`_shared`(公共基线)与 `global`(「全局」)。
1904
- if (name === '' || name === SHARED_GROUP || name === GLOBAL_SCENE)
1905
- continue;
1906
- if (!isValidGroupPath(name)) {
1907
- return fail('error.rules.invalidGroup', `场景名非法:${name}(非空、≤${MAX_GROUP_SEGMENT_LENGTH} 字符、不含路径分隔符与 < > : " | ? *、不以 . 开头)`);
1908
- }
1909
- if (seen.has(name))
1910
- continue;
1911
- seen.add(name);
1912
- names.push(name);
2075
+ if (hasAll) {
2076
+ return fail('error.rules.singleSceneOnly', '除「全局」外同时只能启用一个场景:不支持"全部启用",请改用 scenes:[<场景名>] 或 scenes:[](全部关闭)');
2077
+ }
2078
+ const index = await readIndex(stateDir);
2079
+ const raw = Array.isArray(args && args.scenes) ? args.scenes : [];
2080
+ const names = [];
2081
+ const seen = new Set();
2082
+ for (const item of raw) {
2083
+ const name = String(item == null ? '' : item).trim();
2084
+ // 恒常启用的保留场景不入显式集合:`_shared`(公共基线)与 `global`(「全局」)。
2085
+ if (name === '' || name === SHARED_GROUP || name === GLOBAL_SCENE)
2086
+ continue;
2087
+ if (!isValidGroupPath(name)) {
2088
+ return fail('error.rules.invalidGroup', `场景名非法:${name}(非空、≤${MAX_GROUP_SEGMENT_LENGTH} 字符、不含路径分隔符与 < > : " | ? *、不以 . 开头)`);
1913
2089
  }
1914
- index.active = names;
2090
+ if (seen.has(name))
2091
+ continue;
2092
+ seen.add(name);
2093
+ names.push(name);
2094
+ }
2095
+ if (names.length > 1) {
2096
+ return fail('error.rules.singleSceneOnly', `除「全局」外同时只能启用一个场景(收到 ${names.length} 个:${names.join('、')})`);
1915
2097
  }
2098
+ index.active = names;
1916
2099
  await writeIndex(stateDir, index);
1917
2100
  invalidateSnapshot();
1918
2101
  invalidateProviders();
@@ -1942,6 +2125,10 @@ export function createRulesService(ctx, deps) {
1942
2125
  if (description.length > MAX_DESCRIPTION_LENGTH) {
1943
2126
  return fail('error.rules.descriptionTooLong', `场景描述过长(≤${MAX_DESCRIPTION_LENGTH} 字符)`);
1944
2127
  }
2128
+ const prompt = args && args.prompt !== undefined ? normalizeScenePromptId(args.prompt) : undefined;
2129
+ if (prompt === null) {
2130
+ return fail('error.rules.invalidGroup', `提示词预设 id 非法:${String(args.prompt)}(仅小写字母/数字/连字符,且不能是备份槽)`);
2131
+ }
1945
2132
  await ensureLayout();
1946
2133
  try {
1947
2134
  await mkdir(join(rulesRoot, name), { recursive: true });
@@ -1950,6 +2137,9 @@ export function createRulesService(ctx, deps) {
1950
2137
  return fail('error.rules.ioFailed', `创建场景目录失败:${message(e)}`);
1951
2138
  }
1952
2139
  const index = await readIndex(stateDir);
2140
+ // **新场景默认不启动**:先把历史默认(active=null=全部启用)收敛成显式集合,
2141
+ // 新建的这个自然不在其中;收敛后仍是"至多一个场景启用"。
2142
+ const collapsed = collapseActiveForNewScene(index);
1953
2143
  if (!index.scenes)
1954
2144
  index.scenes = {};
1955
2145
  const prev = index.scenes[name] || {};
@@ -1957,6 +2147,7 @@ export function createRulesService(ctx, deps) {
1957
2147
  ...prev,
1958
2148
  ...(label !== '' && name !== GLOBAL_SCENE ? { label } : {}),
1959
2149
  ...(description !== '' ? { description } : {}),
2150
+ ...(prompt !== undefined && prompt !== '' ? { prompt } : {}),
1960
2151
  order: prev.order ?? DEFAULT_GROUP_ORDER,
1961
2152
  createdAt: prev.createdAt ?? new Date().toISOString(),
1962
2153
  };
@@ -1966,10 +2157,10 @@ export function createRulesService(ctx, deps) {
1966
2157
  await writeIndex(stateDir, index);
1967
2158
  invalidateSnapshot();
1968
2159
  invalidateProviders();
1969
- return { ok: true, scene: sceneRecordOf(name, next) };
2160
+ return { ok: true, scene: sceneRecordOf(name, next), ...(collapsed ? { collapsedActive: true } : {}) };
1970
2161
  }
1971
2162
  /**
1972
- * 更新场景记录(描述 / 显示名 / 顺序)。记忆文件不动。
2163
+ * 更新场景记录(描述 / 显示名 / 顺序 / 绑定的提示词预设)。记忆文件不动。
1973
2164
  * 这是「场景有描述」的写入口——旧版场景只有目录名,没有可编辑元数据。
1974
2165
  */
1975
2166
  async function rulesUpdateScene(args) {
@@ -1993,6 +2184,20 @@ export function createRulesService(ctx, deps) {
1993
2184
  else
1994
2185
  next.description = description;
1995
2186
  }
2187
+ if (args && args.prompt !== undefined) {
2188
+ const prompt = normalizeScenePromptId(args.prompt);
2189
+ if (prompt === null) {
2190
+ return fail('error.rules.invalidGroup', `提示词预设 id 非法:${String(args.prompt)}(仅小写字母/数字/连字符,且不能是备份槽)`);
2191
+ }
2192
+ // 绑定前校验预设确实存在:宁可现在拒绝,也不要留一个永远注入不出东西的悬空绑定。
2193
+ if (prompt !== '' && !presetExistsSync(prompt)) {
2194
+ return fail('error.rules.notFound', `提示词预设不存在:${prompt}`);
2195
+ }
2196
+ if (prompt === '')
2197
+ delete next.prompt;
2198
+ else
2199
+ next.prompt = prompt;
2200
+ }
1996
2201
  if (args && args.label !== undefined && name !== GLOBAL_SCENE) {
1997
2202
  const label = String(args.label).trim();
1998
2203
  if (label === '')
@@ -2074,6 +2279,34 @@ export function createRulesService(ctx, deps) {
2074
2279
  invalidateProviders();
2075
2280
  return { ok: true, name };
2076
2281
  }
2282
+ /**
2283
+ * 提示词预设改名后**同步场景绑定**:把所有 `scenes[].prompt === from` 改成 `to`。
2284
+ * 由 AGENTS.md 预设库的改名路径调用——绑定存在 `rules-index.json` 里,预设库
2285
+ * 自己看不到它,不叫这一声改名就会留下悬空绑定(场景卡片显示「预设不存在」)。
2286
+ * @returns 改了几个场景。
2287
+ */
2288
+ async function rulesRebindPrompt(args) {
2289
+ const from = String((args && args.from) || '').trim();
2290
+ const to = String((args && args.to) || '').trim();
2291
+ if (!from || !to)
2292
+ return fail('error.rules.invalidArgs', '需要 from 与 to 两个预设 id');
2293
+ const index = await readIndex(stateDir);
2294
+ const scenes = index.scenes || {};
2295
+ let changed = 0;
2296
+ for (const [name, entry] of Object.entries(scenes)) {
2297
+ if (entry && entry.prompt === from) {
2298
+ scenes[name] = { ...entry, prompt: to };
2299
+ changed++;
2300
+ }
2301
+ }
2302
+ if (changed) {
2303
+ index.scenes = scenes;
2304
+ await writeIndex(stateDir, index);
2305
+ invalidateSnapshot();
2306
+ invalidateProviders();
2307
+ }
2308
+ return { ok: true, from, to, changed };
2309
+ }
2077
2310
  async function rulesSetIndex(args) {
2078
2311
  const id = String((args && args.id) || '');
2079
2312
  const parts = parseId(id);
@@ -2130,7 +2363,7 @@ export function createRulesService(ctx, deps) {
2130
2363
  // index.ts 从本集合派生,勿在宿主端另抄一份。
2131
2364
  const writeOps = new Set([
2132
2365
  'rules-create', 'rules-update', 'rules-remove', 'rules-restore', 'rules-toggle', 'rules-import',
2133
- 'rules-set-index', 'rules-set-active', 'rules-create-scene', 'rules-update-scene', 'rules-remove-scene',
2366
+ 'rules-set-index', 'rules-set-active', 'rules-create-scene', 'rules-update-scene', 'rules-remove-scene', 'rules-rebind-prompt',
2134
2367
  'rules-attach', 'rules-detach', 'rules-trash-remove',
2135
2368
  ]);
2136
2369
  const ops = {
@@ -2153,6 +2386,7 @@ export function createRulesService(ctx, deps) {
2153
2386
  'rules-create-scene': (args) => runWrite(() => rulesCreateScene(args || {})),
2154
2387
  'rules-update-scene': (args) => runWrite(() => rulesUpdateScene(args || {})),
2155
2388
  'rules-remove-scene': (args) => runWrite(() => rulesRemoveScene(args || {})),
2389
+ 'rules-rebind-prompt': (args) => runWrite(() => rulesRebindPrompt(args || {})),
2156
2390
  };
2157
2391
  const service = {
2158
2392
  ops,
@@ -0,0 +1,112 @@
1
+ // dsh-plugin-tool-management —— 场景绑定提示词 ⇄ 全局基线(`~/.dsh/AGENTS.md`)同步。
2
+ //
3
+ // 用户裁定(2026-09-15):「切换场景,对应的提示词直接把 AGENTS.md 直接修改」。
4
+ // 因此场景绑定的提示词不再作为 systemPrompt 段注入,而是**真写全局基线文件**,
5
+ // 与「提示词」页的「应用」走同一条路(覆盖前多代备份到 `agents-md/__last-applied__/`)。
6
+ //
7
+ // 四个动作会触发同步(调用方在 op 层包一层 `withSync`):
8
+ // ① 启用/切换场景 ② 关掉场景(恢复进场景前的基线)
9
+ // ③ 改场景绑定的预设 ④ 编辑"正在驱动基线的那份预设"的正文
10
+ //
11
+ // 「进场景前的基线」快照存在 hub 的 `scene-baseline.json`:进场景时记下当时的
12
+ // AGENTS.md 正文(以及当时匹配到的预设 id),关掉场景时**按原文**写回 ——
13
+ // 那份内容可能是用户手写的,不一定对应任何预设。恢复后把快照清空,避免之后再关一次
14
+ // 场景时复活旧基线。
15
+ //
16
+ // 幂等与如实:文件已经是那份(`duplicate`)时不写盘;没有场景驱动、也没有快照时什么都不做;
17
+ // 写失败**不改**原操作的成功结论,而是把原因放进 `error` 交给界面显示成警告。
18
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
19
+ import { dirname } from 'node:path';
20
+ export function createScenePromptSync(deps) {
21
+ const warn = (message) => { try {
22
+ deps.logger?.warn?.(message);
23
+ }
24
+ catch { /* ignore */ } };
25
+ async function readBaseline() {
26
+ try {
27
+ const raw = await readFile(deps.baselineFile, 'utf8');
28
+ const obj = JSON.parse(raw);
29
+ if (!obj || typeof obj !== 'object')
30
+ return null;
31
+ return {
32
+ v: 1,
33
+ at: Number.isFinite(obj.at) ? Number(obj.at) : 0,
34
+ presetId: typeof obj.presetId === 'string' ? obj.presetId : null,
35
+ content: typeof obj.content === 'string' ? obj.content : null,
36
+ };
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ async function writeBaseline(next) {
43
+ try {
44
+ await mkdir(dirname(deps.baselineFile), { recursive: true });
45
+ await writeFile(deps.baselineFile, JSON.stringify(next), 'utf8');
46
+ }
47
+ catch (e) {
48
+ warn(`scene-prompt: could not persist baseline: ${String(e)}`);
49
+ }
50
+ }
51
+ async function state() {
52
+ try {
53
+ const r = await deps.rules.ops['rules-list']({});
54
+ const sp = r && r.ok ? r.scenePrompt : null;
55
+ if (!sp)
56
+ return null;
57
+ return {
58
+ scene: sp.scene ? String(sp.scene) : null,
59
+ presetId: sp.presetId ? String(sp.presetId) : null,
60
+ missing: sp.missing === true,
61
+ duplicate: sp.duplicate === true,
62
+ };
63
+ }
64
+ catch (e) {
65
+ warn(`scene-prompt: probe failed: ${String(e)}`);
66
+ return null;
67
+ }
68
+ }
69
+ async function sync() {
70
+ const current = await state();
71
+ if (!current)
72
+ return { unchanged: true };
73
+ const driving = current.scene !== null && !current.missing && current.presetId !== null;
74
+ if (driving) {
75
+ if (current.duplicate)
76
+ return { applied: current.presetId, unchanged: true };
77
+ // 进场景的第一笔写入之前,先记下当时的基线(已有快照就不覆盖:连续切场景时
78
+ // 基线始终是"进场景之前"那一份)。
79
+ const baseline = await readBaseline();
80
+ if (!baseline || baseline.content === null) {
81
+ const cur = await deps.agentsMd.getCurrent();
82
+ await writeBaseline({
83
+ v: 1,
84
+ at: Date.now(),
85
+ presetId: cur && cur.ok && cur.presetId ? String(cur.presetId) : null,
86
+ content: cur && cur.ok && cur.exists ? String(cur.content) : null,
87
+ });
88
+ }
89
+ const res = await deps.agentsMd.apply(current.presetId);
90
+ if (res && res.ok === false)
91
+ return { error: String(res.error || '写入 AGENTS.md 失败') };
92
+ return { applied: current.presetId };
93
+ }
94
+ // 没有场景驱动 → 如果刚从驱动态退出,把进场景前的基线写回去。
95
+ const baseline = await readBaseline();
96
+ if (!baseline || baseline.content === null)
97
+ return { unchanged: true };
98
+ const res = await deps.agentsMd.restore(baseline.content);
99
+ if (res && res.ok === false)
100
+ return { error: String(res.error || '恢复 AGENTS.md 失败') };
101
+ await writeBaseline({ v: 1, at: Date.now(), presetId: null, content: null });
102
+ return { restored: true };
103
+ }
104
+ async function withSync(res) {
105
+ const anyRes = res;
106
+ if (!anyRes || anyRes.ok === false)
107
+ return res;
108
+ const agentsMd = await sync();
109
+ return { ...res, agentsMd };
110
+ }
111
+ return { sync, withSync, state };
112
+ }