@modusensus/dsh-mneme 0.8.16 → 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 (73) hide show
  1. package/README.md +16 -3
  2. package/assets/icon.svg +2 -2
  3. package/docs/CONFIGURATION.md +2 -1
  4. package/docs/CONTINUITY.md +18 -3
  5. package/docs/WORKFLOWS.md +299 -0
  6. package/lib/api.js +124 -8
  7. package/lib/candidates.js +454 -0
  8. package/lib/capabilities.js +819 -0
  9. package/lib/client.js +1703 -182
  10. package/lib/config.js +31 -5
  11. package/lib/continuity.js +35 -6
  12. package/lib/dream/decisions.js +17 -1
  13. package/lib/dream/sleep.js +10 -2
  14. package/lib/dream.js +5 -1
  15. package/lib/entities/extractor.js +4 -0
  16. package/lib/index.js +49 -12
  17. package/lib/inject.js +6 -1
  18. package/lib/pipeline.js +534 -0
  19. package/lib/scope.js +16 -4
  20. package/lib/serve.js +1 -1
  21. package/lib/service.js +59 -18
  22. package/lib/session-usage.js +350 -0
  23. package/lib/settings.js +7 -1
  24. package/lib/store.js +55 -3
  25. package/lib/summarize.js +31 -0
  26. package/lib/tools.js +11 -2
  27. package/package.json +1 -1
  28. package/scripts/publish-gate.mjs +197 -0
  29. package/src/api.js +124 -8
  30. package/src/candidates.js +454 -0
  31. package/src/capabilities.js +819 -0
  32. package/src/config.js +31 -5
  33. package/src/continuity.js +35 -6
  34. package/src/dream/decisions.js +17 -1
  35. package/src/dream/sleep.js +10 -2
  36. package/src/dream.js +5 -1
  37. package/src/entities/extractor.js +4 -0
  38. package/src/index.js +49 -12
  39. package/src/inject.js +6 -1
  40. package/src/pipeline.js +534 -0
  41. package/src/scope.js +16 -4
  42. package/src/serve.js +1 -1
  43. package/src/service.js +59 -18
  44. package/src/session-usage.js +350 -0
  45. package/src/settings.js +7 -1
  46. package/src/store.js +55 -3
  47. package/src/summarize.js +31 -0
  48. package/src/tools.js +11 -2
  49. package/test/api.test.js +147 -7
  50. package/test/candidates.test.js +345 -0
  51. package/test/capabilities.test.js +577 -0
  52. package/test/client.test.js +361 -2
  53. package/test/continuity.test.js +84 -0
  54. package/test/embed-ready-poll.test.js +94 -0
  55. package/test/entities.test.js +13 -2
  56. package/test/failure-query.test.js +147 -0
  57. package/test/failure-retention.test.js +105 -0
  58. package/test/graph-api.test.js +36 -2
  59. package/test/inject-recall.test.js +32 -4
  60. package/test/llm-audit.test.js +92 -1
  61. package/test/mint-source.test.js +202 -0
  62. package/test/pipeline.test.js +505 -0
  63. package/test/placement.test.js +1 -1
  64. package/test/publish-gate.test.js +272 -0
  65. package/test/recall-retention.test.js +128 -0
  66. package/test/release-workflow.test.js +77 -0
  67. package/test/scope-distill-stamp.test.js +135 -0
  68. package/test/serve.test.js +1 -1
  69. package/test/session-usage.test.js +308 -0
  70. package/test/store-write-path-columns.test.js +191 -0
  71. package/test/summarize.test.js +176 -0
  72. package/test/supersede-state-tracking.test.js +4 -4
  73. package/test/tools.test.js +16 -1
