@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.
- package/README.md +21 -3
- package/assets/icon.svg +2 -2
- package/bin/cli.mjs +4 -2
- package/docs/CONFIGURATION.md +16 -3
- package/docs/CONTINUITY.md +18 -3
- package/docs/DISTILL.md +162 -0
- package/docs/STORAGE.md +11 -2
- package/docs/WORKFLOWS.md +299 -0
- package/lib/api-standalone.js +22 -39
- package/lib/api.js +294 -10
- package/lib/candidates.js +454 -0
- package/lib/capabilities.js +819 -0
- package/lib/client.js +2767 -337
- package/lib/config.js +99 -6
- package/lib/continuity.js +35 -6
- package/lib/dream/decisions.js +37 -1
- package/lib/dream/sleep.js +10 -2
- package/lib/dream.js +7 -1
- package/lib/entities/extractor.js +4 -0
- package/lib/hot-memory.js +62 -0
- package/lib/index.js +72 -18
- package/lib/inject.js +33 -64
- package/lib/maintenance.js +25 -5
- package/lib/pipeline.js +534 -0
- package/lib/recall-stats.js +22 -7
- package/lib/reclaim-http.js +150 -0
- package/lib/safe-slice.js +18 -0
- package/lib/scope.js +16 -4
- package/lib/serve.js +1 -1
- package/lib/service.js +83 -17
- package/lib/session-usage.js +350 -0
- package/lib/settings.js +19 -2
- package/lib/store.js +251 -10
- package/lib/summarize.js +239 -60
- package/lib/tools.js +11 -2
- package/package.json +1 -1
- package/scripts/publish-gate.mjs +197 -0
- package/src/api-standalone.js +22 -39
- package/src/api.js +294 -10
- package/src/candidates.js +454 -0
- package/src/capabilities.js +819 -0
- package/src/config.js +99 -6
- package/src/continuity.js +35 -6
- package/src/dream/decisions.js +37 -1
- package/src/dream/sleep.js +10 -2
- package/src/dream.js +7 -1
- package/src/entities/extractor.js +4 -0
- package/src/hot-memory.js +62 -0
- package/src/index.js +72 -18
- package/src/inject.js +33 -64
- package/src/maintenance.js +25 -5
- package/src/pipeline.js +534 -0
- package/src/recall-stats.js +22 -7
- package/src/reclaim-http.js +150 -0
- package/src/safe-slice.js +18 -0
- package/src/scope.js +16 -4
- package/src/serve.js +1 -1
- package/src/service.js +83 -17
- package/src/session-usage.js +350 -0
- package/src/settings.js +19 -2
- package/src/store.js +251 -10
- package/src/summarize.js +239 -60
- package/src/tools.js +11 -2
- package/test/api-routes-gaps.test.js +681 -0
- package/test/api.test.js +171 -8
- package/test/candidates.test.js +345 -0
- package/test/capabilities.test.js +577 -0
- package/test/client.test.js +715 -4
- package/test/continuity.test.js +84 -0
- package/test/contrast.test.js +314 -0
- package/test/dream-merge-guardrail.test.js +219 -0
- package/test/embed-ready-poll.test.js +94 -0
- package/test/entities.test.js +13 -2
- package/test/failure-query.test.js +147 -0
- package/test/failure-retention.test.js +105 -0
- package/test/graph-api.test.js +36 -2
- package/test/hot-memory.test.js +67 -1
- package/test/inject-recall.test.js +68 -1
- package/test/inject.test.js +23 -0
- package/test/llm-audit.test.js +190 -1
- package/test/maintenance.test.js +112 -2
- package/test/mint-source.test.js +202 -0
- package/test/pipeline.test.js +505 -0
- package/test/placement.test.js +1 -1
- package/test/pre-inject-gate.test.js +10 -0
- package/test/publish-gate.test.js +272 -0
- package/test/recall-retention.test.js +128 -0
- package/test/recall-stats.test.js +89 -4
- package/test/reclaim-http.test.js +209 -0
- package/test/release-workflow.test.js +77 -0
- package/test/scope-distill-stamp.test.js +135 -0
- package/test/serve.test.js +1 -1
- package/test/service.test.js +7 -2
- package/test/session-usage.test.js +308 -0
- package/test/settings.test.js +8 -0
- package/test/store-write-path-columns.test.js +191 -0
- package/test/store.test.js +108 -0
- package/test/summarize.test.js +350 -0
- package/test/supersede-state-tracking.test.js +4 -4
- package/test/tools.test.js +16 -1
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 能力清单读侧(src/capabilities.js)的回归测试。
|
|
3
|
+
*
|
|
4
|
+
* 本文件锁的重点不是 happy path,而是**两类容易被做错的判断**:
|
|
5
|
+
* ① 「坏」与「警告」的分界——第一版把「name 与目录名不一致」当坏,一次误报 20 例;
|
|
6
|
+
* 实测宿主以 frontmatter 的 name 为身份(`react-three-fiber/` 里声明
|
|
7
|
+
* `threejs-scene-builder`),所以那条**不是错**。这里的用例就是钉死这个分界。
|
|
8
|
+
* ② 块标量(`description: |`)要真读出来——第一版把 `|` 当「不解析就丢」,误报
|
|
9
|
+
* 55 个技能「没有 description」。
|
|
10
|
+
* 两条都属同一类回归:**假警报会让人去改一个没坏的东西**,比漏报更坏。
|
|
11
|
+
*
|
|
12
|
+
* 另外钉死:没有项目根时**不得**回退到进程 cwd(读盘范围的正确性)。
|
|
13
|
+
*/
|
|
14
|
+
import test from "node:test";
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import zlib from "node:zlib";
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
parseFrontmatter,
|
|
23
|
+
parsePatchEntries,
|
|
24
|
+
scanSkillRoot,
|
|
25
|
+
skillRoots,
|
|
26
|
+
resolveContext,
|
|
27
|
+
discoverCapabilities
|
|
28
|
+
} from "../src/capabilities.js";
|
|
29
|
+
|
|
30
|
+
/** 造一个临时技能根;返回 {root, skillDir} 供断言用。 */
|
|
31
|
+
function makeSkillRoot() {
|
|
32
|
+
const base = mkdtempSync(join(tmpdir(), "mneme-cap-"));
|
|
33
|
+
const root = join(base, "skills");
|
|
34
|
+
mkdirSync(root, { recursive: true });
|
|
35
|
+
return {
|
|
36
|
+
base,
|
|
37
|
+
root,
|
|
38
|
+
cleanup: () => rmSync(base, { recursive: true, force: true })
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 写一个技能目录(<root>/<dirName>/SKILL.md)。 */
|
|
43
|
+
function writeSkill(root, dirName, body) {
|
|
44
|
+
const dir = join(root, dirName);
|
|
45
|
+
mkdirSync(dir, { recursive: true });
|
|
46
|
+
writeFileSync(join(dir, "SKILL.md"), body, "utf8");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
test("frontmatter:单行标量、引号、块标量都要读出来", () => {
|
|
50
|
+
const text = [
|
|
51
|
+
"---",
|
|
52
|
+
'name: demo-skill',
|
|
53
|
+
'description: "一行描述"',
|
|
54
|
+
"metadata:",
|
|
55
|
+
' author: "someone"',
|
|
56
|
+
"---",
|
|
57
|
+
"",
|
|
58
|
+
"# 正文不该被当成 frontmatter"
|
|
59
|
+
].join("\n");
|
|
60
|
+
const fm = parseFrontmatter(text);
|
|
61
|
+
assert.equal(fm.fields.name, "demo-skill");
|
|
62
|
+
assert.equal(fm.fields.description, "一行描述");
|
|
63
|
+
// 嵌套映射只记「有这个键」,不解析其内部
|
|
64
|
+
assert.equal(fm.fields.metadata, "");
|
|
65
|
+
assert.deepEqual(fm.problems, []);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("frontmatter:块标量 | 与 > 要分别按字面/折叠处理", () => {
|
|
69
|
+
const literal = ["---", "name: a-b", "description: |", " 第一行", " 第二行", "---", ""].join("\n");
|
|
70
|
+
const fmLiteral = parseFrontmatter(literal);
|
|
71
|
+
assert.equal(fmLiteral.fields.description, "第一行\n第二行");
|
|
72
|
+
|
|
73
|
+
const folded = ["---", "name: a-b", "description: >", " 第一行", " 第二行", "---", ""].join("\n");
|
|
74
|
+
const fmFolded = parseFrontmatter(folded);
|
|
75
|
+
assert.equal(fmFolded.fields.description, "第一行 第二行");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("frontmatter:缺 name / 未闭合 / 无 frontmatter 都要报出来", () => {
|
|
79
|
+
assert.match(parseFrontmatter("# 没有 frontmatter").problems.join(), /没有 frontmatter/);
|
|
80
|
+
assert.match(parseFrontmatter("---\nname: a\n").problems.join(), /未闭合/);
|
|
81
|
+
assert.match(parseFrontmatter("").problems.join(), /文件为空/);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("技能扫描:name 与目录名不一致是**警告**不是坏(宿主以 name 为身份)", () => {
|
|
85
|
+
const ctx = makeSkillRoot();
|
|
86
|
+
try {
|
|
87
|
+
writeSkill(
|
|
88
|
+
ctx.root,
|
|
89
|
+
"react-three-fiber",
|
|
90
|
+
["---", "name: threejs-scene-builder", "description: 造三维场景", "---", "正文"].join("\n")
|
|
91
|
+
);
|
|
92
|
+
const scanned = scanSkillRoot(ctx.root, { source: "user-agents", rank: 500, scope: "user" });
|
|
93
|
+
assert.equal(scanned.broken.length, 0, "不一致不该判为坏");
|
|
94
|
+
assert.equal(scanned.skills.length, 1);
|
|
95
|
+
// 身份取 frontmatter 的 name;dirName 另存,好让人知道文件在哪
|
|
96
|
+
assert.equal(scanned.skills[0].name, "threejs-scene-builder");
|
|
97
|
+
assert.equal(scanned.skills[0].dirName, "react-three-fiber");
|
|
98
|
+
assert.equal(scanned.skills[0].warnings[0].kind, "name-mismatch");
|
|
99
|
+
} finally {
|
|
100
|
+
ctx.cleanup();
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("技能扫描:description 超展示上限是**警告**,技能仍留在列表里", () => {
|
|
105
|
+
const ctx = makeSkillRoot();
|
|
106
|
+
try {
|
|
107
|
+
const long = "x".repeat(600);
|
|
108
|
+
writeSkill(ctx.root, "long-desc", ["---", "name: long-desc", `description: ${long}`, "---", ""].join("\n"));
|
|
109
|
+
const scanned = scanSkillRoot(ctx.root, { source: "user-agents", rank: 500, scope: "user" });
|
|
110
|
+
assert.equal(scanned.broken.length, 0, "超长不该判为坏");
|
|
111
|
+
assert.equal(scanned.skills.length, 1, "技能必须留在列表里");
|
|
112
|
+
assert.equal(scanned.skills[0].descriptionLength, 600);
|
|
113
|
+
assert.equal(scanned.skills[0].warnings[0].kind, "description-too-long");
|
|
114
|
+
} finally {
|
|
115
|
+
ctx.cleanup();
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("技能扫描:真·不可加载的三种情况进 broken", () => {
|
|
120
|
+
const ctx = makeSkillRoot();
|
|
121
|
+
try {
|
|
122
|
+
// ① 缺 description —— 宿主不会加载
|
|
123
|
+
writeSkill(ctx.root, "no-desc", ["---", "name: no-desc", "---", ""].join("\n"));
|
|
124
|
+
// ② 缺 name —— 宿主不会加载
|
|
125
|
+
writeSkill(ctx.root, "no-name", ["---", "description: 有描述没名字", "---", ""].join("\n"));
|
|
126
|
+
// ③ 名字不是 kebab-case
|
|
127
|
+
writeSkill(ctx.root, "Bad_Name", ["---", "name: Bad_Name", "description: 大写不行", "---", ""].join("\n"));
|
|
128
|
+
// ④ 目录里根本没有 SKILL.md
|
|
129
|
+
mkdirSync(join(ctx.root, "empty-dir"), { recursive: true });
|
|
130
|
+
// ⑤ 一个好的,证明坏的不影响好的
|
|
131
|
+
writeSkill(ctx.root, "good-one", ["---", "name: good-one", "description: 正常技能", "---", ""].join("\n"));
|
|
132
|
+
|
|
133
|
+
const scanned = scanSkillRoot(ctx.root, { source: "user-agents", rank: 500, scope: "user" });
|
|
134
|
+
assert.equal(scanned.skills.length, 1);
|
|
135
|
+
assert.equal(scanned.skills[0].name, "good-one");
|
|
136
|
+
assert.equal(scanned.broken.length, 4);
|
|
137
|
+
const details = scanned.broken.map((b) => `${b.file}:${b.detail}`).join(" | ");
|
|
138
|
+
assert.match(details, /no-desc:.*没有 description/);
|
|
139
|
+
assert.match(details, /no-name:.*没有 name/);
|
|
140
|
+
assert.match(details, /Bad_Name:.*kebab-case/);
|
|
141
|
+
assert.match(details, /empty-dir:.*没有 SKILL.md/);
|
|
142
|
+
} finally {
|
|
143
|
+
ctx.cleanup();
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("技能扫描:符号链接指向的技能目录必须被扫到(Dirent 对 symlink 两个 is* 都是 false)", () => {
|
|
148
|
+
const ctx = makeSkillRoot();
|
|
149
|
+
const outside = mkdtempSync(join(tmpdir(), "mneme-cap-link-target-"));
|
|
150
|
+
try {
|
|
151
|
+
// 真实技能放在别处,根目录里只放一个链接——实测本机某个用户级技能根里就是这种
|
|
152
|
+
// 形态(一个链接进来的技能),一度 8 个目录只扫出 7 个且既不报错也不进 broken。
|
|
153
|
+
mkdirSync(join(outside, "linked-skill"), { recursive: true });
|
|
154
|
+
writeFileSync(
|
|
155
|
+
join(outside, "linked-skill", "SKILL.md"),
|
|
156
|
+
["---", "name: linked-skill", "description: 我是链接进来的", "---", ""].join("\n"),
|
|
157
|
+
"utf8"
|
|
158
|
+
);
|
|
159
|
+
try {
|
|
160
|
+
symlinkSync(join(outside, "linked-skill"), join(ctx.root, "linked-skill"), "junction");
|
|
161
|
+
} catch (error) {
|
|
162
|
+
// Windows 无权限时跳过,而不是让整条用例假绿
|
|
163
|
+
if (error.code === "EPERM" || error.code === "EACCES") return;
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const scanned = scanSkillRoot(ctx.root, { source: "user-dsh", rank: 400, scope: "user" });
|
|
168
|
+
assert.equal(scanned.broken.length, 0, "链接不该被判为坏");
|
|
169
|
+
assert.equal(scanned.skills.length, 1, "链接进来的技能必须出现在列表里(不许静默消失)");
|
|
170
|
+
assert.equal(scanned.skills[0].name, "linked-skill");
|
|
171
|
+
assert.equal(scanned.skills[0].viaSymlink, true, "要标出它是链接进来的(改它等于改那边)");
|
|
172
|
+
} finally {
|
|
173
|
+
ctx.cleanup();
|
|
174
|
+
rmSync(outside, { recursive: true, force: true });
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("技能扫描:悬空符号链接要报出来,不许静默跳过", () => {
|
|
179
|
+
const ctx = makeSkillRoot();
|
|
180
|
+
try {
|
|
181
|
+
try {
|
|
182
|
+
symlinkSync(join(ctx.root, "no-such-target-at-all"), join(ctx.root, "dangling"), "junction");
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (error.code === "EPERM" || error.code === "EACCES") return;
|
|
185
|
+
throw error;
|
|
186
|
+
}
|
|
187
|
+
const scanned = scanSkillRoot(ctx.root, { source: "user-dsh", rank: 400, scope: "user" });
|
|
188
|
+
assert.equal(scanned.skills.length, 0);
|
|
189
|
+
assert.equal(scanned.broken.length, 1, "悬空链接必须进 broken,不能消失");
|
|
190
|
+
assert.match(scanned.broken[0].detail, /符号链接目标不可达|没有 SKILL\.md/);
|
|
191
|
+
} finally {
|
|
192
|
+
ctx.cleanup();
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("cordis.patch.yml:嵌套列表项**不得**被当成顶层条目", () => {
|
|
197
|
+
// 实测回归:本机 cordis.patch.yml 顶层只有 30 个 `- ` 条目,但 `allowedModels:`
|
|
198
|
+
// 这类嵌套列表下也写着自己的 - id / name,最初一律当成新条目,把 30 条数成 69 条。
|
|
199
|
+
// 夹具刻意用「嵌套列表」而不是嵌套映射——映射那种情况原用例已覆盖,列表才是漏网的。
|
|
200
|
+
const text = [
|
|
201
|
+
"- id: llm-pi-ai",
|
|
202
|
+
" name: '@deepseek-ai/dsh-llm-pi-ai'",
|
|
203
|
+
" config:",
|
|
204
|
+
" providers:",
|
|
205
|
+
" volcano-engine:",
|
|
206
|
+
" allowedModels:",
|
|
207
|
+
" - id: deepseek-v4-flash",
|
|
208
|
+
" name: Deepseek-v4 Flash",
|
|
209
|
+
" - id: glm-5.3-flash",
|
|
210
|
+
" name: GLM-5.3 Flash",
|
|
211
|
+
"- id: ui-theme",
|
|
212
|
+
" disabled: false"
|
|
213
|
+
].join("\n");
|
|
214
|
+
const { entries } = parsePatchEntries(text);
|
|
215
|
+
assert.equal(entries.length, 2, "嵌套列表里的 - id 不是顶层条目");
|
|
216
|
+
assert.equal(entries[0].id, "llm-pi-ai");
|
|
217
|
+
assert.equal(entries[0].name, "@deepseek-ai/dsh-llm-pi-ai");
|
|
218
|
+
assert.equal(entries[1].id, "ui-theme");
|
|
219
|
+
assert.equal(entries[1].disabled, false);
|
|
220
|
+
// 嵌套列表里的 id 不许冒上来
|
|
221
|
+
assert.ok(!entries.some((e) => e.id === "deepseek-v4-flash"), "嵌套的模型 id 不许变成补丁条目");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("技能根:custom 目录要支持,且排在 user 之前(rank 300 < 400)", () => {
|
|
225
|
+
// 宿主 roots() 有 7 个 rank(100/200/250 runtime/300 custom/400/500/600 bundled),
|
|
226
|
+
// 本模块覆盖其中有目录的五类。custom 最容易漏——它来自用户配置而非固定路径。
|
|
227
|
+
const roots = skillRoots({
|
|
228
|
+
projectRoot: "D:/some/project",
|
|
229
|
+
dshHome: "D:/x/.dsh",
|
|
230
|
+
agentsHome: "D:/x/.agents",
|
|
231
|
+
customSkillDirs: ["D:/my/skills", "D:/other/skills"]
|
|
232
|
+
});
|
|
233
|
+
assert.deepEqual(
|
|
234
|
+
roots.map((r) => `${r.source}:${r.rank}`),
|
|
235
|
+
["project-dsh:100", "project-agents:200", "custom:300", "custom:300", "user-dsh:400", "user-agents:500"]
|
|
236
|
+
);
|
|
237
|
+
// 空串与空白要滤掉,否则会去读一个没意义的目录。过滤发生在 resolveContext
|
|
238
|
+
// (skillRoots 是纯函数,不做清洗)——所以这里走 resolveContext 再传进去。
|
|
239
|
+
const ctx = resolveContext({ dshHome: "D:/x", agentsHome: "D:/y", customSkillDirs: ["", " ", "D:/ok"] });
|
|
240
|
+
assert.deepEqual(ctx.customSkillDirs, ["D:/ok"], "空白自定义目录必须在入口被滤掉");
|
|
241
|
+
const cleaned = skillRoots(ctx);
|
|
242
|
+
assert.equal(cleaned.filter((r) => r.source === "custom").length, 1);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("总入口:runtime / bundled 两类要如实报「未覆盖」,不能让人以为清单是全的", () => {
|
|
246
|
+
const base = mkdtempSync(join(tmpdir(), "mneme-cap-uncovered-"));
|
|
247
|
+
try {
|
|
248
|
+
const r = discoverCapabilities({
|
|
249
|
+
projectRoot: null,
|
|
250
|
+
dshHome: join(base, "none"),
|
|
251
|
+
agentsHome: join(base, "none"),
|
|
252
|
+
profileDir: join(base, "none")
|
|
253
|
+
});
|
|
254
|
+
// 宿主 roots() 里有 7 个 rank,本模块只覆盖有目录的五类——差的这两个要说出来
|
|
255
|
+
const uncovered = r.uncoveredSkillSources.map((u) => u.source).sort();
|
|
256
|
+
assert.deepEqual(uncovered, ["bundled", "runtime"]);
|
|
257
|
+
for (const u of r.uncoveredSkillSources) {
|
|
258
|
+
assert.ok(u.why.length > 0, "每个未覆盖来源都要写清为什么");
|
|
259
|
+
assert.equal(typeof u.rank, "number");
|
|
260
|
+
}
|
|
261
|
+
} finally {
|
|
262
|
+
rmSync(base, { recursive: true, force: true });
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("总入口:每条技能的 origin.dir 与每个 source 的 dir 都必须有值(不许 undefined)", () => {
|
|
267
|
+
// 实测回归:scanSkillRoot 返回的键是 `root`,而下游读的是 `r.dir`——两者不一致时
|
|
268
|
+
// **175 条技能的 origin.dir 全是 undefined 且不报任何错**(位置丢了,别的字段照常)。
|
|
269
|
+
// 这种「静默丢字段」不会让任何现有用例变红,所以单独钉一条。
|
|
270
|
+
const base = mkdtempSync(join(tmpdir(), "mneme-cap-dir-"));
|
|
271
|
+
try {
|
|
272
|
+
const projectRoot = join(base, "proj");
|
|
273
|
+
const dshHome = join(base, ".dsh");
|
|
274
|
+
const agentsHome = join(base, ".agents");
|
|
275
|
+
mkdirSync(projectRoot, { recursive: true });
|
|
276
|
+
writeSkill(join(projectRoot, ".agents", "skills"), "one-skill", [
|
|
277
|
+
"---",
|
|
278
|
+
"name: one-skill",
|
|
279
|
+
"description: 用来检查 dir 字段",
|
|
280
|
+
"---",
|
|
281
|
+
""
|
|
282
|
+
].join("\n"));
|
|
283
|
+
// 再放一个坏的,检查 brokenSkills[].dir 也有值
|
|
284
|
+
mkdirSync(join(agentsHome, "skills", "no-skill-md"), { recursive: true });
|
|
285
|
+
|
|
286
|
+
const r = discoverCapabilities({ projectRoot, dshHome, agentsHome, profileDir: join(base, "none") });
|
|
287
|
+
|
|
288
|
+
assert.equal(r.skills.length, 1);
|
|
289
|
+
for (const s of r.skills) {
|
|
290
|
+
assert.equal(typeof s.origin.dir, "string", `${s.name} 的 origin.dir 必须是字符串`);
|
|
291
|
+
assert.ok(s.origin.dir.length > 0, `${s.name} 的 origin.dir 不能是空串`);
|
|
292
|
+
// 而且必须真的是它所在的那个根
|
|
293
|
+
assert.ok(s.origin.dir.includes(".agents"), "origin.dir 应指向技能所在的根目录");
|
|
294
|
+
}
|
|
295
|
+
for (const src of r.sources) {
|
|
296
|
+
assert.equal(typeof src.dir, "string", `source ${src.source} 的 dir 必须是字符串`);
|
|
297
|
+
assert.ok(src.dir.length > 0, `source ${src.source} 的 dir 不能是空串`);
|
|
298
|
+
}
|
|
299
|
+
assert.equal(r.brokenSkills.length, 1);
|
|
300
|
+
for (const b of r.brokenSkills) {
|
|
301
|
+
assert.equal(typeof b.dir, "string", "brokenSkills[].dir 必须是字符串");
|
|
302
|
+
assert.ok(b.dir.length > 0, "brokenSkills[].dir 不能是空串");
|
|
303
|
+
}
|
|
304
|
+
} finally {
|
|
305
|
+
rmSync(base, { recursive: true, force: true });
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("技能根:没有项目根时**不得**造出项目级根(不许回退到进程 cwd)", () => {
|
|
310
|
+
// 这里只需一个「像项目根」的路径,不必是本机真实路径——用常量避免把机器信息写进仓库。
|
|
311
|
+
const PROJECT = "D:/some/project";
|
|
312
|
+
const withProject = skillRoots({ projectRoot: PROJECT, dshHome: "D:/x/.dsh", agentsHome: "D:/x/.agents" });
|
|
313
|
+
const sources = withProject.map((r) => r.source);
|
|
314
|
+
assert.deepEqual(sources, ["project-dsh", "project-agents", "user-dsh", "user-agents"]);
|
|
315
|
+
// rank 升序 = 优先级顺序,抄的是宿主的数
|
|
316
|
+
assert.deepEqual(withProject.map((r) => r.rank), [100, 200, 400, 500]);
|
|
317
|
+
|
|
318
|
+
const noProject = skillRoots({ projectRoot: null, dshHome: "D:/x/.dsh", agentsHome: "D:/x/.agents" });
|
|
319
|
+
assert.deepEqual(noProject.map((r) => r.source), ["user-dsh", "user-agents"], "没有项目根就只能有用户级根");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("技能根:projectRoot 为空串或空白视同没有项目根", () => {
|
|
323
|
+
const ctx = resolveContext({ projectRoot: " " });
|
|
324
|
+
assert.equal(ctx.projectRoot, null, "空白项目根必须归一成 null,否则会去读 './.agents/skills'");
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("cordis.patch.yml:只认顶层 id/name/disabled,嵌套 config 不进去", () => {
|
|
328
|
+
const text = [
|
|
329
|
+
"# 注释行不该被当成条目",
|
|
330
|
+
"- id: mnemon-bundle",
|
|
331
|
+
" disabled: true",
|
|
332
|
+
"- id: ui-theme",
|
|
333
|
+
" name: '@deepseek-ai/dsh-client-ui-theme'",
|
|
334
|
+
" config:",
|
|
335
|
+
" preference: dark",
|
|
336
|
+
" nested:",
|
|
337
|
+
" deeper: 1",
|
|
338
|
+
"- id: agent-default-model",
|
|
339
|
+
" config:",
|
|
340
|
+
" provider: workbuddy-xdpool",
|
|
341
|
+
" model: deepseek-v4.1-flash",
|
|
342
|
+
" disabled: false",
|
|
343
|
+
// 顶层非 `- ` 行:真格式错,要报出来。放在最后,避免落进上一个条目的嵌套块范围
|
|
344
|
+
// (那种跟随行本来就该被当成上一个条目的内容,不算格式错)
|
|
345
|
+
"not-an-entry: true"
|
|
346
|
+
].join("\n");
|
|
347
|
+
const { entries, problems } = parsePatchEntries(text);
|
|
348
|
+
assert.equal(entries.length, 3);
|
|
349
|
+
assert.deepEqual(entries[0], { id: "mnemon-bundle", name: null, disabled: true });
|
|
350
|
+
assert.equal(entries[1].name, "@deepseek-ai/dsh-client-ui-theme");
|
|
351
|
+
assert.equal(entries[1].disabled, null, "没写 disabled 就是 null(未声明),不是 false");
|
|
352
|
+
// 关键:disabled 出现在嵌套 config **之后**,仍必须认出来
|
|
353
|
+
assert.equal(entries[2].disabled, false, "config 之后写的 disabled 也要认");
|
|
354
|
+
// 嵌套 config 里的键不能被误读成顶层字段
|
|
355
|
+
assert.equal(entries[2].name, null, "config 里的 provider/model 不该冒上来");
|
|
356
|
+
assert.equal(entries[2].id, "agent-default-model", "config 不该盖掉 id");
|
|
357
|
+
// 不认识的顶层键要说出来,不能静默忽略(格式一变就悄悄读错是最坏的一种错)
|
|
358
|
+
assert.match(problems.join(), /未识别的顶层键「not-an-entry」/);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test("总入口:三类都读不到时如实报 absent,不抛错也不编造", () => {
|
|
362
|
+
const base = mkdtempSync(join(tmpdir(), "mneme-cap-empty-"));
|
|
363
|
+
try {
|
|
364
|
+
const r = discoverCapabilities({
|
|
365
|
+
projectRoot: null,
|
|
366
|
+
dshHome: join(base, "no-such-dsh"),
|
|
367
|
+
agentsHome: join(base, "no-such-agents"),
|
|
368
|
+
profileDir: join(base, "no-such-profile")
|
|
369
|
+
});
|
|
370
|
+
assert.equal(r.contract, "mneme.capability/v1");
|
|
371
|
+
assert.equal(r.counts.skills, 0);
|
|
372
|
+
assert.equal(r.counts.mcpCatalog, 0);
|
|
373
|
+
assert.equal(r.counts.pluginBundles, 0);
|
|
374
|
+
// 根不存在是「absent」,不是错误
|
|
375
|
+
for (const s of r.sources) assert.equal(s.present, false);
|
|
376
|
+
// MCP 与插件的运行时部分要说清「拿不到,得问宿主」,而不是留空
|
|
377
|
+
assert.match(r.mcp.runtimeOnly.why, /ctx\.tools/);
|
|
378
|
+
assert.match(r.plugins.runtimeOnly.why, /Loader/);
|
|
379
|
+
} finally {
|
|
380
|
+
rmSync(base, { recursive: true, force: true });
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("总入口:技能同名时项目级压住用户级,且被压住的记进 shadowed", () => {
|
|
385
|
+
const base = mkdtempSync(join(tmpdir(), "mneme-cap-shadow-"));
|
|
386
|
+
try {
|
|
387
|
+
const projectRoot = join(base, "proj");
|
|
388
|
+
const dshHome = join(base, ".dsh");
|
|
389
|
+
const agentsHome = join(base, ".agents");
|
|
390
|
+
mkdirSync(projectRoot, { recursive: true });
|
|
391
|
+
|
|
392
|
+
// 两个根里都有同名技能,内容不同
|
|
393
|
+
writeSkill(join(projectRoot, ".agents", "skills"), "same-name", [
|
|
394
|
+
"---",
|
|
395
|
+
"name: same-name",
|
|
396
|
+
"description: 我来自项目级",
|
|
397
|
+
"---",
|
|
398
|
+
""
|
|
399
|
+
].join("\n"));
|
|
400
|
+
writeSkill(join(agentsHome, "skills"), "same-name", [
|
|
401
|
+
"---",
|
|
402
|
+
"name: same-name",
|
|
403
|
+
"description: 我来自用户级",
|
|
404
|
+
"---",
|
|
405
|
+
""
|
|
406
|
+
].join("\n"));
|
|
407
|
+
// 只有一个用户级才有的技能,证明它不该被埋掉
|
|
408
|
+
writeSkill(join(agentsHome, "skills"), "only-user", ["---", "name: only-user", "description: 只有用户级有", "---", ""].join("\n"));
|
|
409
|
+
|
|
410
|
+
const r = discoverCapabilities({ projectRoot, dshHome, agentsHome, profileDir: join(base, "none") });
|
|
411
|
+
assert.equal(r.counts.skills, 2);
|
|
412
|
+
const same = r.skills.find((s) => s.name === "same-name");
|
|
413
|
+
assert.equal(same.summary, "我来自项目级", "同名时项目级(rank 200)必须赢");
|
|
414
|
+
assert.equal(same.origin.source, "project-agents");
|
|
415
|
+
assert.ok(r.skills.find((s) => s.name === "only-user"), "不重名的用户级技能必须保留");
|
|
416
|
+
assert.equal(r.shadowed.length, 1);
|
|
417
|
+
assert.equal(r.shadowed[0].hidden, "user-agents");
|
|
418
|
+
assert.equal(r.shadowed[0].by, "project-agents");
|
|
419
|
+
} finally {
|
|
420
|
+
rmSync(base, { recursive: true, force: true });
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("总入口:MCP 目录与连接是两件事,catalog 不算「已连接」", () => {
|
|
425
|
+
const base = mkdtempSync(join(tmpdir(), "mneme-cap-mcp-"));
|
|
426
|
+
try {
|
|
427
|
+
const dshHome = join(base, ".dsh");
|
|
428
|
+
mkdirSync(join(dshHome, "storages"), { recursive: true });
|
|
429
|
+
writeFileSync(
|
|
430
|
+
join(dshHome, "storages", "mcp_connector.json"),
|
|
431
|
+
JSON.stringify({
|
|
432
|
+
unit: { name: "mcp_connector", version: 1 },
|
|
433
|
+
tables: {
|
|
434
|
+
// 目录里有 2 条,但一条都没连
|
|
435
|
+
catalog: {
|
|
436
|
+
remote: {
|
|
437
|
+
connectors: [
|
|
438
|
+
{ id: "a", name: "甲", vendor: "V", category: "C", summary: "s", servers: [{ url: "https://secret.example/mcp" }], auth: { mode: "none" }, probeStatus: "pass" },
|
|
439
|
+
{ id: "b", name: "乙", servers: [], auth: { mode: "oauth" }, probeStatus: "fail" }
|
|
440
|
+
]
|
|
441
|
+
}
|
|
442
|
+
},
|
|
443
|
+
connections: {},
|
|
444
|
+
grants: {}
|
|
445
|
+
}
|
|
446
|
+
}),
|
|
447
|
+
"utf8"
|
|
448
|
+
);
|
|
449
|
+
const r = discoverCapabilities({ projectRoot: null, dshHome, agentsHome: join(base, "none"), profileDir: join(base, "none") });
|
|
450
|
+
assert.equal(r.counts.mcpCatalog, 2);
|
|
451
|
+
assert.equal(r.counts.mcpConnections, 0);
|
|
452
|
+
assert.equal(r.mcp.connections.length, 0, "connections 为空就是空,不许拿 catalog 顶替");
|
|
453
|
+
// catalog 条目标 catalog 可见性,不是 declared / read
|
|
454
|
+
assert.equal(r.mcp.catalog[0].visibility, "catalog");
|
|
455
|
+
// 私有端点 URL 不进清单(清单会被截图)
|
|
456
|
+
const serialized = JSON.stringify(r.mcp.catalog);
|
|
457
|
+
assert.ok(!serialized.includes("secret.example"), "servers 里的 url 不该被输出");
|
|
458
|
+
} finally {
|
|
459
|
+
rmSync(base, { recursive: true, force: true });
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
test("总入口:插件 bundles 与 patch 分开报,disabled 的能数出来", () => {
|
|
464
|
+
const base = mkdtempSync(join(tmpdir(), "mneme-cap-plug-"));
|
|
465
|
+
try {
|
|
466
|
+
const profileDir = join(base, "profile");
|
|
467
|
+
mkdirSync(profileDir, { recursive: true });
|
|
468
|
+
writeFileSync(
|
|
469
|
+
join(profileDir, "package.json"),
|
|
470
|
+
JSON.stringify({ dsh: { profile: { bundles: ["@deepseek-ai/dsh-base", "dsh-study-desk"] } } }),
|
|
471
|
+
"utf8"
|
|
472
|
+
);
|
|
473
|
+
writeFileSync(
|
|
474
|
+
join(profileDir, "cordis.patch.yml"),
|
|
475
|
+
["- id: mnemon", " disabled: true", "- id: ui-theme", " name: '@deepseek-ai/dsh-client-ui-theme'"].join("\n"),
|
|
476
|
+
"utf8"
|
|
477
|
+
);
|
|
478
|
+
const r = discoverCapabilities({ projectRoot: null, dshHome: join(base, "none"), agentsHome: join(base, "none"), profileDir });
|
|
479
|
+
assert.equal(r.counts.pluginBundles, 2);
|
|
480
|
+
assert.equal(r.counts.pluginPatches, 2);
|
|
481
|
+
assert.equal(r.plugins.patches.filter((p) => p.disabled === true).length, 1);
|
|
482
|
+
// 声明不等于生效:两类都标 declared,运行时状态单列
|
|
483
|
+
assert.equal(r.plugins.bundles[0].visibility, "declared");
|
|
484
|
+
assert.match(r.plugins.runtimeOnly.what, /真实加载结果/);
|
|
485
|
+
} finally {
|
|
486
|
+
rmSync(base, { recursive: true, force: true });
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test("MCP 使用记录要按 serverName 匹配,不是按 catalog 的 id(CodeRabbit 抓到的映射缺陷)", () => {
|
|
491
|
+
// 实测回归:会话日志里的 MCP 工具名是 mcp__<serverName>__<tool>,而 serverName 与
|
|
492
|
+
// catalog 的 `id` **不保证相同**——本机 109 条里有 41 条不一致(如
|
|
493
|
+
// id=baidu-maps ↔ serverName=baidu-map;id=polardb ↔ serverName=polardbx)。
|
|
494
|
+
// 早期只按 id 匹配,那些 server 的真实调用会被显示成 candidate(用过却看着没用过)。
|
|
495
|
+
const base = mkdtempSync(join(tmpdir(), "mneme-cap-mcpname-"));
|
|
496
|
+
try {
|
|
497
|
+
const dshHome = join(base, ".dsh");
|
|
498
|
+
mkdirSync(join(dshHome, "storages"), { recursive: true });
|
|
499
|
+
writeFileSync(
|
|
500
|
+
join(dshHome, "storages", "mcp_connector.json"),
|
|
501
|
+
JSON.stringify({
|
|
502
|
+
unit: { name: "mcp_connector", version: 1 },
|
|
503
|
+
tables: {
|
|
504
|
+
catalog: {
|
|
505
|
+
remote: {
|
|
506
|
+
connectors: [
|
|
507
|
+
// id 与 serverName 故意不一致——这正是要钉住的情形
|
|
508
|
+
{ id: "baidu-maps", name: "百度地图", servers: [{ serverName: "baidu-map" }] },
|
|
509
|
+
// 一个 id 挂多个 serverName
|
|
510
|
+
{ id: "pkulaw-legal", name: "北大法宝", servers: [{ serverName: "pkulaw-law-search" }, { serverName: "pkulaw-case-keyword" }] },
|
|
511
|
+
// 一致的普通情形(回归保护)
|
|
512
|
+
{ id: "github", name: "GitHub", servers: [{ serverName: "github" }] }
|
|
513
|
+
]
|
|
514
|
+
}
|
|
515
|
+
},
|
|
516
|
+
connections: {},
|
|
517
|
+
grants: {}
|
|
518
|
+
}
|
|
519
|
+
}),
|
|
520
|
+
"utf8"
|
|
521
|
+
);
|
|
522
|
+
// 会话日志里用的是 serverName(baidu-map),不是 id(baidu-maps)
|
|
523
|
+
const sessionId = "session-mcpname-0001";
|
|
524
|
+
const dir = join(base, "sessions", "work", sessionId);
|
|
525
|
+
mkdirSync(dir, { recursive: true });
|
|
526
|
+
const lines = [
|
|
527
|
+
JSON.stringify({
|
|
528
|
+
type: "assistant/message",
|
|
529
|
+
time: 1792000000000,
|
|
530
|
+
data: { turn: 1, step: 1, message: { role: "assistant", content: [{ type: "tool-call", name: "mcp__baidu-map__geocode", arguments: "{}" }] } }
|
|
531
|
+
}),
|
|
532
|
+
JSON.stringify({
|
|
533
|
+
type: "assistant/message",
|
|
534
|
+
time: 1792000000001,
|
|
535
|
+
data: { turn: 1, step: 2, message: { role: "assistant", content: [{ type: "tool-call", name: "mcp__pkulaw-case-keyword__search", arguments: "{}" }] } }
|
|
536
|
+
})
|
|
537
|
+
].join("\n");
|
|
538
|
+
writeFileSync(join(dir, "session.v4.jsonl.zstd"), zlib.zstdCompressSync(Buffer.from(lines, "utf8")));
|
|
539
|
+
|
|
540
|
+
const r = discoverCapabilities({ projectRoot: null, dshHome, agentsHome: join(base, "none"), profileDir: join(base, "none"), sessionDir: join(base, "sessions") });
|
|
541
|
+
|
|
542
|
+
// serverNames 必须被留下来
|
|
543
|
+
const baidu = r.mcp.catalog.find((c) => c.id === "baidu-maps");
|
|
544
|
+
assert.deepEqual(baidu.serverNames, ["baidu-map"], "serverName 要留在 catalog 条目上");
|
|
545
|
+
|
|
546
|
+
// 关键:按 serverName 匹配上了 → 不是 candidate
|
|
547
|
+
assert.equal(baidu.usage.state, "trusted", "id 与 serverName 不一致时仍要匹配到使用记录");
|
|
548
|
+
assert.equal(baidu.usage.name, "baidu-map", "usage 记的是 serverName");
|
|
549
|
+
assert.equal(baidu.usage.uses, 1);
|
|
550
|
+
|
|
551
|
+
// 一个 id 挂多个 serverName:命中任意一个即可
|
|
552
|
+
const pkulaw = r.mcp.catalog.find((c) => c.id === "pkulaw-legal");
|
|
553
|
+
assert.equal(pkulaw.usage.uses, 1, "多 serverName 时命中其一即可");
|
|
554
|
+
assert.equal(pkulaw.usage.name, "pkulaw-case-keyword");
|
|
555
|
+
|
|
556
|
+
// 一致的情形照旧
|
|
557
|
+
const gh = r.mcp.catalog.find((c) => c.id === "github");
|
|
558
|
+
assert.equal(gh.usage.state, "candidate", "没用过的仍是 candidate");
|
|
559
|
+
assert.equal(gh.serverNames[0], "github");
|
|
560
|
+
} finally {
|
|
561
|
+
rmSync(base, { recursive: true, force: true });
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
test("总入口:坏的 mcp_connector.json 不抛错,如实记 unreadable", () => {
|
|
566
|
+
const base = mkdtempSync(join(tmpdir(), "mneme-cap-badmcp-"));
|
|
567
|
+
try {
|
|
568
|
+
const dshHome = join(base, ".dsh");
|
|
569
|
+
mkdirSync(join(dshHome, "storages"), { recursive: true });
|
|
570
|
+
writeFileSync(join(dshHome, "storages", "mcp_connector.json"), "{ 这不是 JSON", "utf8");
|
|
571
|
+
const r = discoverCapabilities({ projectRoot: null, dshHome, agentsHome: join(base, "none"), profileDir: join(base, "none") });
|
|
572
|
+
assert.equal(r.counts.mcpCatalog, 0);
|
|
573
|
+
assert.match(String(r.mcp.unreadable), /JSON 解析失败/);
|
|
574
|
+
} finally {
|
|
575
|
+
rmSync(base, { recursive: true, force: true });
|
|
576
|
+
}
|
|
577
|
+
});
|