@modusensus/dsh-mneme 0.8.15 → 0.9.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 (100) hide show
  1. package/README.md +21 -3
  2. package/assets/icon.svg +2 -2
  3. package/bin/cli.mjs +4 -2
  4. package/docs/CONFIGURATION.md +16 -3
  5. package/docs/CONTINUITY.md +18 -3
  6. package/docs/DISTILL.md +162 -0
  7. package/docs/STORAGE.md +11 -2
  8. package/docs/WORKFLOWS.md +299 -0
  9. package/lib/api-standalone.js +22 -39
  10. package/lib/api.js +294 -10
  11. package/lib/candidates.js +454 -0
  12. package/lib/capabilities.js +819 -0
  13. package/lib/client.js +2767 -337
  14. package/lib/config.js +99 -6
  15. package/lib/continuity.js +35 -6
  16. package/lib/dream/decisions.js +37 -1
  17. package/lib/dream/sleep.js +10 -2
  18. package/lib/dream.js +7 -1
  19. package/lib/entities/extractor.js +4 -0
  20. package/lib/hot-memory.js +62 -0
  21. package/lib/index.js +72 -18
  22. package/lib/inject.js +33 -64
  23. package/lib/maintenance.js +25 -5
  24. package/lib/pipeline.js +534 -0
  25. package/lib/recall-stats.js +22 -7
  26. package/lib/reclaim-http.js +150 -0
  27. package/lib/safe-slice.js +18 -0
  28. package/lib/scope.js +16 -4
  29. package/lib/serve.js +1 -1
  30. package/lib/service.js +83 -17
  31. package/lib/session-usage.js +350 -0
  32. package/lib/settings.js +19 -2
  33. package/lib/store.js +251 -10
  34. package/lib/summarize.js +239 -60
  35. package/lib/tools.js +11 -2
  36. package/package.json +1 -1
  37. package/scripts/publish-gate.mjs +197 -0
  38. package/src/api-standalone.js +22 -39
  39. package/src/api.js +294 -10
  40. package/src/candidates.js +454 -0
  41. package/src/capabilities.js +819 -0
  42. package/src/config.js +99 -6
  43. package/src/continuity.js +35 -6
  44. package/src/dream/decisions.js +37 -1
  45. package/src/dream/sleep.js +10 -2
  46. package/src/dream.js +7 -1
  47. package/src/entities/extractor.js +4 -0
  48. package/src/hot-memory.js +62 -0
  49. package/src/index.js +72 -18
  50. package/src/inject.js +33 -64
  51. package/src/maintenance.js +25 -5
  52. package/src/pipeline.js +534 -0
  53. package/src/recall-stats.js +22 -7
  54. package/src/reclaim-http.js +150 -0
  55. package/src/safe-slice.js +18 -0
  56. package/src/scope.js +16 -4
  57. package/src/serve.js +1 -1
  58. package/src/service.js +83 -17
  59. package/src/session-usage.js +350 -0
  60. package/src/settings.js +19 -2
  61. package/src/store.js +251 -10
  62. package/src/summarize.js +239 -60
  63. package/src/tools.js +11 -2
  64. package/test/api-routes-gaps.test.js +681 -0
  65. package/test/api.test.js +171 -8
  66. package/test/candidates.test.js +345 -0
  67. package/test/capabilities.test.js +577 -0
  68. package/test/client.test.js +715 -4
  69. package/test/continuity.test.js +84 -0
  70. package/test/contrast.test.js +314 -0
  71. package/test/dream-merge-guardrail.test.js +219 -0
  72. package/test/embed-ready-poll.test.js +94 -0
  73. package/test/entities.test.js +13 -2
  74. package/test/failure-query.test.js +147 -0
  75. package/test/failure-retention.test.js +105 -0
  76. package/test/graph-api.test.js +36 -2
  77. package/test/hot-memory.test.js +67 -1
  78. package/test/inject-recall.test.js +68 -1
  79. package/test/inject.test.js +23 -0
  80. package/test/llm-audit.test.js +190 -1
  81. package/test/maintenance.test.js +112 -2
  82. package/test/mint-source.test.js +202 -0
  83. package/test/pipeline.test.js +505 -0
  84. package/test/placement.test.js +1 -1
  85. package/test/pre-inject-gate.test.js +10 -0
  86. package/test/publish-gate.test.js +272 -0
  87. package/test/recall-retention.test.js +128 -0
  88. package/test/recall-stats.test.js +89 -4
  89. package/test/reclaim-http.test.js +209 -0
  90. package/test/release-workflow.test.js +77 -0
  91. package/test/scope-distill-stamp.test.js +135 -0
  92. package/test/serve.test.js +1 -1
  93. package/test/service.test.js +7 -2
  94. package/test/session-usage.test.js +308 -0
  95. package/test/settings.test.js +8 -0
  96. package/test/store-write-path-columns.test.js +191 -0
  97. package/test/store.test.js +108 -0
  98. package/test/summarize.test.js +350 -0
  99. package/test/supersede-state-tracking.test.js +4 -4
  100. package/test/tools.test.js +16 -1
