dsh-plugin-tool-management 0.10.0 → 0.11.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 (74) hide show
  1. package/CHANGELOG.md +71 -1
  2. package/README.md +64 -49
  3. package/README_EN.md +58 -37
  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 +105 -12
  21. package/lib/client.js +2763 -546
  22. package/lib/compat/preset-reach.js +1 -10
  23. package/lib/compat/probe.js +158 -20
  24. package/lib/context-inject.js +11 -0
  25. package/lib/host-names.js +12 -0
  26. package/lib/http-fence.js +35 -15
  27. package/lib/hub.js +28 -2
  28. package/lib/imports/parsers.js +15 -9
  29. package/lib/imports/upload.js +43 -4
  30. package/lib/index.js +804 -3887
  31. package/lib/mcp/loader-token.js +238 -0
  32. package/lib/mcp/manager.js +1681 -0
  33. package/lib/mcp/override-blocks.js +10 -3
  34. package/lib/mcp/patch-yaml.js +351 -0
  35. package/lib/mcp/secret-guard.js +145 -0
  36. package/lib/{rules → memories}/archive-engine.js +1 -1
  37. package/lib/{rules → memories}/archive.js +1 -1
  38. package/lib/memories/constants.js +128 -0
  39. package/lib/memories/index-io.js +330 -0
  40. package/lib/memories/projection.js +280 -0
  41. package/lib/memories/service.js +686 -0
  42. package/lib/memories/snapshot.js +672 -0
  43. package/lib/ops/candidates.js +64 -0
  44. package/lib/ops/compat.js +136 -0
  45. package/lib/ops/ctx.js +9 -0
  46. package/lib/ops/memory.js +678 -0
  47. package/lib/ops/prompts.js +107 -0
  48. package/lib/ops/scene-records.js +460 -0
  49. package/lib/ops/scene-sync.js +17 -0
  50. package/lib/ops/sessions.js +603 -0
  51. package/lib/ops/trash.js +140 -0
  52. package/lib/paths.js +103 -0
  53. package/lib/prompts/preset-id.js +49 -0
  54. package/lib/{agents-md → prompts}/service.js +1 -1
  55. package/lib/request-gate.js +320 -0
  56. package/lib/scene-prompt-sync.js +4 -4
  57. package/lib/scenes/candidates.js +344 -0
  58. package/lib/{history → sessions}/bridge.js +15 -5
  59. package/lib/sessions/history.js +323 -0
  60. package/lib/{history → sessions}/tombstone.js +1 -1
  61. package/lib/{history → sessions}/workspace.js +92 -24
  62. package/lib/skills/core.js +74 -39
  63. package/lib/skills/readonly-discovery.js +4 -1
  64. package/lib/skills/service.js +98 -13
  65. package/lib/subagents/service.js +199 -55
  66. package/lib/tools/deps.js +8 -0
  67. package/lib/tools/mcp.js +110 -0
  68. package/lib/tools/memory.js +87 -0
  69. package/lib/tools/prompt.js +70 -0
  70. package/lib/tools/skills.js +139 -0
  71. package/lib/tools/subagent.js +40 -0
  72. package/package.json +7 -7
  73. package/lib/agents-md/preset-id.js +0 -49
  74. package/lib/rules/service.js +0 -3078
