@modusensus/dsh-mneme 0.8.16 → 0.9.1
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 +91 -16
- package/assets/icon.svg +2 -2
- package/bin/cli.mjs +8 -2
- package/docs/CONFIGURATION.md +27 -1
- package/docs/CONTINUITY.md +18 -3
- package/docs/SEMANTIC.md +3 -3
- package/docs/WORKFLOWS.md +299 -0
- package/lib/api-standalone.js +78 -12
- package/lib/api.js +403 -62
- package/lib/candidates.js +454 -0
- package/lib/capabilities.js +819 -0
- package/lib/client.js +2294 -423
- package/lib/config.js +63 -6
- package/lib/continuity.js +35 -6
- package/lib/dream/decisions.js +17 -1
- package/lib/dream/sleep.js +10 -2
- package/lib/dream.js +5 -1
- package/lib/embedding.js +116 -15
- package/lib/entities/extractor.js +4 -0
- package/lib/index.js +81 -12
- package/lib/inject.js +40 -4
- package/lib/lang.js +124 -0
- package/lib/pipeline-activities.js +178 -0
- package/lib/pipeline-attribution.js +165 -0
- package/lib/pipeline-command.js +279 -0
- package/lib/pipeline-confirm.js +142 -0
- package/lib/pipeline-exec.js +174 -0
- package/lib/pipeline-nudge.js +1281 -0
- package/lib/pipeline-observations.js +181 -0
- package/lib/pipeline-observe.js +133 -0
- package/lib/pipeline-runner.js +346 -0
- package/lib/pipeline-runs.js +442 -0
- package/lib/pipeline.js +655 -0
- package/lib/scope.js +16 -4
- package/lib/semantic.js +66 -10
- package/lib/serve.js +1 -1
- package/lib/service.js +59 -18
- package/lib/session-usage.js +350 -0
- package/lib/settings.js +138 -1
- package/lib/store.js +132 -6
- package/lib/summarize.js +30 -0
- package/lib/tools.js +11 -2
- package/package.json +3 -3
- package/scripts/changelog-prep.mjs +62 -7
- package/scripts/check-sync.js +8 -6
- package/scripts/publish-gate.mjs +197 -0
- package/scripts/release-docs.mjs +204 -0
- package/scripts/release-notes.mjs +162 -0
- package/src/api-standalone.js +78 -12
- package/src/api.js +403 -62
- package/src/candidates.js +454 -0
- package/src/capabilities.js +819 -0
- package/src/config.js +63 -6
- package/src/continuity.js +35 -6
- package/src/dream/decisions.js +17 -1
- package/src/dream/sleep.js +10 -2
- package/src/dream.js +5 -1
- package/src/embedding.js +116 -15
- package/src/entities/extractor.js +4 -0
- package/src/index.js +81 -12
- package/src/inject.js +40 -4
- package/src/lang.js +124 -0
- package/src/pipeline-activities.js +178 -0
- package/src/pipeline-attribution.js +165 -0
- package/src/pipeline-command.js +279 -0
- package/src/pipeline-confirm.js +142 -0
- package/src/pipeline-exec.js +174 -0
- package/src/pipeline-nudge.js +1281 -0
- package/src/pipeline-observations.js +181 -0
- package/src/pipeline-observe.js +133 -0
- package/src/pipeline-runner.js +346 -0
- package/src/pipeline-runs.js +442 -0
- package/src/pipeline.js +655 -0
- package/src/scope.js +16 -4
- package/src/semantic.js +66 -10
- package/src/serve.js +1 -1
- package/src/service.js +59 -18
- package/src/session-usage.js +350 -0
- package/src/settings.js +138 -1
- package/src/store.js +132 -6
- package/src/summarize.js +30 -0
- package/src/tools.js +11 -2
- package/test/api-hardening.test.js +173 -0
- package/test/api.test.js +244 -7
- package/test/candidates.test.js +345 -0
- package/test/capabilities.test.js +577 -0
- package/test/changelog-prep.test.js +74 -8
- package/test/cli-enums.test.js +37 -0
- package/test/client.test.js +510 -2
- package/test/continuity.test.js +84 -0
- package/test/docs-config-keys.test.js +65 -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/helpers/write-admission-samples.js +6 -2
- package/test/inject-recall.test.js +32 -4
- package/test/lib-smoke.test.js +20 -0
- package/test/llm-audit.test.js +92 -1
- package/test/mint-source.test.js +202 -0
- package/test/persona-api.test.js +172 -0
- package/test/persona-flag.test.js +36 -0
- package/test/persona-inject.test.js +145 -0
- package/test/persona-settings.test.js +145 -0
- package/test/pipeline-activities.test.js +155 -0
- package/test/pipeline-attribution.test.js +175 -0
- package/test/pipeline-command.test.js +243 -0
- package/test/pipeline-confirm.test.js +148 -0
- package/test/pipeline-exec.test.js +172 -0
- package/test/pipeline-nudge.test.js +1036 -0
- package/test/pipeline-observations.test.js +157 -0
- package/test/pipeline-observe.test.js +127 -0
- package/test/pipeline-r1r2.test.js +149 -0
- package/test/pipeline-runner.test.js +442 -0
- package/test/pipeline-runs.test.js +767 -0
- package/test/pipeline.test.js +636 -0
- package/test/placement.test.js +1 -1
- package/test/publish-gate.test.js +272 -0
- package/test/recall-retention.test.js +128 -0
- package/test/reindex-backfill.test.js +83 -4
- package/test/release-docs.test.js +130 -0
- package/test/release-notes.test.js +240 -0
- package/test/release-workflow.test.js +93 -0
- package/test/route-policy.test.js +113 -0
- package/test/scope-distill-stamp.test.js +135 -0
- package/test/security-doc.test.js +139 -0
- package/test/sensitive-scan.test.js +44 -0
- package/test/serve.test.js +1 -1
- package/test/session-usage.test.js +308 -0
- package/test/store-write-path-columns.test.js +191 -0
- package/test/summarize.test.js +176 -0
- package/test/supersede-state-tracking.test.js +4 -4
- package/test/tools.test.js +16 -1
- package/test/workflow-guards.test.js +156 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// SECURITY.md 的两处声明会随时间变成谎话,而且没人会注意到:
|
|
2
|
+
// 一是「Supported Versions」表——0.9.0 发了两个月它还在写 0.8.x 活跃开发中(#SECURITY 审计实测);
|
|
3
|
+
// 二是文件里的相对链接——docs/DAEMON.md 实际在 dsh-mneme/docs/ 下,链接指错了也没人点。
|
|
4
|
+
// 这里把两件事都锁住:版本行必须等于 package.json 的 major.minor,链接必须真的能 stat 到。
|
|
5
|
+
// 不锁「正文里出现的版本号」——历史审计节记录的是过去的事实,写死版本号在那里是对的。
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { createStore } from "../src/store.js";
|
|
12
|
+
import { createService } from "../src/service.js";
|
|
13
|
+
import { createSettings } from "../src/settings.js";
|
|
14
|
+
import { createApi } from "../src/api.js";
|
|
15
|
+
|
|
16
|
+
const pkgRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
17
|
+
const repoRoot = path.dirname(pkgRoot);
|
|
18
|
+
const docPath = path.join(repoRoot, "SECURITY.md");
|
|
19
|
+
const doc = readFileSync(docPath, "utf8");
|
|
20
|
+
|
|
21
|
+
// package.json 的 version → 支持表里应当出现的那一行("0.9.0" → "0.9.x")。
|
|
22
|
+
const { version } = JSON.parse(readFileSync(path.join(pkgRoot, "package.json"), "utf8"));
|
|
23
|
+
const [major, minor] = version.split(".");
|
|
24
|
+
const expectedVersionRow = `${major}.${minor}.x`;
|
|
25
|
+
|
|
26
|
+
// 取某一节(到下一个同级 ## 标题为止)。用节而不是全文 grep:
|
|
27
|
+
// 历史审计节里也有 "v0.x.y" 字样,全文匹配会假绿。
|
|
28
|
+
function section(heading) {
|
|
29
|
+
const start = doc.indexOf(heading);
|
|
30
|
+
assert.ok(start >= 0, `SECURITY.md 里应有「${heading}」一节`);
|
|
31
|
+
const rest = doc.slice(start + heading.length);
|
|
32
|
+
const end = rest.search(/\r?\n## /);
|
|
33
|
+
return end >= 0 ? rest.slice(0, end) : rest;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test("SECURITY.md 的支持版本表跟着 package.json 走(中英各一份)", () => {
|
|
37
|
+
for (const heading of ["## Supported Versions", "## 支持版本"]) {
|
|
38
|
+
const body = section(heading);
|
|
39
|
+
// 表第一行就是「当前支持」的那一行;锁它,别锁整表(历史版本行本来就该在)。
|
|
40
|
+
const rows = body.split(/\r?\n/).filter((l) => /^\|\s*[\d<]/.test(l));
|
|
41
|
+
assert.ok(rows.length >= 2, `${heading} 的版本表应至少两行,实际 ${rows.length} 行`);
|
|
42
|
+
const current = rows[0].split("|")[1].trim();
|
|
43
|
+
assert.equal(current, expectedVersionRow,
|
|
44
|
+
`${heading} 的首行应写 ${expectedVersionRow}(package.json 为 ${version}),实际写了 ${current}`);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("SECURITY.md 的版本表在两种语言之间是同一张表", () => {
|
|
49
|
+
const versions = (heading) => section(heading)
|
|
50
|
+
.split(/\r?\n/)
|
|
51
|
+
.filter((l) => /^\|\s*[\d<]/.test(l))
|
|
52
|
+
.map((l) => l.split("|")[1].trim());
|
|
53
|
+
assert.deepEqual(versions("## 支持版本"), versions("## Supported Versions"),
|
|
54
|
+
"中文表与英文表的版本行必须逐行一致,否则读者会看到两个不同的支持范围");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("SECURITY.md 里的相对链接都能落到真实文件", () => {
|
|
58
|
+
// 链接从仓库根解析(README 与 SECURITY.md 都在根上)。
|
|
59
|
+
// 只查相对路径:外链、锚点、mailto 不归这里管。
|
|
60
|
+
const links = [...doc.matchAll(/\[[^\]]*\]\(([^)\s]+)\)/g)]
|
|
61
|
+
.map((m) => m[1])
|
|
62
|
+
.filter((href) => !/^(?:[a-z][a-z0-9+.-]*:|#)/i.test(href))
|
|
63
|
+
.map((href) => href.split("#")[0])
|
|
64
|
+
.filter(Boolean);
|
|
65
|
+
assert.ok(links.length >= 2, `应解析出至少 2 条相对链接,实际 ${links.length}——正则失配会让这个用例变成永真`);
|
|
66
|
+
const broken = links.filter((href) => !existsSync(path.resolve(repoRoot, href)));
|
|
67
|
+
assert.deepEqual(broken, [], "这些相对链接指向了不存在的文件:" + broken.join(", "));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("SECURITY.md 不再把供应商写成已停用的那家", () => {
|
|
71
|
+
// 依赖扫描的实况是 gitleaks + dependency-review + OSV-Scanner + Dependabot,
|
|
72
|
+
// 文档里曾长期写着第三方商业扫描平台(该平台并不在 CI 里跑)。
|
|
73
|
+
assert.ok(!/OrbisAI/i.test(doc), "依赖扫描工具应写 CI 里真实在跑的那几个,不能留旧供应商名");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// 安全模型节那两张路由清单是「从代码抄出来的」,抄错了没人会发现:
|
|
77
|
+
// 实测就漏过 /persona(token-write,GET 开放)与 /conflicts(token,只读也要 token)。
|
|
78
|
+
// 这里不重跑鉴权逻辑(那是 route-policy.test.js 的事),只锁「文档清单 == 代码里的 policy 声明」:
|
|
79
|
+
// 以真实装配出来的路由为准,双向比对——文档少写、多写、写错边,都红。
|
|
80
|
+
function declaredRoutes() {
|
|
81
|
+
const store = createStore(":memory:");
|
|
82
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
83
|
+
const settings = createSettings(store.db);
|
|
84
|
+
const mounted = [];
|
|
85
|
+
createApi(
|
|
86
|
+
{ webServer: { register(route) { mounted.push(route); return () => {}; } } },
|
|
87
|
+
service, settings, { add() {}, remove() {}, list: () => [] }, null, undefined, "secret-token"
|
|
88
|
+
);
|
|
89
|
+
// 路由名去掉公共前缀,文档里写的是 /export 而不是 /api/dsh-mneme/export。
|
|
90
|
+
return mounted.map((route) => ({
|
|
91
|
+
name: route.path.replace("/api/dsh-mneme", "") || "/",
|
|
92
|
+
policy: route.policy
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 从清单行里抠出反引号包着的路由。只认「可选方法 + 斜杠开头」的形状,
|
|
97
|
+
// 这样 `GET`/`HEAD` 这种纯方法名不会被当成路径。
|
|
98
|
+
function routesInBullet(line) {
|
|
99
|
+
const out = [];
|
|
100
|
+
for (const m of line.matchAll(/`([^`]+)`/g)) {
|
|
101
|
+
const hit = m[1].trim().match(/^(?:GET|HEAD|POST|PUT|PATCH|DELETE)?\s*(\/\S*)$/);
|
|
102
|
+
if (hit) out.push(hit[1]);
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function bullet(prefix) {
|
|
108
|
+
const line = doc.split(/\r?\n/).find((l) => l.startsWith(prefix));
|
|
109
|
+
assert.ok(line, `SECURITY.md 里应有以「${prefix}」开头的清单行`);
|
|
110
|
+
return routesInBullet(line);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 文档里的 /semantic/graph/* 覆盖代码里的 /semantic/graph/ego 与 /semantic/graph/entity-attrs。
|
|
114
|
+
function covers(docEntry, name) {
|
|
115
|
+
if (docEntry === name) return true;
|
|
116
|
+
return docEntry.endsWith("/*") && name.startsWith(docEntry.slice(0, -1));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
test("SECURITY.md 的开放读清单与代码里的 policy 声明一致", () => {
|
|
120
|
+
const open = declaredRoutes().filter((r) => r.policy === "open" || r.policy === "token-write");
|
|
121
|
+
for (const prefix of ["- **Open reads", "- **开放读"]) {
|
|
122
|
+
const listed = bullet(prefix);
|
|
123
|
+
const missing = open.filter((r) => !listed.some((e) => covers(e, r.name))).map((r) => r.name);
|
|
124
|
+
assert.deepEqual(missing, [], `${prefix} 漏了这些实际开放的路由:${missing.join(", ")}`);
|
|
125
|
+
const stale = listed.filter((e) => !open.some((r) => covers(e, r.name)));
|
|
126
|
+
assert.deepEqual(stale, [], `${prefix} 写了代码里并不开放的路由:${stale.join(", ")}`);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("SECURITY.md 的受门控清单与代码里的 policy 声明一致", () => {
|
|
131
|
+
const gated = declaredRoutes().filter((r) => r.policy === "token" || r.policy === "token-write");
|
|
132
|
+
for (const prefix of ["- **Gated routes", "- **受门控的路由"]) {
|
|
133
|
+
const listed = bullet(prefix);
|
|
134
|
+
const missing = gated.filter((r) => !listed.some((e) => covers(e, r.name))).map((r) => r.name);
|
|
135
|
+
assert.deepEqual(missing, [], `${prefix} 漏了这些实际受门控的路由:${missing.join(", ")}`);
|
|
136
|
+
const stale = listed.filter((e) => !gated.some((r) => covers(e, r.name)));
|
|
137
|
+
assert.deepEqual(stale, [], `${prefix} 写了代码里并不受门控的路由:${stale.join(", ")}`);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
@@ -205,3 +205,47 @@ test("长文本上的回溯有上界:写阻塞类回归的计时护栏", () =>
|
|
|
205
205
|
assert.ok(ms < 2000, `${text.length} 字符耗时 ${ms.toFixed(1)}ms,超 2000ms 预算(回溯上界被破坏?)`);
|
|
206
206
|
}
|
|
207
207
|
});
|
|
208
|
+
|
|
209
|
+
// 仓库文本守卫:语料里的凭据值必须以拼接形式存在,仓库里不得留下完整形状。
|
|
210
|
+
// 触发这件事的是 GitHub secret scanning 的 alert #1——write-admission-samples.js 里
|
|
211
|
+
// openai-key 那条曾经是整串字面量(同文件其它四条都已是 assemble 拼接),被报成
|
|
212
|
+
// openai_api_key 的 open=true 告警。它本身不是泄露(正文是 a-z、0-9 的顺序串),
|
|
213
|
+
// 但扫描器只看形状;而这条漏网说明「逐条记得改」靠不住,所以用一条测试把它钉住:
|
|
214
|
+
// 以后谁再往语料里加字面量,这里先红,不用等 GitHub 来通知。
|
|
215
|
+
test("语料里不留完整形状的凭据字面量(secret scanning alert #1 的回归锁)", async () => {
|
|
216
|
+
const { readFile, readdir } = await import("node:fs/promises");
|
|
217
|
+
const helpersDir = new URL("./helpers/", import.meta.url);
|
|
218
|
+
const files = (await readdir(helpersDir)).filter((f) => f.endsWith(".js"));
|
|
219
|
+
// 前缀集合与长度都对齐 src/sensitive-scan.js 的 github_token / slack_token /
|
|
220
|
+
// openai_key 三条规则——只有形状一致才谈得上「扫描器会认」,前缀写窄了等于给
|
|
221
|
+
// gho_ / ghs_ / xoxp- 这些同族凭据开后门。改那三条规则时这里要跟着改。
|
|
222
|
+
// 唯一放宽的是 sk- 的下限:生产规则是 24,这里用 20,宁可多报——漏报的代价是
|
|
223
|
+
// GitHub 再报一次告警,多报的代价只是有人来把这行改成拼接。
|
|
224
|
+
const rules = [
|
|
225
|
+
// kind 与生产判据对齐,下面的断言拿它反查 scanSensitive 是不是真认这个形状。
|
|
226
|
+
{ re: /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}\b/, what: "OpenAI key", kind: "openai_key", probe: `sk-${"a1".repeat(13)}` },
|
|
227
|
+
{ re: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b/, what: "GitHub token", kind: "github_token", probe: `ghs_${"a1".repeat(18)}` },
|
|
228
|
+
{ re: /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/, what: "Slack token", kind: "slack_token", probe: `xoxp-${"a1".repeat(8)}` }
|
|
229
|
+
];
|
|
230
|
+
// 「与生产规则同源」不能只是注释:CodeRabbit 在 #465 指出前一版把前缀写窄了
|
|
231
|
+
// (只认 ghp_ / xoxb-,漏了 gho_/ghs_/xoxp-),注释却写着同源。所以这里反查
|
|
232
|
+
// scanSensitive——守卫的每条规则必须真的被生产判据认成同一个 kind,否则这条红。
|
|
233
|
+
// 生产规则收窄前缀时,这条测试会先于「GitHub 又报一次告警」发现不一致。
|
|
234
|
+
// 两段合起来才叫同源:探针要同时被**守卫的规则**和**生产判据**认下,且 kind 一致。
|
|
235
|
+
// 只查一边的话,任一侧收窄前缀都不会红——那正是 CodeRabbit 指出的失效模式。
|
|
236
|
+
for (const { re, what, kind, probe } of rules) {
|
|
237
|
+
assert.match(probe, re, `守卫自己的 ${what} 规则认不出 ${probe},前缀写窄了?`);
|
|
238
|
+
assert.equal(scanSensitive(probe)?.kind, kind, `生产判据不认 ${probe} 为 ${kind},守卫与它不同源`);
|
|
239
|
+
}
|
|
240
|
+
for (const file of files) {
|
|
241
|
+
const text = await readFile(new URL(file, helpersDir), "utf8");
|
|
242
|
+
for (const { re, what } of rules) {
|
|
243
|
+
const hit = text.match(re);
|
|
244
|
+
assert.equal(
|
|
245
|
+
hit,
|
|
246
|
+
null,
|
|
247
|
+
`${file} 里出现了完整的 ${what} 字面量(${hit?.[0]?.slice(0, 12)}…):按 assemble 拼接,别整串写`
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
});
|
package/test/serve.test.js
CHANGED
|
@@ -83,7 +83,7 @@ test("serve: save → search 走通;token 持久化 kv;二次启动复用同一
|
|
|
83
83
|
// token 持久化在 kv external_api(与 DSH 面板/CLI 共用同一凭证通道)
|
|
84
84
|
assert.equal(rt.settings.getExternalApi().token, rt.api.token);
|
|
85
85
|
|
|
86
|
-
// 检索回执:recorder 已接(index.js:
|
|
86
|
+
// 检索回执:recorder 已接(index.js 的 setRecallRecorder,2026-10-14 在 :339-350),recall_runs 不缺数 —— #363 回帖
|
|
87
87
|
// 向 bridge 承诺过「第三方检索的复用统计不受影响」,这条就是该承诺的回归锁。
|
|
88
88
|
const recallRows = rt.store.db.prepare("SELECT COUNT(*) AS n FROM recall_runs").get();
|
|
89
89
|
assert.ok(recallRows.n >= 1, "recall_runs should record the search above");
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话日志使用证据(src/session-usage.js)的回归测试。
|
|
3
|
+
*
|
|
4
|
+
* 本文件锁的重点是**两类「静默少一大截」的坑**(都真踩过):
|
|
5
|
+
* ① **zstd 多帧**:`zlib.zstdDecompressSync(buf)` 只解**第一帧**且不报错——实测一个
|
|
6
|
+
* 1.5 MB 的会话文件只解出 291 字节,于是扫描得出「零命中」的假结论。所以这里用
|
|
7
|
+
* **两帧拼接的夹具**钉死「两帧都要解出来」。
|
|
8
|
+
* ② **参数在 `arguments` 且是 JSON 字符串**:取 `parameters` 会得到 null,看着像
|
|
9
|
+
* 「调用了但没记参数」。夹具按真实形态写。
|
|
10
|
+
*
|
|
11
|
+
* 另外钉住状态推导的语义边界:`candidate` 是「现存日志里没记录」,不是「从未使用」。
|
|
12
|
+
*/
|
|
13
|
+
import test from "node:test";
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import zlib from "node:zlib";
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
forEachFrame,
|
|
22
|
+
toolCallsInLine,
|
|
23
|
+
identifyCapability,
|
|
24
|
+
deriveUsageState,
|
|
25
|
+
collectUsage,
|
|
26
|
+
summarizeUsage,
|
|
27
|
+
resolveSessionDir,
|
|
28
|
+
findSessionFiles,
|
|
29
|
+
STALE_AFTER_DAYS
|
|
30
|
+
} from "../src/session-usage.js";
|
|
31
|
+
|
|
32
|
+
const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
|
|
33
|
+
|
|
34
|
+
/** 把若干段文本各压成一帧再拼起来,模拟真实的多帧文件。 */
|
|
35
|
+
function multiFrameBuffer(chunks) {
|
|
36
|
+
return Buffer.concat(chunks.map((c) => zlib.zstdCompressSync(Buffer.from(c, "utf8"))));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 造一行真实的 tool-call 记录(参数是 JSON 字符串,不是对象)。 */
|
|
40
|
+
function toolCallLine({ name, args, turn = 1, time = 1700000000000 }) {
|
|
41
|
+
const item = { type: "tool-call", id: `call_${Math.random().toString(36).slice(2)}`, name };
|
|
42
|
+
if (args !== undefined) item.arguments = JSON.stringify(args);
|
|
43
|
+
return JSON.stringify({
|
|
44
|
+
type: "assistant/message",
|
|
45
|
+
time,
|
|
46
|
+
data: { turn, step: 1, message: { role: "assistant", content: [item] } }
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 造一个会话目录,内容按给定数组写进一个文件。 */
|
|
51
|
+
function makeSessionDir(lines) {
|
|
52
|
+
const base = mkdtempSync(join(tmpdir(), "mneme-usage-"));
|
|
53
|
+
const sessionId = "session-test-0001";
|
|
54
|
+
const dir = join(base, "sessions", `--work-${sessionId.slice(0, 8)}--`, sessionId);
|
|
55
|
+
mkdirSync(dir, { recursive: true });
|
|
56
|
+
writeFileSync(join(dir, "session.v4.jsonl.zstd"), multiFrameBuffer(lines), "utf8");
|
|
57
|
+
return { base, dir: join(base, "sessions"), cleanup: () => rmSync(base, { recursive: true, force: true }) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
test("多帧 zstd:**每一帧**都要解出来(只解第一帧是踩过的坑)", () => {
|
|
61
|
+
const buf = multiFrameBuffer(["第一帧的内容", "第二帧的内容", "第三帧的内容"]);
|
|
62
|
+
const seen = [];
|
|
63
|
+
const stat = forEachFrame(buf, (text) => seen.push(text));
|
|
64
|
+
assert.deepEqual(seen, ["第一帧的内容", "第二帧的内容", "第三帧的内容"], "三帧都要被回调到");
|
|
65
|
+
assert.equal(stat.frames, 3);
|
|
66
|
+
assert.equal(stat.failed, 0);
|
|
67
|
+
// 反证:证明这个夹具真能暴露「只解第一帧」——直接 zstdDecompressSync 只能拿到第一段
|
|
68
|
+
assert.equal(zlib.zstdDecompressSync(buf).toString("utf8"), "第一帧的内容");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("多帧 zstd:每帧都解;截断产生的**半行**由 jsonl 层丢掉(宁可少一条,不要错一条)", () => {
|
|
72
|
+
// 真实的「坏帧」不是帧头损坏,而是**文件正在写**:末帧被截断。
|
|
73
|
+
// 实测结论(写这条时验过):zstd 是流式格式,截断帧会**解出部分文本而不抛错**,
|
|
74
|
+
// 且 Node 的绑定不暴露「还有多少输入未消费」,所以帧层的完整性判不了。
|
|
75
|
+
// 兜底放在**行层**:半行 JSON 解析失败会被丢弃——代价是少一条记录,收益是绝不误用。
|
|
76
|
+
const good = zlib.zstdCompressSync(Buffer.from("完整帧的内容", "utf8"));
|
|
77
|
+
|
|
78
|
+
// 帧层:两帧都要回调到(这是最初踩的坑——只解第一帧)
|
|
79
|
+
const seen = [];
|
|
80
|
+
const stat = forEachFrame(Buffer.concat([good, good]), (t) => seen.push(t));
|
|
81
|
+
assert.equal(stat.frames, 2);
|
|
82
|
+
assert.equal(seen.length, 2);
|
|
83
|
+
|
|
84
|
+
// 行层兜底:一个被截断的 tool-call 行不能被当完整记录用
|
|
85
|
+
const fullLine = toolCallLine({ name: "skill", args: { name: "agent-reach" } });
|
|
86
|
+
assert.equal(toolCallsInLine(fullLine).length, 1, "完整行要解析出来");
|
|
87
|
+
const halfLine = fullLine.slice(0, Math.floor(fullLine.length / 2));
|
|
88
|
+
assert.deepEqual(toolCallsInLine(halfLine), [], "半行必须被丢掉,不能猜出一个不完整的调用");
|
|
89
|
+
|
|
90
|
+
// 真实截断文件:整帧 + 半帧 → 完整的那些记录照常统计,半行被丢
|
|
91
|
+
const base = mkdtempSync(join(tmpdir(), "mneme-usage-trunc-"));
|
|
92
|
+
try {
|
|
93
|
+
const sessionId = "session-trunc-0001";
|
|
94
|
+
const dir = join(base, "sessions", "work", sessionId);
|
|
95
|
+
mkdirSync(dir, { recursive: true });
|
|
96
|
+
const truncated = zlib.zstdCompressSync(Buffer.from(fullLine, "utf8")).subarray(0, 12);
|
|
97
|
+
writeFileSync(join(dir, "session.v4.jsonl.zstd"), Buffer.concat([good, truncated]));
|
|
98
|
+
const c = collectUsage({ sessionDir: join(base, "sessions") });
|
|
99
|
+
assert.equal(c.scannedFiles, 1);
|
|
100
|
+
// 好帧里没有 tool-call,截断帧里的调用不完整 → 一条都不该被记
|
|
101
|
+
assert.equal(c.skillCalls, 0, "截断的半行不能被算成一次调用");
|
|
102
|
+
} finally {
|
|
103
|
+
rmSync(base, { recursive: true, force: true });
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("tool-call 解析:参数在 arguments 且是 JSON 字符串", () => {
|
|
108
|
+
// 真实形态:{"type":"tool-call","name":"skill","arguments":"{\"name\":\"agent-reach\"}"}
|
|
109
|
+
const line = toolCallLine({ name: "skill", args: { name: "agent-reach" }, turn: 7, time: 1792000000000 });
|
|
110
|
+
const calls = toolCallsInLine(line);
|
|
111
|
+
assert.equal(calls.length, 1);
|
|
112
|
+
assert.equal(calls[0].name, "skill");
|
|
113
|
+
assert.equal(calls[0].args.name, "agent-reach", "arguments 要二次解析成对象");
|
|
114
|
+
assert.equal(calls[0].turn, 7);
|
|
115
|
+
assert.equal(calls[0].time, 1792000000000);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("tool-call 解析:坏 JSON、非 tool-call 行、参数缺失都要能分清", () => {
|
|
119
|
+
assert.deepEqual(toolCallsInLine("{ 这不是 JSON"), []);
|
|
120
|
+
assert.deepEqual(toolCallsInLine(JSON.stringify({ type: "user/message" })), []);
|
|
121
|
+
// 参数缺失:**调用本身要保留**(它确实发生过),只是 args 为 null
|
|
122
|
+
const noArgs = toolCallLine({ name: "skill" });
|
|
123
|
+
const calls = toolCallsInLine(noArgs);
|
|
124
|
+
assert.equal(calls.length, 1);
|
|
125
|
+
assert.equal(calls[0].args, null);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("能力识别:skill 取 arguments.name;MCP 取 mcp__<server>__<tool> 的 server 段", () => {
|
|
129
|
+
assert.deepEqual(identifyCapability({ name: "skill", args: { name: "agent-reach" } }), {
|
|
130
|
+
kind: "skill",
|
|
131
|
+
name: "agent-reach"
|
|
132
|
+
});
|
|
133
|
+
const mcp = identifyCapability({ name: "mcp__github__get_file_contents", args: {} });
|
|
134
|
+
assert.equal(mcp.kind, "mcp");
|
|
135
|
+
assert.equal(mcp.name, "github", "MCP 按 server 聚合(清单里的条目是 server 粒度)");
|
|
136
|
+
assert.equal(mcp.tool, "get_file_contents");
|
|
137
|
+
// 内置工具不是能力清单里的东西 → null(丢掉,不计 unmatched)
|
|
138
|
+
assert.equal(identifyCapability({ name: "read", args: {} }), null);
|
|
139
|
+
assert.equal(identifyCapability({ name: "pwsh", args: {} }), null);
|
|
140
|
+
// skill 没记 name → 返回 name:null,让上层计入 unmatched(如实报,不硬凑)
|
|
141
|
+
assert.deepEqual(identifyCapability({ name: "skill", args: {} }), { kind: "skill", name: null });
|
|
142
|
+
// mcp__ 后面 server 段为空同理
|
|
143
|
+
assert.equal(identifyCapability({ name: "mcp____x", args: {} }).name, null);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("状态推导:没记录=candidate、用过=trusted、久未用=stale", () => {
|
|
147
|
+
const now = Date.parse("2026-10-16T00:00:00Z");
|
|
148
|
+
assert.equal(deriveUsageState(null, { now }), "candidate");
|
|
149
|
+
assert.equal(deriveUsageState({ uses: 0, lastUsedAt: null }, { now }), "candidate", "0 次也算没记录");
|
|
150
|
+
// 30 天前用过 → trusted
|
|
151
|
+
assert.equal(deriveUsageState({ uses: 3, lastUsedAt: now - 30 * 86400000 }, { now }), "trusted");
|
|
152
|
+
// 超过阈值 → stale
|
|
153
|
+
assert.equal(deriveUsageState({ uses: 3, lastUsedAt: now - (STALE_AFTER_DAYS + 1) * 86400000 }, { now }), "stale");
|
|
154
|
+
// 恰好在阈值上不算 stale(边界)
|
|
155
|
+
assert.equal(deriveUsageState({ uses: 3, lastUsedAt: now - STALE_AFTER_DAYS * 86400000 }, { now }), "trusted");
|
|
156
|
+
// 没有时间戳但有次数 → 不能判 stale,保守给 trusted
|
|
157
|
+
assert.equal(deriveUsageState({ uses: 1, lastUsedAt: null }, { now }), "trusted");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("整条链路:从会话文件聚合出技能与 MCP 计数", () => {
|
|
161
|
+
const ctx = makeSessionDir([
|
|
162
|
+
toolCallLine({ name: "skill", args: { name: "agent-reach" }, turn: 1, time: 1792000000000 }),
|
|
163
|
+
toolCallLine({ name: "mcp__github__get_file_contents", args: {}, turn: 1 }),
|
|
164
|
+
toolCallLine({ name: "skill", args: { name: "agent-reach" }, turn: 2, time: 1792050000000 }),
|
|
165
|
+
toolCallLine({ name: "skill", args: { name: "euthyna" }, turn: 3 }),
|
|
166
|
+
toolCallLine({ name: "read", args: { path: "x" }, turn: 3 }), // 内置,忽略
|
|
167
|
+
toolCallLine({ name: "skill", args: {}, turn: 4 }) // 缺 name → unmatched
|
|
168
|
+
]);
|
|
169
|
+
try {
|
|
170
|
+
const c = collectUsage({ sessionDir: ctx.dir });
|
|
171
|
+
assert.equal(c.present, true);
|
|
172
|
+
assert.equal(c.scannedFiles, 1);
|
|
173
|
+
assert.equal(c.skillCalls, 3, "两次 agent-reach + 一次 euthyna");
|
|
174
|
+
assert.equal(c.mcpCalls, 1);
|
|
175
|
+
assert.equal(c.unmatched, 1, "缺 name 的那次要对不上,不能静默丢");
|
|
176
|
+
assert.equal(c.skills.get("agent-reach").uses, 2);
|
|
177
|
+
// 最近一次使用要取最大的那个时间戳
|
|
178
|
+
assert.equal(c.skills.get("agent-reach").lastUsedAt, 1792050000000);
|
|
179
|
+
assert.equal(c.mcp.get("github").uses, 1);
|
|
180
|
+
} finally {
|
|
181
|
+
ctx.cleanup();
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("降级:会话目录不存在时如实报 present=false,不抛错不编造", () => {
|
|
186
|
+
const base = mkdtempSync(join(tmpdir(), "mneme-usage-none-"));
|
|
187
|
+
try {
|
|
188
|
+
const c = collectUsage({ sessionDir: join(base, "no-such-dir") });
|
|
189
|
+
assert.equal(c.present, false);
|
|
190
|
+
assert.equal(c.scannedFiles, 0);
|
|
191
|
+
assert.equal(c.skillCalls, 0);
|
|
192
|
+
assert.deepEqual([...c.skills.keys()], []);
|
|
193
|
+
const s = summarizeUsage(c);
|
|
194
|
+
assert.deepEqual(s.skills, []);
|
|
195
|
+
assert.deepEqual(s.mcp, []);
|
|
196
|
+
} finally {
|
|
197
|
+
rmSync(base, { recursive: true, force: true });
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("会话目录默认值由 dshHome 推导,不写死本机路径", () => {
|
|
202
|
+
const dir = resolveSessionDir({ dshHome: "/custom/dsh" });
|
|
203
|
+
assert.ok(dir.includes("sessions"), "默认落在 sessions 子目录");
|
|
204
|
+
assert.ok(!/[A-Za-z]:\\Users/i.test(dir), "不得出现本机盘符路径");
|
|
205
|
+
// 环境变量优先
|
|
206
|
+
assert.ok(resolveSessionDir({ dshHome: join(tmpdir(), "x") }).includes("x"));
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("文件发现:**所有版本**的会话文件都要收(只认 v4 会漏掉一半历史)", () => {
|
|
210
|
+
// 实测回归:本机同一会话树下并存三种格式——v4(219)、v3(218)、无版本号(72)。
|
|
211
|
+
// 早期版本只认 `session.v4`,静默漏掉 50 次技能调用(euthyna 44 次),
|
|
212
|
+
// 导致三个明明用过的技能被报成 candidate。所以只要求「以 session 开头」。
|
|
213
|
+
const base = mkdtempSync(join(tmpdir(), "mneme-usage-find-"));
|
|
214
|
+
try {
|
|
215
|
+
const sessions = join(base, "sessions");
|
|
216
|
+
mkdirSync(join(sessions, "a", "id1"), { recursive: true });
|
|
217
|
+
mkdirSync(join(sessions, "b", "id2"), { recursive: true });
|
|
218
|
+
writeFileSync(join(sessions, "a", "id1", "session.v4.jsonl.zstd"), multiFrameBuffer(["x"]));
|
|
219
|
+
writeFileSync(join(sessions, "b", "id2", "session.v3.jsonl.zstd"), multiFrameBuffer(["y"]));
|
|
220
|
+
writeFileSync(join(sessions, "b", "id2", "session.jsonl.zstd"), multiFrameBuffer(["z"]));
|
|
221
|
+
writeFileSync(join(sessions, "a", "id1", "other.jsonl"), "不该被收");
|
|
222
|
+
const found = findSessionFiles(sessions);
|
|
223
|
+
assert.equal(found.files.length, 3, "v4 / v3 / 无版本号三种都要找到,other.jsonl 不要");
|
|
224
|
+
assert.equal(found.unreadable.length, 0);
|
|
225
|
+
assert.equal(found.present, true);
|
|
226
|
+
} finally {
|
|
227
|
+
rmSync(base, { recursive: true, force: true });
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("文件上限可注入,且截断要留痕(CodeRabbit 抓到的静默截断)", () => {
|
|
232
|
+
// 实测回归:早期版本把上限写死在遍历里(MAX_SESSION_FILES=2000),于是
|
|
233
|
+
// · 调用方传 maxFiles: 5000 也只拿到 2000 个——参数无效,无从察觉;
|
|
234
|
+
// · 截断发生在内层,外层算不出 truncated(`found.files.length > maxFiles` 永假)。
|
|
235
|
+
// 结果就是「只扫了一部分」却看起来像扫全了——本仓库反复防的那类静默错。
|
|
236
|
+
const base = mkdtempSync(join(tmpdir(), "mneme-usage-cap-"));
|
|
237
|
+
try {
|
|
238
|
+
const sessions = join(base, "sessions");
|
|
239
|
+
// 造 12 个会话文件,限 5 个:应拿到 5 个 + truncated=true
|
|
240
|
+
for (let i = 0; i < 12; i++) {
|
|
241
|
+
const d = join(sessions, `s${String(i).padStart(2, "0")}`);
|
|
242
|
+
mkdirSync(d, { recursive: true });
|
|
243
|
+
writeFileSync(join(d, "session.v4.jsonl.zstd"), multiFrameBuffer([`line${i}`]));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ① limit 真的生效(不是被内层写死的上限压住)
|
|
247
|
+
const capped = findSessionFiles(sessions, { limit: 5 });
|
|
248
|
+
assert.equal(capped.files.length, 5, "limit=5 就该只拿 5 个");
|
|
249
|
+
assert.equal(capped.truncated, true, "被截断必须留痕");
|
|
250
|
+
|
|
251
|
+
// ② 不设限时全拿到,且不报截断
|
|
252
|
+
const all = findSessionFiles(sessions);
|
|
253
|
+
assert.equal(all.files.length, 12);
|
|
254
|
+
assert.equal(all.truncated, false, "没截断就不该报截断");
|
|
255
|
+
|
|
256
|
+
// ③ collectUsage 透传:maxFiles 生效,且 truncated 透出来
|
|
257
|
+
const c = collectUsage({ sessionDir: sessions, maxFiles: 5 });
|
|
258
|
+
assert.equal(c.scannedFiles, 5);
|
|
259
|
+
assert.equal(c.truncated, true, "collectUsage 要把截断标记透出来");
|
|
260
|
+
assert.equal(c.totalFiles, 5, "totalFiles 是被限制后的数量(不是磁盘总数)");
|
|
261
|
+
|
|
262
|
+
// ④ 给足够大的 maxFiles 时能扫全(旧实现这里会失败:内层卡 2000)
|
|
263
|
+
const c2 = collectUsage({ sessionDir: sessions, maxFiles: 9999 });
|
|
264
|
+
assert.equal(c2.scannedFiles, 12);
|
|
265
|
+
assert.equal(c2.truncated, false);
|
|
266
|
+
} finally {
|
|
267
|
+
rmSync(base, { recursive: true, force: true });
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("目录不可读要与「空目录」**可分辨**(不许同形)", () => {
|
|
272
|
+
// 实测回归:早期版本用 existsSync 定 present、readdirSync 失败直接 return,
|
|
273
|
+
// 于是 ACL 拒绝的目录产出与「可读空目录」逐字段相同的读数
|
|
274
|
+
// (present=true, scannedFiles=0, unreadable=[]),清单侧 175 条全 candidate。
|
|
275
|
+
// 这正是本仓库反复防的那类错:读不到被显示成「读到但没有」。
|
|
276
|
+
const base = mkdtempSync(join(tmpdir(), "mneme-usage-unreadable-"));
|
|
277
|
+
try {
|
|
278
|
+
// ① 不存在的目录:present=false
|
|
279
|
+
const missing = findSessionFiles(join(base, "nope"));
|
|
280
|
+
assert.equal(missing.present, false);
|
|
281
|
+
assert.equal(missing.files.length, 0);
|
|
282
|
+
|
|
283
|
+
// ② 存在的空目录:present=true、无 unreadable
|
|
284
|
+
const empty = join(base, "empty");
|
|
285
|
+
mkdirSync(empty, { recursive: true });
|
|
286
|
+
const emptyRes = findSessionFiles(empty);
|
|
287
|
+
assert.equal(emptyRes.present, true);
|
|
288
|
+
assert.equal(emptyRes.files.length, 0);
|
|
289
|
+
assert.equal(emptyRes.unreadable.length, 0, "空目录不该有 unreadable");
|
|
290
|
+
|
|
291
|
+
// ③ 悬空符号链接:present=true,但**必须有 unreadable**,与空目录不同形
|
|
292
|
+
try {
|
|
293
|
+
symlinkSync(join(base, "no-such-target"), join(empty, "session.dangling.jsonl.zstd"), "junction");
|
|
294
|
+
} catch (error) {
|
|
295
|
+
if (error.code === "EPERM" || error.code === "EACCES") return; // 无权限则跳过本断言
|
|
296
|
+
throw error;
|
|
297
|
+
}
|
|
298
|
+
const dangling = findSessionFiles(empty);
|
|
299
|
+
assert.ok(dangling.unreadable.length > 0, "读不到的项必须留痕,否则与空目录同形");
|
|
300
|
+
// 而且上层能区分:collectUsage 的 readable 会变 false
|
|
301
|
+
const c = collectUsage({ sessionDir: empty });
|
|
302
|
+
assert.equal(c.present, true);
|
|
303
|
+
assert.equal(c.readable, false, "有读不动的项时 readable 必须为 false");
|
|
304
|
+
assert.ok(c.unreadable.length > 0, "unreadable 要透到 collectUsage");
|
|
305
|
+
} finally {
|
|
306
|
+
rmSync(base, { recursive: true, force: true });
|
|
307
|
+
}
|
|
308
|
+
});
|