@@ -0,0 +1,272 @@
1
+ // scripts/publish-gate.mjs 的回归测试。
2
+ //
3
+ // 这一步的价值全在「不许假绿」上:原先的 Verify published version 是
4
+ // echo "Published: $(npm view "@modusensus/dsh-mneme@${V}" version)"
5
+ // 命令替换里的失败不让 `set -e` 退出 —— v0.8.16 那次日志里明明 `404 No match found for version
6
+ // 0.8.16`,step 与 job 却都是 success。下面第一条与第三条各自钉住两端:
7
+ // · 版本已在 registry(幂等跳过路径)→ 立刻通过,不为传播延迟白等;
8
+ // · 等满预算仍看不见 → 必须 `ok:false` 且入口退出 1。
9
+ // 谁要是把「拿不到」改成「跳过校验」或「返回成功」,第三条会红。
10
+ //
11
+ // 全部用例走假时钟 + 注入 probe:零网络、零真实等待,跑起来是毫秒级。
12
+ // 还有一条走真进程(spawnSync)的端到端用例:工作流红不红只看真进程退出码,函数级断言钉不到
13
+ // 「主模块判别 + process.exitCode」那条路径。
14
+ import test from "node:test";
15
+ import assert from "node:assert/strict";
16
+ import { spawnSync } from "node:child_process";
17
+ import { dirname, join } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ import {
21
+ DEFAULT_INTERVAL_MS,
22
+ DEFAULT_TIMEOUT_MS,
23
+ PACKAGE_NAME,
24
+ main,
25
+ parseArgs,
26
+ probeVersion,
27
+ versionUrl,
28
+ waitForPublished
29
+ } from "../scripts/publish-gate.mjs";
30
+
31
+ /** 被测入口的真实路径:端到端用例要把它当子进程跑起来。 */
32
+ const scriptDir = dirname(fileURLToPath(import.meta.url));
33
+
34
+ /** 假时钟:只有 sleep() 会让时间前进,于是超时逻辑可以确定性断言。 */
35
+ function fakeClock() {
36
+ let t = 0;
37
+ return {
38
+ now: () => t,
39
+ sleep: async (ms) => { t += ms; },
40
+ slept: () => t
41
+ };
42
+ }
43
+
44
+ const noop = () => {};
45
+
46
+ /** 前 `misses` 次报 404、之后可见的 probe;锁的是「重试真的发生了」。 */
47
+ function probeMissingTimes(misses, { version = "0.8.16" } = {}) {
48
+ let calls = 0;
49
+ return async () => {
50
+ calls += 1;
51
+ if (calls <= misses) return { ok: false, reason: "missing", detail: "HTTP 404,registry 上还看不到" };
52
+ return { ok: true, version, tarball: "https://registry.npmjs.org/x/-/x-0.8.16.tgz" };
53
+ };
54
+ }
55
+
56
+ test("版本已在 registry:首次探测即通过,不为传播延迟白等(幂等跳过路径)", async () => {
57
+ const clock = fakeClock();
58
+ let sleeps = 0;
59
+ const result = await waitForPublished("0.8.16", {
60
+ now: clock.now,
61
+ sleep: async (ms) => { sleeps += 1; await clock.sleep(ms); },
62
+ probe: probeMissingTimes(0),
63
+ log: noop
64
+ });
65
+ assert.equal(result.ok, true);
66
+ assert.equal(result.attempts, 1);
67
+ assert.equal(result.waitedMs, 0);
68
+ assert.equal(sleeps, 0, "已发布路径不该有任何等待");
69
+ });
70
+
71
+ test("传播延迟:前两次 404、第三次可见 → 通过,且等待时间与重试次数如实报出", async () => {
72
+ const clock = fakeClock();
73
+ const result = await waitForPublished("0.8.16", {
74
+ now: clock.now,
75
+ sleep: clock.sleep,
76
+ intervalMs: 15000,
77
+ probe: probeMissingTimes(2),
78
+ log: noop
79
+ });
80
+ assert.equal(result.ok, true);
81
+ assert.equal(result.attempts, 3);
82
+ assert.equal(result.waitedMs, 30000, "两次间隔的等待要计入");
83
+ assert.ok(result.tarball.includes("0.8.16"));
84
+ });
85
+
86
+ test("等满预算仍看不见:必须 ok:false + reason timeout(防的就是 v0.8.16 那种假绿)", async () => {
87
+ const clock = fakeClock();
88
+ const result = await waitForPublished("0.8.16", {
89
+ now: clock.now,
90
+ sleep: clock.sleep,
91
+ timeoutMs: 30000,
92
+ intervalMs: 15000,
93
+ probe: probeMissingTimes(Infinity),
94
+ log: noop
95
+ });
96
+ assert.equal(result.ok, false, "拿不到版本时绝不是成功");
97
+ assert.equal(result.reason, "timeout");
98
+ assert.equal(result.attempts, 3, "t=0 / 15s / 30s 各一次探测");
99
+ assert.equal(result.waitedMs, 30000);
100
+ assert.match(result.lastError, /404/);
101
+ });
102
+
103
+ test("探测自己耗时到超过预算、但回来时版本已可见:仍然通过(预算约束等待,不约束判定)", async () => {
104
+ // 锁的是刻意选择:单次探测带 HTTP 超时,晚到的成功最多超出预算十几秒;而版本真的在 registry 上
105
+ // 就是发布成功,为晚到几秒判红是假红——闸门要消灭的是假绿,不该顺手制造另一种失真。
106
+ const clock = fakeClock();
107
+ const result = await waitForPublished("0.8.16", {
108
+ now: clock.now,
109
+ sleep: clock.sleep,
110
+ timeoutMs: 15000,
111
+ intervalMs: 15000,
112
+ probe: async () => {
113
+ await clock.sleep(20000); // 这一次探测自己就把预算耗穿了
114
+ return { ok: true, version: "0.8.16", tarball: "" };
115
+ },
116
+ log: noop
117
+ });
118
+ assert.equal(result.ok, true);
119
+ assert.equal(result.attempts, 1);
120
+ assert.equal(result.waitedMs, 20000);
121
+ });
122
+
123
+ test("预算不是间隔整数倍时:最后一轮只睡到超时点,不多睡一整轮", async () => {
124
+ const clock = fakeClock();
125
+ const result = await waitForPublished("0.8.16", {
126
+ now: clock.now,
127
+ sleep: clock.sleep,
128
+ timeoutMs: 20000,
129
+ intervalMs: 15000,
130
+ probe: probeMissingTimes(Infinity),
131
+ log: noop
132
+ });
133
+ assert.equal(result.ok, false);
134
+ assert.equal(result.attempts, 3, "t=0 / 15s / 20s(最后一次落在超时点上)");
135
+ assert.equal(result.waitedMs, 20000);
136
+ assert.equal(clock.slept(), 20000, "总睡眠时间恰为预算,而不是 30000");
137
+ });
138
+
139
+ test("网络异常同样计入重试(既不崩也不当作成功),最终以最后一次原因报出", async () => {
140
+ const clock = fakeClock();
141
+ let calls = 0;
142
+ const result = await waitForPublished("0.8.16", {
143
+ now: clock.now,
144
+ sleep: clock.sleep,
145
+ timeoutMs: 15000,
146
+ intervalMs: 15000,
147
+ probe: async () => { calls += 1; return { ok: false, detail: `请求失败:fetch failed(第 ${calls} 次)` }; },
148
+ log: noop
149
+ });
150
+ assert.equal(result.ok, false);
151
+ assert.equal(result.attempts, 2, "异常也继续重试");
152
+ assert.match(result.lastError, /第 2 次/, "报的是最后一次原因");
153
+ });
154
+
155
+ test("probe 返回形状异常(undefined)也不炸,按拿不到处理", async () => {
156
+ const clock = fakeClock();
157
+ const result = await waitForPublished("0.8.16", {
158
+ now: clock.now,
159
+ sleep: clock.sleep,
160
+ timeoutMs: 0,
161
+ intervalMs: 15000,
162
+ probe: async () => undefined,
163
+ log: noop
164
+ });
165
+ assert.equal(result.ok, false);
166
+ assert.equal(result.reason, "timeout");
167
+ });
168
+
169
+ test("默认预算:超时 10 分钟 / 间隔 15 秒(覆盖实测 ~4.5 分钟的传播延迟)", () => {
170
+ assert.equal(DEFAULT_TIMEOUT_MS, 600000);
171
+ assert.equal(DEFAULT_INTERVAL_MS, 15000);
172
+ });
173
+
174
+ test("versionUrl:scoped 包名整体编码,origin 尾斜杠不重复", () => {
175
+ assert.equal(
176
+ versionUrl(PACKAGE_NAME, "0.8.16"),
177
+ "https://registry.npmjs.org/%40modusensus%2Fdsh-mneme/0.8.16"
178
+ );
179
+ assert.equal(
180
+ versionUrl(PACKAGE_NAME, "0.8.16", "https://registry.npmjs.org/"),
181
+ "https://registry.npmjs.org/%40modusensus%2Fdsh-mneme/0.8.16"
182
+ );
183
+ });
184
+
185
+ test("probeVersion:404 = 还没传播,200 且版本一致 = 可见(带 tarball)", async () => {
186
+ const missing = await probeVersion("0.8.16", {
187
+ fetchImpl: async () => ({ ok: false, status: 404, json: async () => ({}) })
188
+ });
189
+ assert.equal(missing.ok, false);
190
+ assert.equal(missing.reason, "missing");
191
+
192
+ const found = await probeVersion("0.8.16", {
193
+ fetchImpl: async () => ({
194
+ ok: true,
195
+ status: 200,
196
+ json: async () => ({ version: "0.8.16", dist: { tarball: "https://registry.npmjs.org/x/-/x-0.8.16.tgz" } })
197
+ })
198
+ });
199
+ assert.equal(found.ok, true);
200
+ assert.equal(found.tarball, "https://registry.npmjs.org/x/-/x-0.8.16.tgz");
201
+ });
202
+
203
+ test("probeVersion:请求抛错 / 5xx / 响应不是 JSON / 版本对不上,都折成 ok:false 而不抛", async () => {
204
+ const net = await probeVersion("0.8.16", { fetchImpl: async () => { throw new Error("fetch failed"); } });
205
+ assert.equal(net.reason, "network");
206
+
207
+ const http = await probeVersion("0.8.16", { fetchImpl: async () => ({ ok: false, status: 500, json: async () => ({}) }) });
208
+ assert.equal(http.reason, "http");
209
+
210
+ const badJson = await probeVersion("0.8.16", {
211
+ fetchImpl: async () => ({ ok: true, status: 200, json: async () => { throw new Error("boom"); } })
212
+ });
213
+ assert.equal(badJson.reason, "bad-json");
214
+
215
+ const mismatch = await probeVersion("0.8.16", {
216
+ fetchImpl: async () => ({ ok: true, status: 200, json: async () => ({ version: "0.8.15" }) })
217
+ });
218
+ assert.equal(mismatch.ok, false);
219
+ assert.equal(mismatch.reason, "mismatch");
220
+ });
221
+
222
+ test("parseArgs:位置参数给版本号,可覆盖预算;缺版本号如实回报", () => {
223
+ assert.deepEqual(parseArgs(["0.8.16"]), { ok: true, version: "0.8.16", timeoutMs: 600000, intervalMs: 15000 });
224
+ assert.deepEqual(parseArgs(["0.8.16", "--timeout-ms", "1000", "--interval-ms", "500"]), {
225
+ ok: true, version: "0.8.16", timeoutMs: 1000, intervalMs: 500
226
+ });
227
+ assert.equal(parseArgs([]).reason, "missing-version");
228
+ assert.equal(parseArgs(["0.8.16", "0.8.17"]).reason, "missing-version");
229
+ assert.equal(parseArgs(["0.8.16", "--timeout-ms", "abc"]).ok, false);
230
+ });
231
+
232
+ test("入口退出码:通过 → 0;超时 → 1 且 stderr 指明去翻 publish 步日志", async () => {
233
+ const logs = [];
234
+ const errors = [];
235
+ const okCode = await main(["0.8.16"], {
236
+ log: (line) => logs.push(line),
237
+ error: (line) => errors.push(line),
238
+ wait: async (version) => ({ ok: true, version, attempts: 1, waitedMs: 0, tarball: "" })
239
+ });
240
+ assert.equal(okCode, 0);
241
+ assert.equal(errors.length, 0);
242
+
243
+ const failCode = await main(["0.8.16"], {
244
+ log: noop,
245
+ error: (line) => errors.push(line),
246
+ wait: async () => ({ ok: false, reason: "timeout", attempts: 40, waitedMs: 600000, lastError: "HTTP 404" })
247
+ });
248
+ assert.equal(failCode, 1, "闸门未通过必须以非零退出让 job 变红");
249
+ assert.ok(errors.some((line) => line.includes("发布闸门未通过")));
250
+ assert.ok(errors.some((line) => line.includes("publish 步")));
251
+ });
252
+
253
+ test("入口退出码:缺版本号 → 1 并打印用法", async () => {
254
+ const errors = [];
255
+ const code = await main([], { log: noop, error: (line) => errors.push(line), wait: async () => ({ ok: true }) });
256
+ assert.equal(code, 1);
257
+ assert.ok(errors.some((line) => line.includes("用法")));
258
+ });
259
+
260
+ test("真进程端到端:不存在的版本 → 退出码 1 且 stderr 指明原因(这一步红了 job 才会红)", () => {
261
+ // 单测里的 main() 是函数调用,钉不到「主模块判别 + process.exitCode」这条真实退出路径——而
262
+ // 工作流红不红只看真进程的退出码。目标版本刻意取一个永不存在、与发布无关的名字:registry 回
263
+ // 404(连不上网络则是 network 原因),两种都会走到同一个超时分支,所以这条例外不引入网络依赖。
264
+ const result = spawnSync(
265
+ process.execPath,
266
+ [join(scriptDir, "..", "scripts", "publish-gate.mjs"), "0.0.0-mneme-gate-test", "--timeout-ms", "1", "--interval-ms", "1"],
267
+ { encoding: "utf8" }
268
+ );
269
+ assert.equal(result.status, 1, `期望非零退出,实际 ${result.status}(stderr: ${result.stderr})`);
270
+ assert.match(result.stderr, /发布闸门未通过/);
271
+ assert.match(result.stdout, /等待 @modusensus\/dsh-mneme@0\.0\.0-mneme-gate-test/);
272
+ });
@@ -0,0 +1,128 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { readFileSync } from "node:fs";
4
+ import { fileURLToPath } from "node:url";
5
+ import { dirname, join } from "node:path";
6
+ import { createStore } from "../src/store.js";
7
+
8
+ // 2026-10-14 找回:recall_runs 的滚动清理(函数 + 启动调用)在 dd0ed78「release:
9
+ // v0.7.12」里被整块弄丢,而配置键 recallRetentionDays、README.md 与
10
+ // docs/CONFIGURATION.md 一直宣称它有保留期 —— 于是本机那张表只增不减,没人发现
11
+ // (2026-10-13 当天实测就从 16,041 涨到 20,510 行)。
12
+ //
13
+ // 这组测试故意分两半,因为**弄丢的正是「两半都在」这件事**:
14
+ // 1. 行为锁:函数真的删对行、边界正确、空表不炸;
15
+ // 2. 接线锁:index.js 的启动块真的调它,且读的是装配后的 cfg(不是 rawCfg)。
16
+ // 只锁 1 不锁 2,函数写得再对也没人调;只锁 2 不锁 1,调用调的可能是空壳。
17
+ // 第二半用源码文本断言,理由与 test/client.test.js 的 bilingual 锁同款:这是
18
+ // 「接线还在不在」的问题,运行时冒烟测不到一个**在运维时才会跑**的启动分支。
19
+
20
+ const here = dirname(fileURLToPath(import.meta.url));
21
+ const srcText = (name) => readFileSync(join(here, "..", "src", name), "utf8");
22
+
23
+ function seedRun(store, { query, createdAt }) {
24
+ store.saveRecallRun({
25
+ query,
26
+ mode: "auto",
27
+ topK: 5,
28
+ threshold: null,
29
+ candidates: [],
30
+ created_at: createdAt
31
+ });
32
+ }
33
+
34
+ const daysAgo = (n) => new Date(Date.now() - n * 86400000).toISOString();
35
+
36
+ test("保留期:删掉窗口外的行、留下窗口内的行,返回值是真实删除数", () => {
37
+ const store = createStore(":memory:");
38
+ seedRun(store, { query: "老", createdAt: daysAgo(120) });
39
+ seedRun(store, { query: "也老", createdAt: daysAgo(91) });
40
+ seedRun(store, { query: "新", createdAt: daysAgo(89) });
41
+ seedRun(store, { query: "很新", createdAt: daysAgo(1) });
42
+
43
+ const removed = store.purgeRecallRunsOlderThan(90);
44
+ assert.equal(removed, 2, "只有窗口外的两行该被删");
45
+
46
+ // 两侧都排过再比:assert 的是「留下了哪两行」,不是它们的物理顺序
47
+ const left = store.listRecallRunsSince(daysAgo(3650)).rows.map((r) => r.query).sort();
48
+ assert.deepEqual(left, ["很新", "新"].sort());
49
+ store.close();
50
+ });
51
+
52
+ test("保留期:边界按字典序比较,窗口内的留、窗口外的删(两侧各留 1 小时裕量)", () => {
53
+ const store = createStore(":memory:");
54
+ // 不骑刀刃:cutoff 是在**清理那一刻**用 Date.now() 现算的,而造数据也取一次
55
+ // Date.now(),测试跑几毫秒就足以让「正好 90 天前」翻到界外。这里两侧各留
56
+ // 1 小时,锁的是比较方向(`created_at < cutoff` 才删),不是毫秒级巧合。
57
+ const boundary = 90 * 86400000;
58
+ seedRun(store, { query: "窗口内一小时", createdAt: new Date(Date.now() - boundary + 3600000).toISOString() });
59
+ seedRun(store, { query: "窗口外一小时", createdAt: new Date(Date.now() - boundary - 3600000).toISOString() });
60
+
61
+ const removed = store.purgeRecallRunsOlderThan(90);
62
+ assert.equal(removed, 1, "越过界的才删,贴近界的要留");
63
+ assert.deepEqual(store.listRecallRunsSince(daysAgo(3650)).rows.map((r) => r.query), ["窗口内一小时"]);
64
+ store.close();
65
+ });
66
+
67
+ test("保留期:空表返回 0,不抛(清理是 bookkeeping,不许挡启动)", () => {
68
+ const store = createStore(":memory:");
69
+ assert.equal(store.purgeRecallRunsOlderThan(90), 0);
70
+ store.close();
71
+ });
72
+
73
+ test("接线锁:index.js 启动块真的调 purgeRecallRunsOlderThan,且读装配后的 cfg", () => {
74
+ const index = srcText("index.js");
75
+ assert.match(index, /store\.purgeRecallRunsOlderThan\(recallRetentionDays\)/,
76
+ "启动期必须真的调用清理——这对曾在 dd0ed78 里双双蒸发");
77
+ assert.match(index, /cfg\.recallRetentionDays/,
78
+ "必须读装配后的 cfg(面板改保留期才生效),不能读 rawCfg");
79
+ // 失败必须真的出声。这一对当初就是**无声**蒸发的:函数与调用一起没了、没人发现,
80
+ // 表涨到两万余行。清理再失败一次若仍然静默,同一类回归照样看不见——所以这里
81
+ // 两个方向都锁:接住(catch(err),不 rethrow、不挡启动)与出声(真调 logger.warn)。
82
+ // 只锁「不挡启动」会放过静默吞,只锁「出声」会放过把异常抛出去挡启动。
83
+ const at = index.indexOf("store.purgeRecallRunsOlderThan(recallRetentionDays)");
84
+ assert.ok(at > 0, "启动期必须真的调用清理——这对曾在 dd0ed78 里双双蒸发");
85
+ const tail = index.slice(at, at + 600);
86
+ assert.match(tail, /catch \(err\) \{/, "清理失败要接住,不许挡插件启动");
87
+ assert.match(tail, /ctx\.logger\?\.warn\?\.\(/, "清理失败要 warn 出声,静默吞等于把回归再藏一次");
88
+ // 就近确认它落在同一族启动清理之间:失败表 → llm_audit → distill → recall_runs
89
+ const llm = index.indexOf("store.deleteOldLlmAudits");
90
+ const distill = index.indexOf("store.clearDistillRecords");
91
+ assert.ok(llm > 0 && distill > 0 && at > llm, "recall_runs 清理应排在 llm_audit 之后");
92
+ assert.ok(at > distill, "recall_runs 清理应排在 distill_records 之后(与现有启动顺序一致)");
93
+ });
94
+
95
+ test("接线锁:store.js 导出 purgeRecallRunsOlderThan(函数存在但没导出同样是断线)", () => {
96
+ const store = srcText("store.js");
97
+ assert.match(store, /function purgeRecallRunsOlderThan\(days\)/);
98
+ assert.match(store, /^\s*purgeRecallRunsOlderThan,\s*$/m, "必须在 createStore 的返回对象里导出");
99
+ assert.match(store, /DELETE FROM recall_runs WHERE created_at < \?/);
100
+ });
101
+
102
+ // 启动期清理是**一族的四个兄弟**:失败表 / llm_audit / distill_records / recall_runs。
103
+ // 上面那条只锁了 recall_runs 一个,但四个的注释互相引用(llm_audit 那条原文写着
104
+ // 「失败只 warn,与失败表清理同款」),而 2026-10-14 实测前三个全是
105
+ // `catch { /* non-fatal */ }` —— 静默吞,与注释宣称的相反。recall_runs 那一对当初
106
+ // 就是无声蒸发的,所以这一族必须一起锁:只要有一个偷偷退回静默吞,这条就红。
107
+ test("接线锁:四个启动期清理都必须出声(catch(err) + logger.warn,不许静默吞)", () => {
108
+ const index = srcText("index.js");
109
+ const family = [
110
+ ["失败表", "store.deleteOldFailures("],
111
+ ["llm_audit", "store.deleteOldLlmAudits("],
112
+ ["distill_records", "store.clearDistillRecords("],
113
+ ["recall_runs", "store.purgeRecallRunsOlderThan(recallRetentionDays)"]
114
+ ];
115
+ for (const [label, call] of family) {
116
+ const at = index.indexOf(call);
117
+ assert.ok(at > 0, `${label} 的启动期清理调用必须在(弄丢过一次的就是这一族)`);
118
+ const tail = index.slice(at, at + 700);
119
+ assert.match(tail, /catch \(err\) \{/, `${label} 清理失败要接住,不许挡插件启动`);
120
+ assert.match(tail, /ctx\.logger\?\.warn\?\.\(/, `${label} 清理失败要 warn 出声,静默吞等于把回归再藏一次`);
121
+ }
122
+ });
123
+
124
+ test("接线锁:配置键与文档对外宣称的保留期一致(键还在、默认 90 天)", () => {
125
+ const cfgText = srcText("config.js");
126
+ assert.match(cfgText, /recallRetentionDays: z\.natural\(\)\.min\(1\)\.max\(3650\)\.default\(90\)/,
127
+ "配置键被改或删掉时,这条和文档一起要动——别让文档继续说谎");
128
+ });
@@ -0,0 +1,77 @@
1
+ // .github/workflows/release.yml 的作业依赖锁。
2
+ //
3
+ // 防的是一类「声明抢跑」:GitHub Release 是对外的「这个版本已发布」,registry 才是唯一事实。
4
+ // 2026-10-12 修 Verify published version 假闸门(PR #419)时发现隔壁还有同一处豁口——release
5
+ // job 只 needs [verify, test],于是 publish 真失败(token 失效、registry 拒绝)时 Release 照样
6
+ // 建出来:Release 页写着 v0.8.x,registry 上却没有,用户照 Release 装回的是上一个版本。
7
+ //
8
+ // 三条断言各锁一环,缺一环这类豁口就能重开:
9
+ // 1. release 必须等 publish —— 否则「发不出去」和「发出去了」在 Release 页上同形;
10
+ // 2. publish 必须含那道阻塞闸门(publish-gate.mjs)—— 否则 release 等的是一个不做校验的空壳,
11
+ // 第 1 条形同虚设(改回 `npm view` 一次性查询同样会退回假绿);
12
+ // 3. release 仍要等 verify 与 test —— 免得为了修 1 顺手把版本/CHANGELOG 校验也绕过去。
13
+ //
14
+ // 工作流是 YAML,但为了三条断言引入 YAML 解析依赖不值得:这里只按缩进取作业块、读它自己的
15
+ // needs 行。因此断言落在「内联列表形状」上(`needs: [a, b]`)——有人改成块状列表测试会红,
16
+ // 那是真变化,值得人看一眼再改锁,而不是静默失效。
17
+ import test from "node:test";
18
+ import assert from "node:assert/strict";
19
+ import { readFileSync } from "node:fs";
20
+ import { dirname, join } from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+
23
+ const workflowPath = join(dirname(fileURLToPath(import.meta.url)), "..", "..", ".github", "workflows", "release.yml");
24
+ const workflow = readFileSync(workflowPath, "utf8");
25
+
26
+ /** 取一个顶级 job(缩进 2 格的 `name:`)的整块文本,到下一个同级键或文件结尾。 */
27
+ function jobBlock(name) {
28
+ const lines = workflow.split(/\r?\n/);
29
+ const start = lines.findIndex((line) => line === ` ${name}:`);
30
+ assert.notEqual(start, -1, `release.yml 里找不到 job ${name}`);
31
+ let end = lines.length;
32
+ for (let i = start + 1; i < lines.length; i += 1) {
33
+ if (/^ {2}[A-Za-z0-9_-]+:\s*$/.test(lines[i])) {
34
+ end = i;
35
+ break;
36
+ }
37
+ }
38
+ return lines.slice(start, end);
39
+ }
40
+
41
+ /** 读一个 job 块的 `needs:` 内联列表;没有 needs 行返回空数组。 */
42
+ function needsOf(name) {
43
+ const line = jobBlock(name).find((l) => /^ {4}needs:\s*\[/.test(l));
44
+ if (!line) return [];
45
+ const inner = line.slice(line.indexOf("[") + 1, line.lastIndexOf("]"));
46
+ return inner.split(",").map((s) => s.trim()).filter(Boolean);
47
+ }
48
+
49
+ test("release 等 publish:发布声明不许抢在真的发出去之前", () => {
50
+ const needs = needsOf("release");
51
+ assert.ok(
52
+ needs.includes("publish"),
53
+ `release 的 needs 必须含 publish(当前 [${needs.join(", ")}])—— 否则 publish 失败时 Release 仍会建出来`
54
+ );
55
+ });
56
+
57
+ test("release 仍等 verify 与 test:修依赖顺序不该绕开版本与 CHANGELOG 校验", () => {
58
+ const needs = needsOf("release");
59
+ assert.ok(needs.includes("verify"), "release 必须等 verify(版本号与 CHANGELOG 小节)");
60
+ assert.ok(needs.includes("test"), "release 必须等 test");
61
+ });
62
+
63
+ test("publish 内必须跑阻塞闸门:release 等的是一个真做过校验的作业", () => {
64
+ // 只扫非注释行:publish job 的注释里刻意留了那行旧实现(说明它为什么是假绿),
65
+ // 那是文档不是代码——按整块扫会把文档当成复发。
66
+ const code = jobBlock("publish").filter((line) => !/^\s*#/.test(line)).join("\n");
67
+ assert.match(
68
+ code,
69
+ /node dsh-mneme\/scripts\/publish-gate\.mjs/,
70
+ "publish job 必须调用 publish-gate.mjs(拿不到版本就非零退出)"
71
+ );
72
+ assert.doesNotMatch(
73
+ code,
74
+ /echo "Published: \$\(npm view/,
75
+ "不要退回命令替换里的 npm view —— 失败不会让 set -e 退出,那是 v0.8.16 的假绿"
76
+ );
77
+ });
@@ -0,0 +1,135 @@
1
+ // 把 CodeRabbit 在 PR #434 上核出的三条钉死。
2
+ // 前两条是「读起来对、跑起来错」的形态:注释写着 A,代码实际做 B。
3
+ // 第三条是契约与实现的落差:文件头写着「任何异常都被吞掉」,但只吞了一半。
4
+ import test from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { readFileSync } from "node:fs";
7
+ import { fileURLToPath } from "node:url";
8
+ import { dirname, join } from "node:path";
9
+ import { createStore } from "../src/store.js";
10
+ import { createService } from "../src/service.js";
11
+ import { createScopeResolver } from "../src/scope.js";
12
+ import { createSummarizer } from "../src/summarize.js";
13
+
14
+ function summarizerHarness() {
15
+ const store = createStore(":memory:");
16
+ const service = createService({ store, mirror: null, config: {} });
17
+ const events = [];
18
+ const ctx = {
19
+ on(name, fn) { events.push({ name, fn }); return () => {}; },
20
+ llm: {
21
+ stream() {
22
+ const json = JSON.stringify([
23
+ { type: "decision", title: "选型", content: "确定用 node:sqlite", importance: 4 }
24
+ ]);
25
+ return (async function* () {
26
+ yield { type: "block-start", block: { type: "text" } };
27
+ yield { type: "text-delta", delta: json };
28
+ yield { type: "block-end", block: { type: "text" } };
29
+ yield { type: "finish", kind: "ok" };
30
+ })();
31
+ }
32
+ }
33
+ };
34
+ createSummarizer(ctx, service, {
35
+ autoSummarize: true,
36
+ scopeEnabled: true,
37
+ summarizeDedupeMode: "title",
38
+ summarizeDedupeWindowHours: 24,
39
+ summarizeMinIntervalMinutes: 0,
40
+ summarizeMaxRunsPerSession: 0,
41
+ distillRateLimitIntervalMs: 0
42
+ }, {});
43
+ return { store, service, handler: events.find((e) => e.name === "session/event").fn };
44
+ }
45
+
46
+ const SID = "s-scope-dedupe";
47
+
48
+ function sessionOf(scope) {
49
+ return {
50
+ id: SID,
51
+ header: { agentPreset: scope, cwd: "C:\\work\\probe" },
52
+ requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
53
+ events: [
54
+ { seq: 1, type: "user/message", data: { source: { kind: "user" }, content: [{ type: "text", text: "提问" }] } },
55
+ { seq: 2, type: "turn/end" }
56
+ ]
57
+ };
58
+ }
59
+
60
+ // 回归形状:scopeEnabled 与 summarizeDedupeMode 同开时,第二轮蒸馏会命中第一轮
61
+ // 的行。findSessionDuplicate 的候选筛(type + source + 时间窗)不看 scope,真正
62
+ // 决定并不并的是 saveWithDedupe 的 scopeMatches 门。调用方若用「命中就不传章」
63
+ // 表达「不重贴」,新条目的 scope 会变成 null:与已带章的候选不等 → 门拒 → 回落
64
+ // 新建,而那一行也是新行,同样该带章却没有章。结果是同题两行、一行 auto 一行
65
+ // NULL——跨作用域隔离没做成,同题重复倒留下了。
66
+ test("scope + dedupe: a scope-gate rejection still stamps the replacement row", async () => {
67
+ const { store, service, handler } = summarizerHarness();
68
+ // 同 source 的存量未标注行:正是 scopeEnabled 之前写下的那些行的形状。
69
+ service.saveWithDedupe({
70
+ type: "decision", title: "选型", content: "旧内容", source: `session:${SID}`, importance: 4
71
+ });
72
+
73
+ await handler(sessionOf("amarre"), { seq: 2, type: "turn/end" });
74
+
75
+ const rows = store.list({ limit: null });
76
+ assert.equal(rows.length, 2, "跨作用域不并入,存量行原样留着");
77
+ const stamped = rows.filter((r) => r.agent_scope === "amarre");
78
+ assert.equal(stamped.length, 1, "回落新建的那一行必须带章(它同样是新行)");
79
+ assert.equal(stamped[0].workspace_scope, "C:\\work\\probe", "两维都盖");
80
+ });
81
+
82
+ // 反向锁:同作用域同题照旧并入,且不新造行、不把既有行的来源升级成 explicit
83
+ // (升级只在用户显式声明时发生,见 service.js 的 scopeUpgrade 分支)。
84
+ test("scope + dedupe: a same-scope candidate still merges and keeps its source", async () => {
85
+ const { store, service, handler } = summarizerHarness();
86
+ service.saveWithDedupe({
87
+ type: "decision", title: "选型", content: "旧内容", source: `session:${SID}`,
88
+ importance: 4,
89
+ agent_scope: "amarre", agent_scope_source: "auto",
90
+ workspace_scope: "C:\\work\\probe", workspace_scope_source: "auto"
91
+ });
92
+
93
+ await handler(sessionOf("amarre"), { seq: 2, type: "turn/end" });
94
+
95
+ const rows = store.list({ limit: null });
96
+ assert.equal(rows.length, 1, "同作用域同题:并入而不是新造");
97
+ assert.equal(rows[0].agent_scope_source, "auto", "并入不把来源升级成 explicit");
98
+ });
99
+
100
+ // scope.js 文件头写着硬约束「任何异常都被吞掉」,但那条约束此前只覆盖了
101
+ // session.header 的取得与 registry 查询——header 的**字段**读取裸着。宿主给出
102
+ // 会抛错的 accessor 时(sessionOf / sessionKeyOf 就是为同一类宿主写的 try 包),
103
+ // 异常会穿出解析器:蒸馏路径上一个窗口的记忆与游标一起丢,工具路径上是一次
104
+ // memory_save 直接失败。锁的是「字段级读取也必须各自护住」。
105
+ test("scope resolver: a throwing header accessor never escapes the resolver", () => {
106
+ const resolve = createScopeResolver({ ctx: {}, config: { scopeEnabled: true }, logger: {} });
107
+ const session = {
108
+ id: "s-throw",
109
+ get header() {
110
+ return {
111
+ get agentPreset() { throw new Error("host accessor exploded"); },
112
+ get cwd() { throw new Error("host accessor exploded"); }
113
+ };
114
+ }
115
+ };
116
+
117
+ let value = null;
118
+ assert.doesNotThrow(() => { value = resolve({ agent: { session } }); });
119
+ assert.deepEqual(value, { agent_scope: null, workspace_scope: null }, "取不到就落 NULL,不是抛出");
120
+ });
121
+
122
+ // 居中 sheet 是「宿主没有右栏服务」时的唯一完整视图入口(PipelineHeaderEntry 的
123
+ // fallback)。它 portal 到 body,焦点默认留在 trigger 上,而挂在 sheet 元素上的
124
+ // onKeyDown 既因没有 tabIndex 不被聚焦、也收不到外部键盘事件 —— Escape 于是不
125
+ // 生效,键盘用户只剩 Tab 摸到关闭按钮一条路。这里锁住「焦点进得去、Esc 出得来」
126
+ // 两件事都在;做法与 MemoryOverlay 一致(打开时 focus,Esc 挂在 window 上)。
127
+ test("pipeline sheet fallback is keyboard reachable: focus moves in, Escape gets out", () => {
128
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
129
+ const src = readFileSync(join(root, "lib/client.js"), "utf8");
130
+
131
+ assert.match(src, /ref: sheetRef,\s*\n\s*tabIndex: -1,/, "sheet 必须可聚焦(否则 onKeyDown 收不到事件)");
132
+ assert.match(src, /if \(!sheetOpen\) return undefined;\s*\n\s*if \(sheetRef\.current\) sheetRef\.current\.focus\(\);/,
133
+ "打开时必须把焦点移进 sheet");
134
+ assert.match(src, /window\.addEventListener\("keydown", onKey\)/, "Escape 必须挂在 window 上收");
135
+ });
@@ -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:294-309 同款),recall_runs 不缺数 —— #363 回帖
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");