@@ -0,0 +1,128 @@
1
+ // 规则/记忆域的模块级常量与无依赖小工具(2026-09-19 从 memories/service.ts 抽出)。
2
+ //
3
+ // 为什么单独一个文件:service.ts 里这些常量夹在类型与函数之间,而投影函数(projection.ts)、
4
+ // 索引 IO(index-io.ts)、发现与快照(snapshot.ts)与 service 本体都要用同一份 `global` /
5
+ // 预算上限 / 引导语 / 路径段校验。留在 service.ts 会让它们反过来 import service.ts —— 那是
6
+ // 运行时循环依赖。抽到这个无依赖的一层,几边都 import 它即可。
7
+ //
8
+ // 这里只放**不依赖本域状态**的东西:常量、纯字符串函数、字节计算。
9
+ // ── 预算与默认 ─────────────────────────────────────────────────────────────
10
+ import { isValidSegment } from '../paths.js';
11
+ export const MAX_SOURCE_DEPTH = 64; // 与 core.js 一致
12
+ export const MAX_DIRECTORIES = 2000; // 目录预算
13
+ export const MAX_ENTRIES = 20000; // 条目预算
14
+ export const MAX_GROUP_SEGMENT_LENGTH = 64; // 场景/子目录段名长度上限
15
+ export const MAX_DESCRIPTION_LENGTH = 500; // 派生/显式描述上限(派生超长截断,显式超长拒绝)
16
+ export const MAX_RULE_BYTES = 1 << 18; // 正文上限 256 KiB
17
+ export const DEFAULT_ORDER = 1000; // 默认投影 order(索引无记录时)
18
+ export const DEFAULT_GROUP_ORDER = 1000; // 新场景默认 order
19
+ export const SNAPSHOT_TTL_MS = 1000; // 读路径短 TTL 缓存,吸收 UI 密集轮询
20
+ export const DEFAULT_MAX_BYTES = 65536; // 场景记忆段预算上限(字节)
21
+ // ── 保留场景名 ─────────────────────────────────────────────────────────────
22
+ /** 保留场景名:界面显示「全局」,恒定存在、不可删除,其记忆注入任何对话。 */
23
+ export const GLOBAL_SCENE = 'global';
24
+ /** 保留场景在界面上的显示名(磁盘上仍用 ASCII 目录/文件名)。 */
25
+ export const GLOBAL_SCENE_LABEL = '全局';
26
+ export const SHARED_GROUP = '_shared'; // 保留场景名:公共基线(历史语义,仍可使用)
27
+ // ── 段渲染 ─────────────────────────────────────────────────────────────────
28
+ export const TRUNCATION_MARKER = '<!-- truncated -->';
29
+ // 段尾清单:让模型知道自己漏了什么。去掉伞标题后这里也不再挂「场景记忆」前缀 ——
30
+ // 它紧跟在场景块之后,`参考信息` 与段首引导语同一说法。
31
+ export const DROPPED_HEADING = '## 未注入的参考信息(超出预算)';
32
+ /** 单行正文的最大长度:超过就退回「标题 + 正文块」,避免出现一条几千字符的列表行。 */
33
+ export const INLINE_BODY_MAX = 120;
34
+ /** 附件行里最多列几个文件名;多的只报总数(路径已经给了,缺的名字模型自己列目录即可)。 */
35
+ export const ATTACHMENT_LIST_MAX = 10;
36
+ /**
37
+ * 段首的引导语:让模型知道下面是**用户为本机写的参考信息**,并且**以它为准**
38
+ * —— 涉及本机的事一律照它办,确实无关时才放下。
39
+ *
40
+ * 写法(2026-09-16 用户裁定,基于真实注入结果的三次修正):
41
+ * - **单行、加粗**,不再用括号分两行 —— 括号跨行在真实提示词里读起来像被截断,
42
+ * 而加粗是 Markdown 里最省字符的强调手段(用户要求「加强模型对此的重视程度」)。
43
+ * - **提到段首、整段只出现一次**:原来它挂在每个场景的段头里,多场景时会重复注入。
44
+ * - **不点名任何工具**:模型从工具 schema 就知道 `memory_manager_list` 存在,点名反而
45
+ * 像在提示它去调;用户裁定「没启用的信息就是不想在当前用」,所以工具指引整句删除。
46
+ * 真正防探测的是**完整性声明**(「以下就是全部信息」),那半句必须留。
47
+ * - **完整性声明按截断状态自适应**:真有条目因预算没注入时,段尾会有未注入清单,
48
+ * 此时不能再声称「全部」,否则和清单自相矛盾 —— 也正因为那时确实有东西没给到,
49
+ * 模型去查工具是**合理**的,不该再拦。
50
+ *
51
+ * 用词:不用「常驻」「注入」这类内部行话(模型没有先验);用户裁定用「信息」而不是
52
+ * 「记忆」——「记忆」在系统提示词里指代不明,而这段的实质就是用户写的参考信息。
53
+ *
54
+ * 2026-09-17 重写(用户:「当前模式会不重视这些提示词」)。上一版的三个毛病都在**授权**
55
+ * 上,而不在措辞好不好看上:
56
+ * 1. 「与当前任务相关时直接采用」——**没有给"相关"的判据**。最省力的解读永远是"无关",
57
+ * 因为判成无关不需要任何工作;
58
+ * 2. 通篇没有优先级规则。与本机实际情况冲突时,模型会默默按自己的默认假设走,
59
+ * 而用户完全不知道发生了什么;
60
+ * 3. 「无关时忽略」——「忽略」是这句话里**最后一个动词**,也是记得最牢的那个。它把
61
+ * 一个免打扰出口写成了对内容的态度许可。
62
+ * 现在:给出**判据**(凡涉及本机路径 / 配置 / 工具 / 习惯)、给出**裁决规则**、把出口降级为
63
+ * 「不必提及」(关于**要不要声明**,不是关于**要不要采用**)。出口保留是必要的 —— 去掉它
64
+ * 会让模型对无关条目强行攀附,那是另一种失真。
65
+ *
66
+ * 2026-09-17 第二版:**按条目类型分级授权**(用户采纳的四条里的第 1、4 条)。Claude Code
67
+ * 把两类内容分进两个系统、用**相反**的授权:用户指令(`claudemd.ts:89`)是
68
+ * "These instructions OVERRIDE any default behavior and you MUST follow them exactly as
69
+ * written",而记忆(`memdir/memoryTypes.ts:202`)是
70
+ * "If a recalled memory conflicts with current information, trust what you observe now"
71
+ * —— 书里(ch11:25)说记忆是 "working notes, not gospel"。上一版把两类塞进一句授权,
72
+ * 对**场景说明**(用户写的约定)是对的,对**记忆条目**(可能是几个月前记下的事实)是错的。
73
+ * 好在这两类在渲染时就分处不同位置:场景说明在 `sceneHeader` 的 `**场景说明:…**` 里,
74
+ * 记忆条目在 `memoryBlock` 里 —— 所以一句话就能分级,不必改数据结构。
75
+ *
76
+ * 冲突阶梯(第 4 条)来自 Codex `base_instructions/default.md:22-27` 与 Claude Code 的
77
+ * `caller override > agent definition > parent model > default`:把"谁高于谁"写明,
78
+ * 模型才不会在「用户当场说的 ≠ 本机记录」时悬空。写明它还有一个反直觉的好处 ——
79
+ * 它让授权更可信:这说明本条不是要让记忆压过用户,只是要压过模型的默认假设。
80
+ */
81
+ export const SCENE_MEMORY_NOTE = '**用户为本机写的参考信息:「场景说明」是用户的约定,一律照办,覆盖你的默认做法;其余条目是记录,可能已过期 —— 与当前实际情况冲突时以你看到的为准,与用户当场说的冲突时以用户为准。无关时不必提及。以下就是全部信息。**';
82
+ /** 有未注入条目时的版本:去掉完整性声明(见上)。 */
83
+ export const SCENE_MEMORY_NOTE_PARTIAL = '**用户为本机写的参考信息:「场景说明」是用户的约定,一律照办,覆盖你的默认做法;其余条目是记录,可能已过期 —— 与当前实际情况冲突时以你看到的为准,与用户当场说的冲突时以用户为准。无关时不必提及。**';
84
+ // ── bundle 与索引 ──────────────────────────────────────────────────────────
85
+ // bundle 附件限制(body 走 HTTP JSON + base64,故比技能上传收紧一档)。
86
+ export const MAX_ATTACH_ENTRY_BYTES = 8 << 20; // 单个附件 8 MiB
87
+ export const MAX_ATTACH_TOTAL_BYTES = 16 << 20; // 单次总大小 16 MiB
88
+ export const MAX_ATTACH_ENTRIES = 32; // 单次最多 32 个
89
+ export const LEGACY_BUNDLE_DOC = 'SKILL.md'; // 旧版 bundle 的正文文件名;仅在发现/附件排除时作只读兼容,新建一律用 bundleDocName()
90
+ /** bundle 的正文文件名 = `<记忆名>.md`(与目录名一致,不再是固定的 SKILL.md)。 */
91
+ export const bundleDocName = (name) => `${name}.md`;
92
+ export const INDEX_VERSION = 1;
93
+ // ── 段名约束提示 ───────────────────────────────────────────────────────────
94
+ // 场景/子目录段名约束(§4 对照表 + §5.3):任意 Unicode,但必须对文件系统安全。
95
+ // 谓词收敛到 `../paths.ts` —— 此前这里与 subagents / imports / skills 各写一套,
96
+ // 口径不一(这里漏了 Windows 保留设备名与控制字符,而那两样恰好是创建即失败/路径被截断)。
97
+ /**
98
+ * 段名非法时的统一说明。与 `paths.ts` 的谓词保持同步 —— 此前六处报错文案各写一份,
99
+ * 谓词收紧后它们会集体变成过时描述(用户按提示改了还是被拒)。
100
+ */
101
+ export const SEGMENT_RULE_HINT = `非空、≤${MAX_GROUP_SEGMENT_LENGTH} 字符、不含路径分隔符与 < > : " | ? *、不以 . 开头、首尾无空白、不是 Windows 保留设备名(CON/NUL 等)、不含控制字符`;
102
+ // ── 小工具 ─────────────────────────────────────────────────────────────────
103
+ /** UTF-8 字节长度(预算按字节算,不能按 UTF-16 码元)。 */
104
+ export const byteLen = (s) => Buffer.byteLength(s, 'utf8');
105
+ // ── 与索引层共用的路径常量与无依赖小工具(2026-09-19 从 memories/service.ts 下沉)──
106
+ // 放在这里而非 service.ts:index-io / snapshot 都要用,留在 service.ts 会让它们反向引值,
107
+ // 形成运行时循环依赖。
108
+ /** 单个路径段(场景名或子目录名)是否合法。放宽后 `办公` / `日常` 均通过。 */
109
+ export function isValidGroupSegment(segment) {
110
+ return isValidSegment(segment, MAX_GROUP_SEGMENT_LENGTH);
111
+ }
112
+ /** group/场景路径可多层(a/b/c),每段必须合法;_shared 作为保留场景名放行。 */
113
+ export function isValidGroupPath(group) {
114
+ if (typeof group !== 'string' || group === '' || group.startsWith('/') || group.endsWith('/'))
115
+ return false;
116
+ return group.split('/').every(isValidGroupSegment);
117
+ }
118
+ export const message = (e) => String((e && e.message) || e);
119
+ /** 业务校验失败的统一返回形态(与 skills core 一致)。ops 分域后各域都要用,故放在这一层。 */
120
+ export const fail = (code, error, params) => (params ? { ok: false, error, code, params } : { ok: false, error, code });
121
+ /**
122
+ * 记忆索引文件名 / 记忆回收站目录名(hub 根下)。
123
+ * 域叫「记忆」(工具 `memory_manager_*`、界面「记忆」页),所以按域命名 ——
124
+ * 旧名 `rules-index.json` / `rules-trash/` 由 hub 的启动迁移搬过来(见 hub.ts)。
125
+ */
126
+ export const MEMORIES_INDEX_FILE = 'memories-index.json';
127
+ /** 记忆回收站目录名(hub 根下)。ops 分域后 trash 域要用,故与索引文件名一起放在这一层。 */
128
+ export const MEMORIES_TRASH_DIR = 'memories-trash';
@@ -0,0 +1,330 @@
1
+ // 规则/记忆域的**索引 IO**(2026-09-19 从 memories/service.ts 抽出)。
2
+ //
3
+ // memories-index.json 的解析、校验、原子落盘与损坏隔离都在这里。索引是「启停/排序/标签/
4
+ // 启用场景集合」的真源(记忆文件只负责正文),所以这一层必须 fail-closed:解析不出来就隔离
5
+ // 坏文件并回退默认索引,绝不让半截数据流进界面。
6
+ //
7
+ // 对 service.ts 只做 type-only 引用(`import type`),避免与它形成运行时循环依赖。
8
+ import { randomUUID } from 'node:crypto';
9
+ import { readFileSync, renameSync, statSync } from 'node:fs';
10
+ import { lstat, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
11
+ import { basename, dirname, join, resolve } from 'node:path';
12
+ import { renameWithRetry } from '../skills/core.js';
13
+ import { normalizePresetId } from '../prompts/preset-id.js';
14
+ import { normalizeArchive } from './archive.js';
15
+ import { DEFAULT_GROUP_ORDER, GLOBAL_SCENE, GLOBAL_SCENE_LABEL, INDEX_VERSION, isValidGroupPath, MEMORIES_INDEX_FILE } from './constants.js';
16
+ import { normalizeActive } from './projection.js';
17
+ /**
18
+ * 同目录临时文件 + rename 原子写(临时名 `.xxx.dsh-rules-<uuid>.tmp`)。
19
+ * rename 走 `renameWithRetry`:Windows 上杀软/索引器会短暂占住目标文件报
20
+ * EPERM/EACCES/EBUSY(用户实测:进入/退出模式时 memories-index.json 被 rename 撞上,
21
+ * 运行时已切换但状态落盘失败,界面开关停在旧状态、还得再点一次)。这种占用是
22
+ * 瞬时的,重试几轮就能过去;全失败才清理临时文件并把错误抛出。
23
+ */
24
+ export async function writeFileAtomically(path, content) {
25
+ const temp = join(dirname(path), `.${basename(path)}.dsh-rules-${randomUUID()}.tmp`);
26
+ try {
27
+ await writeFile(temp, content, 'utf8');
28
+ await renameWithRetry(temp, path);
29
+ }
30
+ catch (error) {
31
+ await rm(temp, { force: true }).catch(() => undefined);
32
+ throw error;
33
+ }
34
+ }
35
+ /** 二进制版原子写(附件用):临时文件 + rename(同样带瞬时占用重试),失败清理临时文件。 */
36
+ export async function writeFileAtomicBinary(path, data) {
37
+ const temp = join(dirname(path), `.${basename(path)}.dsh-rules-${randomUUID()}.tmp`);
38
+ try {
39
+ await writeFile(temp, data);
40
+ await renameWithRetry(temp, path);
41
+ }
42
+ catch (error) {
43
+ await rm(temp, { force: true }).catch(() => undefined);
44
+ throw error;
45
+ }
46
+ }
47
+ // ── 递归发现 ───────────────────────────────────────────────────────────────
48
+ export const defaultIndex = () => ({ version: INDEX_VERSION, rules: {}, groups: {}, scenes: {}, active: null, archives: {}, mode: { scene: null, snapshot: null } });
49
+ /** 场景镜像条目归一化:只保留已知字段,非法值丢弃(容忍脏数据)。 */
50
+ export function parseSceneEntry(raw) {
51
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
52
+ return null;
53
+ const obj = raw;
54
+ const out = {};
55
+ if (typeof obj.label === 'string' && obj.label.trim() !== '')
56
+ out.label = obj.label;
57
+ if (typeof obj.description === 'string' && obj.description !== '')
58
+ out.description = obj.description;
59
+ if (typeof obj.prompt === 'string') {
60
+ const prompt = normalizeScenePromptId(obj.prompt);
61
+ if (prompt)
62
+ out.prompt = prompt;
63
+ }
64
+ if (typeof obj.order === 'number' && Number.isFinite(obj.order))
65
+ out.order = obj.order;
66
+ if (typeof obj.createdAt === 'string' && obj.createdAt !== '')
67
+ out.createdAt = obj.createdAt;
68
+ // 场景锁定(v0.8):读盘必须原样保留 —— 这里曾只重建已知字段,锁了也会被剥成未锁。
69
+ if (obj.locked === true)
70
+ out.locked = true;
71
+ return out;
72
+ }
73
+ export function parseScenes(raw) {
74
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
75
+ return {};
76
+ const out = {};
77
+ for (const [name, value] of Object.entries(raw)) {
78
+ if (!isValidGroupPath(name))
79
+ continue;
80
+ const entry = parseSceneEntry(value);
81
+ if (entry)
82
+ out[name] = entry;
83
+ }
84
+ return out;
85
+ }
86
+ /** 档案切片归一化:未知形态 → 空对象(容忍脏数据,与 §9.3 同哲学)。 */
87
+ export function parseArchives(raw) {
88
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
89
+ return {};
90
+ const out = {};
91
+ for (const [name, value] of Object.entries(raw)) {
92
+ const archive = normalizeArchive(value);
93
+ if (Object.keys(archive).length)
94
+ out[name] = archive;
95
+ }
96
+ return out;
97
+ }
98
+ export function parseModeState(raw) {
99
+ const obj = (raw && typeof raw === 'object' ? raw : {});
100
+ const scene = typeof obj.scene === 'string' && obj.scene.trim() !== '' ? obj.scene : null;
101
+ const snapshotRaw = (obj.snapshot && typeof obj.snapshot === 'object' ? obj.snapshot : {});
102
+ const toFlagMap = (v) => {
103
+ const out = {};
104
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
105
+ for (const [k, b] of Object.entries(v))
106
+ if (typeof b === 'boolean')
107
+ out[k] = b;
108
+ }
109
+ return out;
110
+ };
111
+ // v2 快照:mcp = 停用表原文(serverName → ['*'] / 工具名);skills = 启停布尔表。
112
+ const mcp = {};
113
+ const mcpRaw = snapshotRaw.mcp;
114
+ if (mcpRaw && typeof mcpRaw === 'object' && !Array.isArray(mcpRaw)) {
115
+ for (const [server, list] of Object.entries(mcpRaw)) {
116
+ if (Array.isArray(list))
117
+ mcp[server] = list.map((x) => String(x));
118
+ }
119
+ }
120
+ else if (snapshotRaw.tools && typeof snapshotRaw.tools === 'object' && !Array.isArray(snapshotRaw.tools)) {
121
+ // v1 快照兼容(升级前数据):tools = 布尔启停表(key = `<server>/<tool>`,false = 停用)
122
+ // → 折算成 v2 的停用名单;否则退出模式会把「快照启停」错误还原成「全部启用」。
123
+ for (const [key, on] of Object.entries(snapshotRaw.tools)) {
124
+ if (on !== false)
125
+ continue;
126
+ const i = key.indexOf('/');
127
+ if (i <= 0 || i === key.length - 1)
128
+ continue;
129
+ const server = key.slice(0, i);
130
+ const tool = key.slice(i + 1);
131
+ const list = mcp[server] || (mcp[server] = []);
132
+ if (list.indexOf(tool) < 0)
133
+ list.push(tool);
134
+ }
135
+ }
136
+ // v2 快照的**服务器级 / 来源级**名单、v0.8 的子智能体名单、v0.9.1 的人设全量映射
137
+ // 必须原样透传 —— 这里曾把它们剥掉,结果退出模式时两张恢复名单全是空的:服务器级 MCP 与
138
+ // 技能来源永远不回滚(用户报的「MCP 不复原」「技能目录不回退」),只有工具级 / 技能级能还原。
139
+ const mcpServers = (Array.isArray(snapshotRaw.mcpServers) ? snapshotRaw.mcpServers : [])
140
+ .filter((x) => !!x && typeof x === 'object')
141
+ .map((x) => ({ id: String(x.id || ''), level: String(x.level || ''), disabled: x.disabled === true }))
142
+ .filter((x) => x.id !== '' && (x.level === 'global' || x.level === 'project'));
143
+ const skillSources = (Array.isArray(snapshotRaw.skillSources) ? snapshotRaw.skillSources : [])
144
+ .filter((x) => !!x && typeof x === 'object')
145
+ .map((x) => ({ root: String(x.root || ''), enabled: x.enabled === true }))
146
+ .filter((x) => x.root !== '');
147
+ const subagents = (Array.isArray(snapshotRaw.subagents) ? snapshotRaw.subagents : []).map((x) => String(x)).filter(Boolean);
148
+ // 同 subagents 一组:进入时被档案**关掉**的人设(退出要重新打开)。漏掉它 = 用户实测的
149
+ // 「进场景关掉了,退出却没开回来」——快照落盘后读回来就只剩「被启用」那一半名单。
150
+ const subagentsOn = (Array.isArray(snapshotRaw.subagentsOn) ? snapshotRaw.subagentsOn : []).map((x) => String(x)).filter(Boolean);
151
+ // v0.9.1 的人设**全量**开关映射(名字 → 进场景时是否开着):同样必须原样透传 ——
152
+ // 剥掉它,退出模式就只能退回上面两个部分名单,「场景里手动开过的人设」不再被还原。
153
+ const subagentsAll = toFlagMap(snapshotRaw.subagentsAll);
154
+ // v0.8.1 的场景备注恢复名单:**必须原样透传**——这里漏掉它,退出模式时备注永不回退
155
+ //(与历史上 mcpServers / skillSources 被剥掉是同一类 bug)。
156
+ const mcpNotes = (Array.isArray(snapshotRaw.mcpNotes) ? snapshotRaw.mcpNotes : [])
157
+ .filter((x) => !!x && typeof x === 'object')
158
+ .map((x) => ({ id: String(x.id || ''), note: typeof x.note === 'string' ? x.note : null }))
159
+ .filter((x) => x.id !== '');
160
+ return {
161
+ scene,
162
+ snapshot: scene
163
+ ? {
164
+ mcp,
165
+ skills: toFlagMap(snapshotRaw.skills),
166
+ ...(mcpServers.length ? { mcpServers } : {}),
167
+ ...(skillSources.length ? { skillSources } : {}),
168
+ ...(subagents.length ? { subagents } : {}),
169
+ ...(subagentsOn.length ? { subagentsOn } : {}),
170
+ ...(Object.keys(subagentsAll).length ? { subagentsAll } : {}),
171
+ ...(mcpNotes.length ? { mcpNotes } : {}),
172
+ }
173
+ : null,
174
+ };
175
+ }
176
+ /** 解析 memories-index.json 原文;任何异常/版本不符 → 默认索引(容忍缺失,§9.3)。 */
177
+ export function parseIndex(raw) {
178
+ const parsed = JSON.parse(raw);
179
+ if (!parsed || parsed.version !== INDEX_VERSION || typeof parsed.rules !== 'object' || parsed.rules === null)
180
+ throw new Error('bad index');
181
+ return {
182
+ version: INDEX_VERSION,
183
+ rules: (parsed.rules || {}),
184
+ groups: (parsed.groups || {}),
185
+ scenes: parseScenes(parsed.scenes),
186
+ active: normalizeActive(parsed.active),
187
+ archives: parseArchives(parsed.archives),
188
+ mode: parseModeState(parsed.mode),
189
+ };
190
+ }
191
+ // 索引损坏的进程级记录:stateDir → 损坏原文件被改名后的落点。
192
+ //
193
+ // 为什么需要它:原来 readIndex 的 catch 把「文件不存在」「读失败」「解析失败」三者合并成
194
+ // 「返回默认索引」,而索引是启停/排序/标签/场景描述/归档/模式快照的**唯一**真相源 ——
195
+ // 于是任意一次写操作都会把这份空索引落盘,一次解析失败就静默换掉用户全部配置,且没有
196
+ // 任何提示;模式快照丢了之后,运行时 MCP/技能开关再也无法回滚。
197
+ // 触发不限于文件损坏:parseIndex 对字段类型严格,跨版本降级读到新版结构同样会抛。
198
+ //
199
+ // 现在的口径分开处理两条路:
200
+ // - 读侧降级 —— 注入路径(readIndexSync)不能抛,返回默认索引,界面表现为「记忆都没了」,
201
+ // 是可见症状而不是静默改写;
202
+ // - 写侧 fail-closed —— 拒绝落盘并把原因回给界面,用户的数据一个字节不动。
203
+ export const corruptIndexes = new Map();
204
+ /** 把损坏的索引改名留存并记录该 stateDir 已损坏(幂等,只记一次)。 */
205
+ export function quarantineIndex(stateDir) {
206
+ const key = resolve(stateDir);
207
+ if (corruptIndexes.has(key))
208
+ return;
209
+ const abs = join(stateDir, MEMORIES_INDEX_FILE);
210
+ const kept = `${abs}.corrupt-${Date.now()}`;
211
+ try {
212
+ renameSync(abs, kept);
213
+ corruptIndexes.set(key, kept);
214
+ }
215
+ catch {
216
+ // 改名失败(被占用/权限)不改判定:仍然标记损坏并拒绝写,只是没有留存副本。
217
+ corruptIndexes.set(key, abs);
218
+ }
219
+ console.warn(`[dsh-plugin-tool-management] 记忆索引解析失败,已停止写入以免覆盖你的数据。`
220
+ + `损坏的原文件已留存为 ${kept} —— 请检查它(或删除后重启 DSH)再继续操作。`);
221
+ }
222
+ /** 该 stateDir 的索引是否已判定损坏(写侧拒绝落盘,读侧按「全部未启用」处理)。 */
223
+ export function isIndexQuarantined(stateDir) {
224
+ return corruptIndexes.has(resolve(stateDir));
225
+ }
226
+ /** 解析索引;失败即隔离该文件并返回默认索引(写侧由 writeIndex 拦截)。 */
227
+ export function parseOrQuarantine(stateDir, raw) {
228
+ try {
229
+ return parseIndex(raw);
230
+ }
231
+ catch {
232
+ quarantineIndex(stateDir);
233
+ return defaultIndex();
234
+ }
235
+ }
236
+ export async function readIndex(stateDir) {
237
+ let raw;
238
+ try {
239
+ raw = await readFile(join(stateDir, MEMORIES_INDEX_FILE), 'utf8');
240
+ }
241
+ catch {
242
+ // 文件不存在 = 全新用户;其余 IO 错误也按「没有索引」处理(与既有语义一致)。
243
+ return defaultIndex();
244
+ }
245
+ return parseOrQuarantine(stateDir, raw);
246
+ }
247
+ /**
248
+ * 同步读索引:注入文本的渲染路径不能 await(见文件内「两相扫描」注释)。
249
+ *
250
+ * 带 `mtimeMs:size` 指纹缓存:一次装配里这个函数会被调到 2 次以上,每个模型 step 都重读
251
+ * 重解一遍整份索引没有意义。指纹**必须**在 —— 索引可能被别的 DSH 实例或用户手工改动,
252
+ * 只按自身写入失效会让插件一直用旧值(表现为「记忆停不掉」)。stat 失败(文件被删 / 被
253
+ * 隔离改名)即丢缓存。同款手法见 `readPresetTextSync` 与 subagents 的 enabledStamp。
254
+ *
255
+ * ⚠️ 返回的可能是**缓存实例**,调用方只许读;写索引一律走异步 `readIndex`(它每次重新解析,
256
+ * 拿到新对象)。当前两个调用方(`sceneMemory` / `resolveScenePreset`)及其下游
257
+ * (`signatureOfIndex` / `resolveActiveScenes` / `sceneHeader` / `compareSceneBuckets` /
258
+ * `sceneOrderOf`)都已确认只读 —— 新增调用方请保持这条。
259
+ */
260
+ export const indexSyncCache = new Map();
261
+ export function readIndexSync(stateDir) {
262
+ const file = join(stateDir, MEMORIES_INDEX_FILE);
263
+ let key;
264
+ try {
265
+ const st = statSync(file);
266
+ key = `${st.mtimeMs}:${st.size}`;
267
+ }
268
+ catch {
269
+ // 文件不存在 = 全新用户;其余 IO 错误也按「没有索引」处理(与 readIndex 一致)。
270
+ indexSyncCache.delete(stateDir);
271
+ return defaultIndex();
272
+ }
273
+ const cached = indexSyncCache.get(stateDir);
274
+ if (cached && cached.key === key)
275
+ return cached.value;
276
+ const raw = readFileIfExistsSync(file);
277
+ const value = raw === null ? defaultIndex() : parseOrQuarantine(stateDir, raw);
278
+ indexSyncCache.set(stateDir, { key, value });
279
+ return value;
280
+ }
281
+ export async function writeIndex(stateDir, index) {
282
+ // 索引损坏时拒绝写入:这一次写会把空索引落盘,等于用一次解析失败换掉用户全部配置。
283
+ const kept = corruptIndexes.get(resolve(stateDir));
284
+ if (kept !== undefined) {
285
+ throw new Error(`记忆索引文件损坏,已拒绝写入以免覆盖你的数据(原文件留存为 ${kept})。`
286
+ + `请检查或删除该损坏文件后重启 DSH,再重试本次操作。`);
287
+ }
288
+ await mkdir(stateDir, { recursive: true });
289
+ await writeFileAtomically(join(stateDir, MEMORIES_INDEX_FILE), JSON.stringify(index, null, 2));
290
+ }
291
+ // ── 场景记录(索引内,`memories-index.json` 的 scenes 切片)+ 旧布局迁移 ────────
292
+ /** 索引条目 → 场景记录(供 UI 直接渲染)。 */
293
+ export function sceneRecordOf(name, entry) {
294
+ const e = entry || {};
295
+ return {
296
+ name,
297
+ ...(name === GLOBAL_SCENE ? { label: e.label || GLOBAL_SCENE_LABEL } : (e.label ? { label: e.label } : {})),
298
+ ...(e.description ? { description: e.description } : {}),
299
+ ...(e.prompt ? { prompt: e.prompt } : {}),
300
+ order: e.order ?? (name === GLOBAL_SCENE ? 0 : DEFAULT_GROUP_ORDER),
301
+ ...(e.createdAt ? { createdAt: e.createdAt } : {}),
302
+ };
303
+ }
304
+ /** 提示词预设 id 的口径与提示词预设服务**同源**(用户裁定:id 什么都能写)。 */
305
+ /** 校验场景要绑定的提示词预设 id;返回 `''` 表示解绑,`null` 表示非法。 */
306
+ export function normalizeScenePromptId(value) {
307
+ const id = String(value ?? '').trim();
308
+ if (id === '')
309
+ return '';
310
+ const result = normalizePresetId(id);
311
+ return result.ok ? result.id : null;
312
+ }
313
+ /** 路径是否存在(不跟随符号链接;用于迁移前置判断)。 */
314
+ export async function pathExists(path) {
315
+ try {
316
+ await lstat(path);
317
+ return true;
318
+ }
319
+ catch {
320
+ return false;
321
+ }
322
+ }
323
+ export function readFileIfExistsSync(path) {
324
+ try {
325
+ return readFileSync(path, 'utf8');
326
+ }
327
+ catch {
328
+ return null;
329
+ }
330
+ }