@@ -0,0 +1,819 @@
1
+ /**
2
+ * M.O.(Modus Operandi · 手法)能力清单的**读侧**。
3
+ *
4
+ * 能力清单 = 「这台机器上、这个项目里,agent 能拿到哪些外部增强」的一份可复核投影。
5
+ * 三类:技能(注入文本指令)/MCP(注册可执行工具)/插件(加载单元)。三者在 DSH 里
6
+ * 同源——都是 Cordis 插件,只往不同 seam 注入——但**可见性完全不同**,这份文件的核心
7
+ * 职责就是把这个差异如实说出来,而不是抹平成一张「都有」的清单。
8
+ *
9
+ * 三条纪律抄自 src/pipeline.js(理由见那边注释,这里只重申最要命的一条):
10
+ * ① **读到什么说什么**:本清单里只有技能是「读到即事实」;插件的 bundles 是**声明**、
11
+ * MCP 的 catalog 是**可选目录**,两者都不等于「已生效」。把三者都说成「可用」,
12
+ * 就是在造 api.js:1661 警告过的那类「看起来一切正常」的假面。
13
+ * ② 来源随身带(origin):scope 是「从哪读出来的」,不是谁写的。
14
+ * ③ **绝不回退到进程 cwd**:拿不到项目根就如实标 project: null,别去读一个碰巧在
15
+ * 旁边的工作区——那会把「读不到」变成「读到别的地方」。
16
+ *
17
+ * 为什么手写 YAML 子集:本包运行时依赖只有 morphicons 一个,为读一个补丁清单引 YAML
18
+ * 解析器不划算(pipeline.js 拒绝 YAML 是同一个理由)。而 cordis.patch.yml 里我们真正
19
+ * 需要的只有顶层数组的 `- id:` / `name:` / `disabled:` 三个标量,**不解析嵌套 config**
20
+ * ——那是用户的任意结构,猜错了比不读更糟。所以下面只认这三个键,其余一律不碰。
21
+ */
22
+ import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
23
+ import { join, basename } from "node:path";
24
+ import { homedir } from "node:os";
25
+ import { collectUsage, summarizeUsage, resolveSessionDir, STALE_AFTER_DAYS } from "./session-usage.js";
26
+
27
+ /**
28
+ * 三类能力的**带命名空间 kind**。顺序即清单展示顺序,也是实现难度顺序(技能最易、
29
+ * 插件次之、MCP 最需运行时)。
30
+ *
31
+ * 为什么带 `capability:` 前缀:裸的「plugin」是 DSH 消息来源的保留 kind,V4 写入准入
32
+ * 会整条拒绝,仓库用静态 grep 锁死了全 src/ 不许出现那个字面量(issue #326,见
33
+ * `test/llm-message-source.test.js:190`)。用同一个前缀把三类统一下来,输出自洽。
34
+ */
35
+ export const CAPABILITY_KINDS = ["capability:skill", "capability:mcp", "capability:plugin"];
36
+
37
+ /**
38
+ * 可见性四态。**这是本清单最该被读懂的一列**:
39
+ * read — 文件层读到即事实(技能条目的存在与元数据)
40
+ * declared — 读到的是**声明**,未验证生效(插件的 bundles 列表、MCP 的连接项)
41
+ * catalog — 读到的是**可选目录**,不是已连接(MCP 的远程 connector 清单)
42
+ * runtime-only — 只有宿主运行时知道,文件层拿不到(插件的加载结果、MCP 的连接状态)
43
+ * absent — 根不存在(不是错,是实话)
44
+ */
45
+ export const VISIBILITIES = ["read", "declared", "catalog", "runtime-only", "absent"];
46
+
47
+ /** 单根最多收多少条技能。目录被塞满时报错,不要把它变成一次磁盘全扫。 */
48
+ const MAX_SKILLS_PER_ROOT = 500;
49
+
50
+ /**
51
+ * 技能根(顺序即优先级,数值小的赢)。与 `@deepseek-ai/dsh-skill-filesystem` 的
52
+ * `roots()` 保持同一套 rank——**抄它的数不是为了好看,是因为不抄就会和宿主的实际
53
+ * 优先级不一致**,那时清单会指着一个实际不生效的技能说「这个是生效的」。
54
+ *
55
+ * **七个 rank,不是四个。** 宿主 `roots()` 还会给出 `custom`(用户配置的自定义目录)
56
+ * 与 `bundled`(插件包自带),另有 `runtime`(插件在运行时注册的技能,250)无对应目录。
57
+ * 本模块实现其中五个**有目录的**(四个默认根 + custom),bundled/runtime 的情况见
58
+ * `skillRoots()` 的说明——它们要么需要宿主才知道目录,要么根本没有文件。
59
+ */
60
+ export const SKILL_RANKS = {
61
+ projectDsh: 100,
62
+ projectAgents: 200,
63
+ runtime: 250,
64
+ custom: 300,
65
+ userDsh: 400,
66
+ userAgents: 500,
67
+ bundled: 600
68
+ };
69
+
70
+ /**
71
+ * 解析运行环境。全部可注入——测试要能造临时目录,不该去读真机器的家目录。
72
+ *
73
+ * 环境变量取法对齐宿主:`DSH_HOME` / `DSH_AGENTS_HOME` / `DSH_PROFILE_DIR` 都是
74
+ * DSH 自己就在用的(本机实测三者均存在),所以读它们不是猜。
75
+ */
76
+ export function resolveContext({
77
+ projectRoot = null,
78
+ dshHome = process.env.DSH_HOME || join(homedir(), ".dsh"),
79
+ agentsHome = process.env.DSH_AGENTS_HOME || join(homedir(), ".agents"),
80
+ profileDir = process.env.DSH_PROFILE_DIR || null,
81
+ customSkillDirs = []
82
+ } = {}) {
83
+ const root = typeof projectRoot === "string" ? projectRoot.trim() : "";
84
+ return {
85
+ projectRoot: root === "" ? null : root,
86
+ dshHome: String(dshHome),
87
+ agentsHome: String(agentsHome),
88
+ profileDir: profileDir ? String(profileDir) : null,
89
+ // 自定义技能目录由用户/宿主配置给出;本模块没有读取那处配置的途径(它在宿主的
90
+ // config 里),所以**由调用方注入**——不猜、不去别处翻。
91
+ customSkillDirs: Array.isArray(customSkillDirs) ? customSkillDirs.filter((d) => typeof d === "string" && d.trim() !== "") : []
92
+ };
93
+ }
94
+
95
+ /**
96
+ * 技能根清单。项目级两个根要有项目根才给——**没有项目根时返回的数组里就没有它们**,
97
+ * 而不是拿进程 cwd 顶替(纪律 ③)。
98
+ *
99
+ * 覆盖宿主 `roots()` 里**有目录的**五类:project-dsh / project-agents / custom /
100
+ * user-dsh / user-agents。另两类**故意不在返回值里**,因为它们的目录本模块拿不到或不存在:
101
+ * · `runtime`(rank 250)——插件在运行时把技能注册进 `ctx.skills`,**没有目录**,
102
+ * 文件层无从发现;
103
+ * · `bundled`(rank 600)——来自插件包自带的 assets,目录由宿主按包解析,
104
+ * 除非调用方显式给出,否则文件层不知道它在哪里。
105
+ * 这两类的缺席是**如实的**:清单的 sources 里看不到它们,就说明本模块没读到,
106
+ * 而不是「它们不存在」——`runtimeOnly` 那段会把这一点写出来。
107
+ */
108
+ export function skillRoots({ projectRoot = null, dshHome, agentsHome, customSkillDirs = [] } = {}) {
109
+ const roots = [];
110
+ if (projectRoot) {
111
+ roots.push({
112
+ dir: join(projectRoot, ".dsh", "skills"),
113
+ source: "project-dsh",
114
+ rank: SKILL_RANKS.projectDsh,
115
+ scope: "project",
116
+ label: basename(projectRoot) || projectRoot
117
+ });
118
+ roots.push({
119
+ dir: join(projectRoot, ".agents", "skills"),
120
+ source: "project-agents",
121
+ rank: SKILL_RANKS.projectAgents,
122
+ scope: "project",
123
+ label: basename(projectRoot) || projectRoot
124
+ });
125
+ }
126
+ for (const dir of customSkillDirs) {
127
+ roots.push({ dir: String(dir), source: "custom", rank: SKILL_RANKS.custom, scope: "custom", label: null });
128
+ }
129
+ roots.push({ dir: join(dshHome, "skills"), source: "user-dsh", rank: SKILL_RANKS.userDsh, scope: "user", label: null });
130
+ roots.push({
131
+ dir: join(agentsHome, "skills"),
132
+ source: "user-agents",
133
+ rank: SKILL_RANKS.userAgents,
134
+ scope: "user",
135
+ label: null
136
+ });
137
+ return roots.sort((a, b) => a.rank - b.rank);
138
+ }
139
+
140
+ /**
141
+ * 技能名是否合法:kebab-case。抄宿主 `@deepseek-ai/dsh-skill` 的 `SKILL_NAME`——
142
+ * 清单里出现一个宿主根本不会加载的名字,比不显示它更坏。
143
+ */
144
+ const SKILL_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
145
+
146
+ /** 宿主对 description 的展示上限。超了会被截断,尾部触发词直接消失(实测过)。 */
147
+ const DESCRIPTION_DISPLAY_LIMIT = 497;
148
+
149
+ /**
150
+ * 轻量 frontmatter 解析:取 `---` 包围块里的**顶层**键值。
151
+ *
152
+ * 支持三种形态,覆盖实测遇到的绝大多数 SKILL.md:
153
+ * ① 单行标量 `name: foo`
154
+ * ② 块标量 `description: |` + 后续更深缩进的行(多行原样拼接)
155
+ * ③ 嵌套映射/数组 `metadata:` 之后更深缩进的行——**只记「有这块」,不进去**
156
+ *
157
+ * 不做完整 YAML:本包运行时依赖只有 morphicons,为读 frontmatter 引解析器不划算。
158
+ * 但②③必须分开处理——最初把 `|` 当成「不解析就丢弃」,结果 55 个技能被误报成
159
+ * 「没有 description」(实为块标量)。**清单里一条假警报会让人去改一个没坏的技能**,
160
+ * 所以块标量要真读出来,嵌套结构才放弃。
161
+ */
162
+ export function parseFrontmatter(text) {
163
+ const out = { fields: {}, problems: [] };
164
+ if (typeof text !== "string" || text === "") {
165
+ out.problems.push("文件为空");
166
+ return out;
167
+ }
168
+ const normalized = text.replace(/^\uFEFF/, "");
169
+ if (!normalized.startsWith("---")) {
170
+ out.problems.push("没有 frontmatter(文件未以 --- 开头)");
171
+ return out;
172
+ }
173
+ const end = normalized.indexOf("\n---", 3);
174
+ if (end === -1) {
175
+ out.problems.push("frontmatter 未闭合(找不到结尾的 ---)");
176
+ return out;
177
+ }
178
+ const block = normalized.slice(normalized.indexOf("\n", 3) + 1, end);
179
+
180
+ /** 记一个标量值;引号包裹的去掉引号。 */
181
+ const put = (key, raw) => {
182
+ let value = raw.trim();
183
+ if (
184
+ (value.startsWith('"') && value.endsWith('"') && value.length > 1) ||
185
+ (value.startsWith("'") && value.endsWith("'") && value.length > 1)
186
+ ) {
187
+ value = value.slice(1, -1);
188
+ }
189
+ out.fields[key] = value;
190
+ };
191
+
192
+ const lines = block.split(/\r?\n/);
193
+ for (let i = 0; i < lines.length; i++) {
194
+ const line = lines[i];
195
+ if (line.trim() === "" || line.trimStart().startsWith("#")) continue;
196
+ const indent = line.length - line.trimStart().length;
197
+ // 顶层键的缩进必须为 0——更深的是上一个键的续行/子结构,已在下面消费掉。
198
+ if (indent > 0) continue;
199
+ const m = /^([A-Za-z0-9_.-]+):\s*(.*)$/.exec(line.trim());
200
+ if (!m) continue;
201
+ const key = m[1];
202
+ const value = m[2].trim();
203
+
204
+ if (value === "|" || value === ">" || value === "|-" || value === ">-") {
205
+ // 块标量:收后续更深缩进的行,按原行的相对缩进拼回去。
206
+ const blockLines = [];
207
+ let j = i + 1;
208
+ let blockIndent = -1;
209
+ for (; j < lines.length; j++) {
210
+ const l = lines[j];
211
+ if (l.trim() === "") {
212
+ blockLines.push("");
213
+ continue;
214
+ }
215
+ const ci = l.length - l.trimStart().length;
216
+ if (ci === 0) break;
217
+ if (blockIndent === -1) blockIndent = ci;
218
+ blockLines.push(l.slice(Math.min(blockIndent, ci)));
219
+ }
220
+ i = j - 1;
221
+ // 折叠标量(>)按 YAML 规则把换行折成空格;字面标量(|)保留换行。
222
+ const joined = value.startsWith(">")
223
+ ? blockLines.join("\n").replace(/\n(?!\n)/g, " ").trim()
224
+ : blockLines.join("\n").replace(/\s+$/, "");
225
+ put(key, joined);
226
+ continue;
227
+ }
228
+
229
+ if (value === "") {
230
+ // 嵌套映射/数组:只记「有这个键」,不解析其内部结构。
231
+ out.fields[key] = "";
232
+ continue;
233
+ }
234
+
235
+ put(key, value);
236
+ }
237
+ return out;
238
+ }
239
+
240
+ /**
241
+ * 扫一个技能根(一层深:`<name>/SKILL.md` 或 `<name>.md`,与宿主一致)。
242
+ *
243
+ * **坏与警告必须分开**,这是本函数最容易做错的地方(第一版就做错了):
244
+ * · `broken` = 宿主**不会加载**它(没有 SKILL.md / 没有 name / 没有 description /
245
+ * 名字不是 kebab-case / 读不出来)。这些要从清单的技能列表里拿掉。
246
+ * · `warnings` = 宿主**照常加载**,只是有值得知道的事。**留在技能列表里**。
247
+ *
248
+ * 判据来自宿主行为,不是我的口味:
249
+ * - name 与目录名不一致 **不是错**——宿主以 frontmatter 的 `name` 为身份。实测
250
+ * `react-three-fiber/` 目录里声明的是 `threejs-scene-builder`,而技能目录里出现的
251
+ * 正是后者;`superpowers-*` 一整批同理。第一版把它当错,一次报案 20 例假警报——
252
+ * **一条假警报会让人去改一个没坏的技能**,比漏报更坏。
253
+ * - description 超过展示上限会被截断(尾部触发词直接看不见),但技能能用。
254
+ * 计为警告并报出长度,让人自己决定要不要缩。
255
+ */
256
+ export function scanSkillRoot(root, { source, rank, scope, label } = {}) {
257
+ // 键名用 `root`(扫的是哪个目录)。**下游一律读 `r.root`**——早期版本这里写成 `root`
258
+ // 而下游读 `r.dir`,结果 175 条技能的 `origin.dir` 全是 undefined,而这种错在
259
+ // 「看着有值」的样本里不会暴露(dir 为空不影响别的字段,只是位置丢了)。
260
+ const result = { root, source, rank, scope, label: label ?? null, present: false, skills: [], broken: [], warnings: [] };
261
+ if (!existsSync(root)) return result;
262
+ result.present = true;
263
+ let entries;
264
+ try {
265
+ entries = readdirSync(root, { withFileTypes: true });
266
+ } catch (error) {
267
+ result.broken.push({ file: null, detail: `目录读失败:${error.message}` });
268
+ return result;
269
+ }
270
+ if (entries.length > MAX_SKILLS_PER_ROOT) {
271
+ result.broken.push({ file: null, detail: `目录里有 ${entries.length} 项,超过上限 ${MAX_SKILLS_PER_ROOT}` });
272
+ return result;
273
+ }
274
+ for (const entry of entries) {
275
+ const entryName = entry.name;
276
+ if (entryName.startsWith(".")) continue;
277
+
278
+ // 符号链接要单独认:`Dirent.isDirectory()` 对 symlink 返回 **false**(它描述的是
279
+ // 链接自身,不是目标),所以只判断 isDirectory()/isFile() 会把链接进来的技能
280
+ // **静默丢掉**——实测本机某用户级技能根里就有一个链接进来的技能,8 个目录只扫出
281
+ // 7 个,且它既不在 skills 也不在 broken 里。宿主的扫描器
282
+ // (dsh-skill-filesystem/lib/index.js:409)正是显式处理 isSymbolicLink 的。
283
+ let isDir = entry.isDirectory();
284
+ let isMdFile = entry.isFile() && entryName.endsWith(".md");
285
+ let viaLink = false;
286
+ const full = join(root, entryName);
287
+ if (entry.isSymbolicLink()) {
288
+ viaLink = true;
289
+ try {
290
+ const st = statSync(full); // 跟随链接看目标
291
+ isDir = st.isDirectory();
292
+ isMdFile = st.isFile() && entryName.endsWith(".md");
293
+ } catch (error) {
294
+ // 悬空链接:目标不存在。**如实报**,不静默跳过。
295
+ result.broken.push({ file: entryName, detail: `符号链接目标不可达:${error.message}` });
296
+ continue;
297
+ }
298
+ }
299
+
300
+ let file = null;
301
+ let dirName = null;
302
+ if (isDir) {
303
+ file = join(root, entryName, "SKILL.md");
304
+ dirName = entryName;
305
+ } else if (isMdFile) {
306
+ file = join(root, entryName);
307
+ dirName = entryName.slice(0, -3);
308
+ } else continue;
309
+ if (!existsSync(file)) {
310
+ result.broken.push({ file: dirName, detail: viaLink ? "符号链接指向的目录里没有 SKILL.md" : "目录里没有 SKILL.md" });
311
+ continue;
312
+ }
313
+ let text;
314
+ try {
315
+ text = readFileSync(file, "utf8");
316
+ } catch (error) {
317
+ result.broken.push({ file: dirName, detail: `读失败:${error.message}` });
318
+ continue;
319
+ }
320
+ const fm = parseFrontmatter(text);
321
+ const declared = String(fm.fields.name ?? "").trim();
322
+ const description = String(fm.fields.description ?? "").trim();
323
+
324
+ // --- 真·不可加载:这些会让宿主跳过整个技能 ---
325
+ const fatal = [...fm.problems];
326
+ if (declared === "") fatal.push("frontmatter 里没有 name(宿主不会加载)");
327
+ else if (!SKILL_NAME_RE.test(declared)) fatal.push(`name 不是 kebab-case(${declared})`);
328
+ if (description === "") fatal.push("frontmatter 里没有 description(宿主不会加载)");
329
+ if (fatal.length) {
330
+ result.broken.push({ file: dirName, detail: fatal.join(";") });
331
+ continue;
332
+ }
333
+
334
+ // --- 警告:能加载,但有值得知道的事 ---
335
+ const warnings = [];
336
+ if (declared !== dirName) {
337
+ // 不是错,但要说清「以哪个名字调用」——否则按目录名去调会找不到。
338
+ warnings.push({
339
+ kind: "name-mismatch",
340
+ detail: `以 frontmatter 的 name「${declared}」调用;目录名是「${dirName}」`
341
+ });
342
+ }
343
+ if (description.length > DESCRIPTION_DISPLAY_LIMIT) {
344
+ warnings.push({
345
+ kind: "description-too-long",
346
+ detail: `description ${description.length} 字符,超过展示上限 ${DESCRIPTION_DISPLAY_LIMIT}——超出的尾部会被截断,写在后面的触发词看不到`
347
+ });
348
+ }
349
+
350
+ const entryOut = {
351
+ name: declared,
352
+ dirName,
353
+ description,
354
+ descriptionLength: description.length,
355
+ file,
356
+ // 链接进来的技能要标出来:文件其实在别处,改它等于改那边。
357
+ viaSymlink: viaLink,
358
+ // 技能是三类里唯一「读到即事实」的:文件在、必填字段齐,宿主就会加载它。
359
+ visibility: "read"
360
+ };
361
+ if (warnings.length) entryOut.warnings = warnings;
362
+ result.skills.push(entryOut);
363
+ }
364
+ result.skills.sort((a, b) => a.name.localeCompare(b.name));
365
+ return result;
366
+ }
367
+
368
+ /** 读 `storages/mcp_connector.json`。只取 catalog 与连接表的**有无**,不解释业务含义。 */
369
+ export function readMcpStore(dshHome) {
370
+ const out = { file: join(dshHome, "storages", "mcp_connector.json"), present: false, catalog: [], connections: [], unreadable: null };
371
+ if (!existsSync(out.file)) return out;
372
+ out.present = true;
373
+ let raw;
374
+ try {
375
+ raw = JSON.parse(readFileSync(out.file, "utf8"));
376
+ } catch (error) {
377
+ out.unreadable = `JSON 解析失败:${error.message}`;
378
+ return out;
379
+ }
380
+ const tables = raw?.tables ?? {};
381
+ const connectors = tables?.catalog?.remote?.connectors;
382
+ if (Array.isArray(connectors)) {
383
+ for (const c of connectors) {
384
+ if (!c || typeof c !== "object") continue;
385
+ out.catalog.push({
386
+ id: String(c.id ?? ""),
387
+ name: String(c.name ?? ""),
388
+ vendor: String(c.vendor ?? ""),
389
+ category: String(c.category ?? ""),
390
+ summary: String(c.summary ?? ""),
391
+ // servers 里带 url/transport,但**不输出 url**:它可能是私有端点,而清单会被截图。
392
+ servers: Array.isArray(c.servers) ? c.servers.length : 0,
393
+ // **serverName 必须留下来**:会话日志里的 MCP 工具名是 mcp__<serverName>__<tool>,
394
+ // 而 serverName 与 catalog 的 `id` **不保证相同**——实测本机就有两例对不上
395
+ // (`baidu-maps`↔`baidu-map`、`bazhuayu-cloud-collection`↔`bazhuayu`)。
396
+ // 只拿 id 去匹配使用记录,那些 server 的真实调用会被显示成 `candidate`。
397
+ serverNames: Array.isArray(c.servers)
398
+ ? c.servers.map((s) => String(s?.serverName ?? "").trim()).filter((s) => s !== "")
399
+ : [],
400
+ authMode: String(c?.auth?.mode ?? ""),
401
+ probeStatus: String(c.probeStatus ?? ""),
402
+ visibility: "catalog"
403
+ });
404
+ }
405
+ }
406
+ const toRows = (table) =>
407
+ Object.entries(table && typeof table === "object" ? table : {}).map(([key, value]) => ({
408
+ key,
409
+ name: String(value?.name ?? value?.serverName ?? key),
410
+ visibility: "declared"
411
+ }));
412
+ out.connections = [...toRows(tables.connections), ...toRows(tables.grants)];
413
+ return out;
414
+ }
415
+
416
+ /**
417
+ * 保守解析 cordis.patch.yml 的**顶层数组条目**:只认 `- id:` / `name:` / `disabled:`。
418
+ *
419
+ * 见到块级 `config:` 就跳过它之后所有更深缩进的行——**不解析用户的任意配置结构**。
420
+ * 缩进判断一律用「行首空格数比条目行多」,不引 YAML 语义。
421
+ *
422
+ * 值要去引号:实测本机 `cordis.patch.yml` 里写的是 `name: "@deepseek-ai/..."`,
423
+ * 带着引号拿去比对包名会一个都匹配不上(这个 bug 是被测试逼出来的)。
424
+ */
425
+ export function parsePatchEntries(text) {
426
+ const entries = [];
427
+ const problems = [];
428
+ if (typeof text !== "string" || text.trim() === "") return { entries, problems };
429
+
430
+ /** 去引号:单双引号成对才剥,避免吃掉值里的正常引号。 */
431
+ const unquote = (v) => {
432
+ const s = v.trim();
433
+ if (s.length > 1 && ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'")))) {
434
+ return s.slice(1, -1);
435
+ }
436
+ return s;
437
+ };
438
+
439
+ let cur = null;
440
+ let skipDeeper = false;
441
+ let entryIndent = 0;
442
+ let blockIndent = -1;
443
+ for (const rawLine of text.split(/\r?\n/)) {
444
+ const noComment = rawLine.replace(/\s+#.*$/, "");
445
+ if (noComment.trim() === "") continue;
446
+ if (noComment.trimStart().startsWith("#")) continue;
447
+ const indent = noComment.length - noComment.trimStart().length;
448
+ const line = noComment.trim();
449
+ if (line.startsWith("- ")) {
450
+ // **嵌套列表不是新条目。** 只有缩进不比当前条目更深的 `- ` 才算顶层条目;
451
+ // 更深的属于上一个条目的 config 内部(实测本机 patch 里 `allowedModels:`
452
+ // 这类嵌套列表下也写着自己的 - id / name,若一律当成新条目,30 个顶层条目
453
+ // 会被数成 69 条)。
454
+ if (cur !== null && indent > entryIndent) continue;
455
+ if (cur) entries.push(cur);
456
+ cur = { id: null, name: null, disabled: null };
457
+ skipDeeper = false;
458
+ blockIndent = -1;
459
+ entryIndent = indent;
460
+ const rest = line.slice(2).trim();
461
+ const m = /^([A-Za-z0-9_.-]+):\s*(.*)$/.exec(rest);
462
+ if (m) {
463
+ if (m[1] === "id") cur.id = unquote(m[2]);
464
+ else if (m[1] === "name") cur.name = unquote(m[2]);
465
+ else if (m[1] === "disabled") cur.disabled = unquote(m[2]) === "true";
466
+ }
467
+ continue;
468
+ }
469
+ if (!cur) {
470
+ problems.push(`顶层不是数组条目(遇到「${line.slice(0, 40)}」)`);
471
+ continue;
472
+ }
473
+ // 条目自身的键缩进与 `- ` 同层;比 `- ` 更深的才是被跳过的嵌套结构。
474
+ //
475
+ // 判据是**相对于「打开嵌套块那个键」的缩进**,不是「比 `- ` 深就跳过」。两边的
476
+ // 用例都必须成立,缺一会踩坑:
477
+ // · `disabled:` 写在 `config:` 块**之后**、且与 `config:` 同缩进 —— 必须认出来
478
+ // (实测本机 patch 里就有这种写法,一律跳过会让 disabled 静默变成「未声明」);
479
+ // · 嵌套**列表**里的 `name:`(实测 `allowedModels` 下就有)缩进更深 —— 必须跳过
480
+ // (否则它会被当成顶层条目的 name,实测就是这么错的)。
481
+ // 所以记下 `blockIndent` = 打开嵌套块那个键的缩进:比它深的才跳。
482
+ const m = /^([A-Za-z0-9_.-]+):\s*(.*)$/.exec(line);
483
+ if (!m) continue;
484
+ const key = m[1];
485
+ const isScalarWeWant = key === "id" || key === "name" || key === "disabled";
486
+ if (skipDeeper && indent > blockIndent) continue;
487
+ const value = unquote(m[2]);
488
+ if (value === "") {
489
+ // 形如 `config:` 的嵌套块:只记录「有这块」,不进去。
490
+ if (!isScalarWeWant) {
491
+ skipDeeper = true;
492
+ blockIndent = indent;
493
+ }
494
+ continue;
495
+ }
496
+ if (key === "id") cur.id = value;
497
+ else if (key === "name") cur.name = value;
498
+ else if (key === "disabled") cur.disabled = value === "true";
499
+ else if (indent <= entryIndent) {
500
+ // 与条目同层的、我们不认识的键:**说出来**。补丁文件格式一变(比如以后多了
501
+ // `enabled:`),静默忽略会让清单显示一份「看起来正常」的旧理解——这正是本仓
502
+ // 反复踩的那类错(api.js:1661 的「忽略了会静默退成全局,而全局看起来
503
+ // 一切正常」是同一族)。只在同层报:更深的是 config 内部,本就不该解析。
504
+ problems.push(`未识别的顶层键「${key}」(只认 id / name / disabled,其余不解析)`);
505
+ }
506
+ }
507
+ if (cur) entries.push(cur);
508
+ return { entries, problems };
509
+ }
510
+
511
+ /** 读 profile 的插件声明:`package.json` 的 `dsh.profile.bundles` + `cordis.patch.yml`。 */
512
+ export function readPluginDeclarations(profileDir) {
513
+ const out = {
514
+ dir: profileDir,
515
+ present: false,
516
+ bundles: [],
517
+ patches: [],
518
+ patchProblems: [],
519
+ unreadable: []
520
+ };
521
+ if (!profileDir || !existsSync(profileDir)) return out;
522
+ out.present = true;
523
+
524
+ const pkgFile = join(profileDir, "package.json");
525
+ if (existsSync(pkgFile)) {
526
+ try {
527
+ const pkg = JSON.parse(readFileSync(pkgFile, "utf8"));
528
+ const bundles = pkg?.dsh?.profile?.bundles;
529
+ if (Array.isArray(bundles)) {
530
+ for (const b of bundles) {
531
+ if (typeof b === "string" && b.trim() !== "") {
532
+ out.bundles.push({ id: b.trim(), visibility: "declared" });
533
+ }
534
+ }
535
+ }
536
+ } catch (error) {
537
+ out.unreadable.push({ file: "package.json", detail: `JSON 解析失败:${error.message}` });
538
+ }
539
+ } else {
540
+ out.unreadable.push({ file: "package.json", detail: "不存在" });
541
+ }
542
+
543
+ const patchFile = join(profileDir, "cordis.patch.yml");
544
+ if (existsSync(patchFile)) {
545
+ try {
546
+ const parsed = parsePatchEntries(readFileSync(patchFile, "utf8"));
547
+ out.patches = parsed.entries
548
+ .filter((e) => e.id !== null || e.name !== null)
549
+ .map((e) => ({
550
+ id: e.id,
551
+ name: e.name,
552
+ disabled: e.disabled === true,
553
+ visibility: "declared"
554
+ }));
555
+ out.patchProblems = parsed.problems;
556
+ } catch (error) {
557
+ out.unreadable.push({ file: "cordis.patch.yml", detail: `读失败:${error.message}` });
558
+ }
559
+ } else {
560
+ out.unreadable.push({ file: "cordis.patch.yml", detail: "不存在" });
561
+ }
562
+ return out;
563
+ }
564
+
565
+ /**
566
+ * 总入口:按三类各读一遍,投影成 `mneme.capability/v1`。
567
+ *
568
+ * 每类的处理态度**故意不同**,理由各自写在上面三个函数里:技能坏一条记一条、MCP 的
569
+ * catalog 缺失不算错、插件声明读不到就如实说。**没有一处会抛**——清单是只读视图,
570
+ * 它自己挂掉只会让人更看不清现状。
571
+ */
572
+ export function discoverCapabilities(options = {}) {
573
+ const ctx = resolveContext(options);
574
+ const now = Number.isFinite(options.now) ? options.now : Date.now();
575
+ const staleAfterDays = Number.isFinite(options.staleAfterDays) ? options.staleAfterDays : STALE_AFTER_DAYS;
576
+
577
+ // --- 使用证据 ---
578
+ // 会话日志里的 tool-call 记录:**唯一能回答「这个能力用过没」的来源**。
579
+ // 注意它的语义边界:读到的只是**本机现存日志**里的调用,日志被清理后旧记录就没了,
580
+ // 所以 `candidate` 的含义是「现存日志里没有记录」,不是「从未使用」——这句话会
581
+ // 随结果一起输出(usage.scopeNote),否则最容易被读错。
582
+ let usageSummary = { skills: [], mcp: [] };
583
+ let usageCollection = null;
584
+ try {
585
+ usageCollection = collectUsage({ sessionDir: options.sessionDir ?? resolveSessionDir(ctx), maxFiles: options.maxUsageFiles });
586
+ usageSummary = summarizeUsage(usageCollection, { now, staleAfterDays });
587
+ } catch (error) {
588
+ // 清单是只读视图,使用证据拿不到不该让它整个挂掉——如实记下来,继续输出其余部分。
589
+ usageCollection = {
590
+ dir: options.sessionDir ?? resolveSessionDir(ctx),
591
+ present: false,
592
+ scannedFiles: 0,
593
+ frames: 0,
594
+ failedFrames: 0,
595
+ skillCalls: 0,
596
+ mcpCalls: 0,
597
+ unmatched: 0,
598
+ skills: new Map(),
599
+ mcp: new Map(),
600
+ unreadable: [{ file: null, detail: `会话日志扫描失败:${error.message}` }]
601
+ };
602
+ }
603
+ const usageBySkill = new Map(usageSummary.skills.map((r) => [r.name, r]));
604
+ const usageByMcp = new Map(usageSummary.mcp.map((r) => [r.name, r]));
605
+
606
+ // --- 技能 ---
607
+ // 这里有两个不同的键名,别混(混过一次,见 scanSkillRoot 的注释):
608
+ // · `skillRoots()` 的返回值用 **dir** → 所以下面读 `r.dir`,正确;
609
+ // · `scanSkillRoot()` 的返回值用 **root** → 下游一律读 `scannedRoot.root`。
610
+ const roots = skillRoots(ctx);
611
+ const scanned = roots.map((r) => scanSkillRoot(r.dir, r));
612
+ const skillEntries = [];
613
+ const seenNames = new Map();
614
+ const shadowed = [];
615
+ for (const scannedRoot of scanned) {
616
+ for (const skill of scannedRoot.skills) {
617
+ // 去重按 **name**(宿主以 frontmatter 的 name 为身份),不是目录名。
618
+ const prior = seenNames.get(skill.name);
619
+ if (prior) {
620
+ // 优先级高的先扫(roots 已按 rank 升序),所以后到的被压住是**安排好的**,
621
+ // 记下来免得以后有人以为技能丢了。
622
+ shadowed.push({ name: skill.name, hidden: scannedRoot.source, by: prior.source });
623
+ continue;
624
+ }
625
+ seenNames.set(skill.name, { source: scannedRoot.source, rank: scannedRoot.rank });
626
+ skillEntries.push({
627
+ kind: "capability:skill",
628
+ id: skill.name,
629
+ name: skill.name,
630
+ dirName: skill.dirName,
631
+ summary: skill.description,
632
+ descriptionLength: skill.descriptionLength,
633
+ // 链接进来的技能要标出来:文件其实在别处,改它等于改那边。
634
+ viaSymlink: skill.viaSymlink === true,
635
+ warnings: skill.warnings ?? [],
636
+ // 使用证据:**没有记录就是 `candidate`**,不由人写死(照 pipeline.js 的 deriveState)。
637
+ usage: usageBySkill.get(skill.name) ?? { name: skill.name, uses: 0, lastUsedAt: null, sessions: 0, state: "candidate" },
638
+ origin: {
639
+ scope: scannedRoot.scope,
640
+ label: scannedRoot.label,
641
+ source: scannedRoot.source,
642
+ rank: scannedRoot.rank,
643
+ // 是 `root`(scanSkillRoot 的键名),不是 `dir`——写错会静默变成 undefined。
644
+ dir: scannedRoot.root,
645
+ file: skill.file
646
+ },
647
+ visibility: "read"
648
+ });
649
+ }
650
+ }
651
+
652
+ // 有使用记录、但清单里没有对应技能名的调用:**如实报出来**。
653
+ // 实测有一例(一个曾用过、现已不在扫描范围内的技能)——它是真信息,
654
+ // 说明「能力消失但使用痕迹还在」,不该被静默丢弃。
655
+ const knownSkillNames = new Set(skillEntries.map((s) => s.name));
656
+ const usageNotInManifest = usageSummary.skills
657
+ .filter((r) => !knownSkillNames.has(r.name))
658
+ .map((r) => ({ name: r.name, uses: r.uses, lastUsedAt: r.lastUsedAt, state: r.state }));
659
+
660
+ // --- MCP ---
661
+ const mcp = readMcpStore(ctx.dshHome);
662
+
663
+ // --- 插件 ---
664
+ const plugins = readPluginDeclarations(ctx.profileDir);
665
+
666
+ const brokenSkills = scanned.flatMap((r) =>
667
+ r.broken.map((b) => ({ source: r.source, dir: r.root, file: b.file, detail: b.detail }))
668
+ );
669
+ const skillWarnings = skillEntries.flatMap((s) =>
670
+ (s.warnings || []).map((w) => ({ source: s.origin.source, name: s.name, kind: w.kind, detail: w.detail }))
671
+ );
672
+
673
+ // 用投影后的 catalog(带 usage)算计数——**不能拿原始的 mcp.catalog**,那份没挂使用记录。
674
+ //
675
+ // **按 serverName 找使用记录,不是按 id。** 会话日志里的 MCP 工具名是
676
+ // `mcp__<serverName>__<tool>`,而 serverName 与 catalog 的 `id` 不保证相同
677
+ // (实测 `baidu-maps`↔`baidu-map`、`bazhuayu-cloud-collection`↔`bazhuayu`)。
678
+ // 只按 id 匹配会让那些 server 的真实调用显示成 candidate。
679
+ // 一条 catalog 可能有多个 serverName:逐个试,命中即用;都没有才回退到 id。
680
+ const usageForCatalogEntry = (c) => {
681
+ for (const sn of c.serverNames ?? []) {
682
+ const hit = usageByMcp.get(sn);
683
+ if (hit) return hit;
684
+ }
685
+ return usageByMcp.get(c.id) ?? { name: c.id, uses: 0, lastUsedAt: null, sessions: 0, state: "candidate" };
686
+ };
687
+ const mcpCatalogWithUsage = mcp.catalog.map((c) => ({ ...c, usage: usageForCatalogEntry(c) }));
688
+
689
+ return {
690
+ contract: "mneme.capability/v1",
691
+ generatedAt: new Date(now).toISOString(),
692
+ context: {
693
+ projectRoot: ctx.projectRoot,
694
+ projectLabel: ctx.projectRoot ? basename(ctx.projectRoot) || ctx.projectRoot : null,
695
+ dshHome: ctx.dshHome,
696
+ agentsHome: ctx.agentsHome,
697
+ profileDir: ctx.profileDir,
698
+ customSkillDirs: ctx.customSkillDirs
699
+ },
700
+ /**
701
+ * **本模块没覆盖的技能来源**,如实列出而不是让人以为清单是全的。
702
+ * 两条都是宿主 `roots()` 里真实存在的 rank,但它们的目录本模块拿不到:
703
+ * · runtime(rank 250):插件运行时注册进 `ctx.skills`,没有目录;
704
+ * · bundled(rank 600):插件包自带 assets,目录由宿主按包解析。
705
+ * 想看到它们,得由调用方把目录经 `customSkillDirs` 注入,或去问宿主。
706
+ */
707
+ uncoveredSkillSources: [
708
+ { source: "runtime", rank: SKILL_RANKS.runtime, why: "插件运行时注册进 ctx.skills,没有目录可供文件层扫描" },
709
+ { source: "bundled", rank: SKILL_RANKS.bundled, why: "插件包自带 assets,目录由宿主按包解析;需要时经 customSkillDirs 注入" }
710
+ ],
711
+ sources: [
712
+ ...scanned.map((r) => ({
713
+ // `kind` 一律带 `capability:` 前缀。这是**规避仓库的保留字**,不是装饰:
714
+ // 裸的「plugin」是 DSH 消息来源的保留 kind,V4 写入准入会整条拒绝,而
715
+ // `test/llm-message-source.test.js:190` 用静态 grep 锁死了全 src/ 不许再出现
716
+ // 那个字面量(issue #326)。三类统一加前缀,既避开保留字,也让「这是能力清单
717
+ // 自己的分类」一眼可辨——真实来源由 source 字段说清。
718
+ kind: "capability:skill",
719
+ source: r.source,
720
+ scope: r.scope,
721
+ label: r.label,
722
+ dir: r.root,
723
+ present: r.present,
724
+ rank: r.rank,
725
+ count: r.skills.length
726
+ })),
727
+ {
728
+ kind: "capability:mcp",
729
+ source: "mcp-store",
730
+ scope: "user",
731
+ label: null,
732
+ dir: mcp.file,
733
+ present: mcp.present,
734
+ rank: null,
735
+ count: mcp.catalog.length
736
+ },
737
+ {
738
+ kind: "capability:plugin",
739
+ source: "profile-declarations",
740
+ scope: "user",
741
+ label: null,
742
+ dir: plugins.dir,
743
+ present: plugins.present,
744
+ rank: null,
745
+ count: plugins.bundles.length + plugins.patches.length
746
+ }
747
+ ],
748
+ skills: skillEntries,
749
+ shadowed,
750
+ usageNotInManifest,
751
+ usage: {
752
+ // 使用证据的来源与口径。**这段必须和数字一起读**:`candidate` 只是「现存日志里
753
+ // 没有记录」,不等于「从未使用」——日志会被清理,越久远的能力越容易退化成 candidate。
754
+ source: usageCollection?.dir ?? null,
755
+ sourcePresent: usageCollection?.present === true,
756
+ scannedFiles: usageCollection?.scannedFiles ?? 0,
757
+ frames: usageCollection?.frames ?? 0,
758
+ // 解不出来的帧数要报出来:它直接决定「有没有漏看调用」。
759
+ failedFrames: usageCollection?.failedFrames ?? 0,
760
+ skillCalls: usageCollection?.skillCalls ?? 0,
761
+ mcpCalls: usageCollection?.mcpCalls ?? 0,
762
+ // 对不上名字的调用数(技能没记 name、或 MCP server 名为空)。如实报,不硬凑。
763
+ unmatchedCalls: usageCollection?.unmatched ?? 0,
764
+ // **读不到要与「没有」可分辨**:`sourcePresent` 只说目录在不在,
765
+ // `sourceReadable` 说它是不是真能列出来。两者都为 true 而记录为空,
766
+ // 才是「确实没有调用记录」;任何一个为 false 时,数字都不可信。
767
+ sourceReadable: usageCollection?.readable === true,
768
+ // 文件数被上限截断时要报出来,否则「只扫了一部分」看不出来。
769
+ filesTruncated: usageCollection?.truncated === true,
770
+ staleAfterDays,
771
+ scopeNote:
772
+ "使用记录只覆盖本机现存的会话日志。`candidate` 的含义是「在这些日志里没有记录」,不是「从未使用」——日志被清理后,久未用的能力会退化成 candidate;反过来,若 sourceReadable 为 false,则所有 candidate 都不可信。另注意:`stale` 只在现存日志覆盖的时间跨度**超过** staleAfterDays 时才可能出现;本机实测窗口 63.5 天 < 90 天,故 stale 结构性不可达——此时「久未用」与「从未用」在读数上不可区分。这是读数的边界,不是「能力都还在用」。",
773
+ unreadable: usageCollection?.unreadable ?? []
774
+ },
775
+ mcp: {
776
+ // 关键区分:catalog 是「可选目录」,connections 是「已连接的声明」。两者都不是
777
+ // 「此刻真的连上了」——那个只有宿主运行时知道,所以下面单列 runtimeOnly。
778
+ catalogSource: mcp.file,
779
+ catalogCount: mcp.catalog.length,
780
+ // catalog 每条挂上使用记录(按 server id 对)。**这是把「可选目录」与「真用过」
781
+ // 分开的关键一列**:109 条目录里,只有 github 这一条有调用痕迹。
782
+ catalog: mcpCatalogWithUsage,
783
+ connections: mcp.connections,
784
+ runtimeOnly: {
785
+ what: "MCP 连接的真实状态与工具清单",
786
+ why: "由 @deepseek-ai/dsh-mcp-client 在运行时把工具注册到 ctx.tools(public name 形如 mcp__<serverName>__<rawName>),不落盘",
787
+ howToAsk: "宿主侧读 Loader / ctx.tools;文件层拿不到时不猜"
788
+ },
789
+ unreadable: mcp.unreadable
790
+ },
791
+ plugins: {
792
+ bundles: plugins.bundles,
793
+ patches: plugins.patches,
794
+ patchProblems: plugins.patchProblems,
795
+ unreadable: plugins.unreadable,
796
+ runtimeOnly: {
797
+ what: "插件的真实加载结果(某个包可能加载失败)",
798
+ why: "bundles 与 patch 是**声明**;加载状态由 Cordis Loader 在内存中持有(dsh-host-plugin-inventory 直读 Loader)",
799
+ howToAsk: "宿主侧读 pluginInventory 服务;文件层拿不到时不猜"
800
+ }
801
+ },
802
+ brokenSkills,
803
+ skillWarnings,
804
+ counts: {
805
+ skills: skillEntries.length,
806
+ mcpCatalog: mcp.catalog.length,
807
+ mcpConnections: mcp.connections.length,
808
+ pluginBundles: plugins.bundles.length,
809
+ pluginPatches: plugins.patches.length,
810
+ // broken = 宿主不会加载;warnings = 能加载但有值得知道的事(**不计入 broken**)。
811
+ broken: brokenSkills.length,
812
+ warnings: skillWarnings.length,
813
+ // 使用侧:有记录的技能数 / 被用过的 MCP server 数 / 用了但已不在清单里的条数。
814
+ skillsWithUsage: skillEntries.filter((s) => s.usage.state !== "candidate").length,
815
+ mcpServersWithUsage: mcpCatalogWithUsage.filter((c) => c.usage.state !== "candidate").length,
816
+ usageNotInManifest: usageNotInManifest.length
817
+ }
818
+ };
819
+ }