@modusensus/dsh-mneme 0.8.15 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +21 -3
  2. package/assets/icon.svg +2 -2
  3. package/bin/cli.mjs +4 -2
  4. package/docs/CONFIGURATION.md +16 -3
  5. package/docs/CONTINUITY.md +18 -3
  6. package/docs/DISTILL.md +162 -0
  7. package/docs/STORAGE.md +11 -2
  8. package/docs/WORKFLOWS.md +299 -0
  9. package/lib/api-standalone.js +22 -39
  10. package/lib/api.js +294 -10
  11. package/lib/candidates.js +454 -0
  12. package/lib/capabilities.js +819 -0
  13. package/lib/client.js +2767 -337
  14. package/lib/config.js +99 -6
  15. package/lib/continuity.js +35 -6
  16. package/lib/dream/decisions.js +37 -1
  17. package/lib/dream/sleep.js +10 -2
  18. package/lib/dream.js +7 -1
  19. package/lib/entities/extractor.js +4 -0
  20. package/lib/hot-memory.js +62 -0
  21. package/lib/index.js +72 -18
  22. package/lib/inject.js +33 -64
  23. package/lib/maintenance.js +25 -5
  24. package/lib/pipeline.js +534 -0
  25. package/lib/recall-stats.js +22 -7
  26. package/lib/reclaim-http.js +150 -0
  27. package/lib/safe-slice.js +18 -0
  28. package/lib/scope.js +16 -4
  29. package/lib/serve.js +1 -1
  30. package/lib/service.js +83 -17
  31. package/lib/session-usage.js +350 -0
  32. package/lib/settings.js +19 -2
  33. package/lib/store.js +251 -10
  34. package/lib/summarize.js +239 -60
  35. package/lib/tools.js +11 -2
  36. package/package.json +1 -1
  37. package/scripts/publish-gate.mjs +197 -0
  38. package/src/api-standalone.js +22 -39
  39. package/src/api.js +294 -10
  40. package/src/candidates.js +454 -0
  41. package/src/capabilities.js +819 -0
  42. package/src/config.js +99 -6
  43. package/src/continuity.js +35 -6
  44. package/src/dream/decisions.js +37 -1
  45. package/src/dream/sleep.js +10 -2
  46. package/src/dream.js +7 -1
  47. package/src/entities/extractor.js +4 -0
  48. package/src/hot-memory.js +62 -0
  49. package/src/index.js +72 -18
  50. package/src/inject.js +33 -64
  51. package/src/maintenance.js +25 -5
  52. package/src/pipeline.js +534 -0
  53. package/src/recall-stats.js +22 -7
  54. package/src/reclaim-http.js +150 -0
  55. package/src/safe-slice.js +18 -0
  56. package/src/scope.js +16 -4
  57. package/src/serve.js +1 -1
  58. package/src/service.js +83 -17
  59. package/src/session-usage.js +350 -0
  60. package/src/settings.js +19 -2
  61. package/src/store.js +251 -10
  62. package/src/summarize.js +239 -60
  63. package/src/tools.js +11 -2
  64. package/test/api-routes-gaps.test.js +681 -0
  65. package/test/api.test.js +171 -8
  66. package/test/candidates.test.js +345 -0
  67. package/test/capabilities.test.js +577 -0
  68. package/test/client.test.js +715 -4
  69. package/test/continuity.test.js +84 -0
  70. package/test/contrast.test.js +314 -0
  71. package/test/dream-merge-guardrail.test.js +219 -0
  72. package/test/embed-ready-poll.test.js +94 -0
  73. package/test/entities.test.js +13 -2
  74. package/test/failure-query.test.js +147 -0
  75. package/test/failure-retention.test.js +105 -0
  76. package/test/graph-api.test.js +36 -2
  77. package/test/hot-memory.test.js +67 -1
  78. package/test/inject-recall.test.js +68 -1
  79. package/test/inject.test.js +23 -0
  80. package/test/llm-audit.test.js +190 -1
  81. package/test/maintenance.test.js +112 -2
  82. package/test/mint-source.test.js +202 -0
  83. package/test/pipeline.test.js +505 -0
  84. package/test/placement.test.js +1 -1
  85. package/test/pre-inject-gate.test.js +10 -0
  86. package/test/publish-gate.test.js +272 -0
  87. package/test/recall-retention.test.js +128 -0
  88. package/test/recall-stats.test.js +89 -4
  89. package/test/reclaim-http.test.js +209 -0
  90. package/test/release-workflow.test.js +77 -0
  91. package/test/scope-distill-stamp.test.js +135 -0
  92. package/test/serve.test.js +1 -1
  93. package/test/service.test.js +7 -2
  94. package/test/session-usage.test.js +308 -0
  95. package/test/settings.test.js +8 -0
  96. package/test/store-write-path-columns.test.js +191 -0
  97. package/test/store.test.js +108 -0
  98. package/test/summarize.test.js +350 -0
  99. package/test/supersede-state-tracking.test.js +4 -4
  100. package/test/tools.test.js +16 -1
@@ -0,0 +1,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
+ });
@@ -124,21 +124,106 @@ test("windowDays/exemptDays 越界回默认;扫描超上限标 truncated", ()
124
124
 
125
125
  // ============================ 第五指标(#275):归档净增速率 + 可压掉行数
126
126
 
127
- test("归档净增速率:按 updated_at 取归档时刻,窗外归档只进总数不进净增", () => {
127
+ test("归档净增速率:按 archived_at 取归档时刻,窗外归档只进总数不进净增", () => {
128
128
  const { store, service } = setup();
129
129
  const fresh = service.saveWithDedupe({ type: "history", title: "刚归档", content: "x", importance: 3 }).memory;
130
130
  const old = service.saveWithDedupe({ type: "history", title: "早归档", content: "y", importance: 3 }).memory;
131
131
  service.saveWithDedupe({ type: "history", title: "活跃件", content: "z", importance: 3 });
132
132
  store.setArchived(fresh.id, true);
133
133
  store.setArchived(old.id, true);
134
- // 归档时刻 = 最后一次写(setArchived 刷 updated_at):把这行推到窗口外
135
- store.db.prepare("UPDATE memories SET updated_at = ? WHERE id = ?")
134
+ // 精确口径:归档时刻是 archived_at 列(#275 第二批),把这行推到窗口外
135
+ store.db.prepare("UPDATE memories SET archived_at = ? WHERE id = ?")
136
136
  .run(new Date(Date.now() - 100 * 86400000).toISOString(), old.id);
137
137
 
138
138
  const stats = service.recallStats({ windowDays: 30, exemptDays: 0 });
139
139
  assert.equal(stats.archive.total, 2, "归档区现有行数(活跃件不算)");
140
140
  assert.equal(stats.archive.addedInWindow, 1, "窗外那次归档不算本窗口净增");
141
141
  assert.equal(stats.archive.perDay, Math.round((1 / 30) * 100) / 100);
142
+ assert.equal(stats.archive.undated, 0, "两行都有 archived_at,都不算时刻不可追溯");
143
+ store.close();
144
+ });
145
+
146
+ test("回归锁:归档后再 memory_update 刷 updated_at,不得算作本窗口新进归档", () => {
147
+ // 本批的核心回归锁。代理口径下归档时刻取 updated_at(setArchived 刷它),于是
148
+ // 「对已归档行做一次普通更新」就会把旧归档算成窗口内新进,perDay 偏高——这正是
149
+ // #275 第二批要消灭的假阳性。任何人把 recall-stats 改回 Date.parse(m.updated_at)
150
+ // 或改回 `m.archived_at ?? m.updated_at`,本用例立刻失败。
151
+ const { store, service } = setup();
152
+ const m = service.saveWithDedupe({ type: "history", title: "早归档", content: "x", importance: 3 }).memory;
153
+ store.setArchived(m.id, true);
154
+ store.db.prepare("UPDATE memories SET archived_at = ? WHERE id = ?")
155
+ .run(new Date(Date.now() - 100 * 86400000).toISOString(), m.id);
156
+ // 已归档行上的一次普通更新:updated_at 被刷到当下,archived_at 必须岿然不动
157
+ store.update(m.id, { content: "改过一遍" });
158
+ const row = store.getById(m.id);
159
+ assert.ok(
160
+ Date.parse(row.updated_at) > Date.now() - 60000,
161
+ "前提成立:update 确实把 updated_at 刷到了当下(否则本用例是恒真的)"
162
+ );
163
+
164
+ const stats = service.recallStats({ windowDays: 30, exemptDays: 0 });
165
+ assert.equal(stats.archive.total, 1);
166
+ assert.equal(stats.archive.addedInWindow, 0, "旧归档不因一次 update 变成窗口新进");
167
+ assert.equal(stats.archive.perDay, 0);
168
+ store.close();
169
+ });
170
+
171
+ test("取消归档清空 archived_at;再次归档重新落新时刻", () => {
172
+ const { store } = setup();
173
+ const m = store.save({ type: "history", title: "t", content: "c" });
174
+ const archived = store.setArchived(m.id, true);
175
+ assert.ok(archived.archived_at, "活跃 → 归档:落当前时刻");
176
+
177
+ const restored = store.setArchived(m.id, false);
178
+ assert.equal(restored.archived, false);
179
+ assert.equal(restored.archived_at, undefined, "取消归档清 NULL(toRow 读作 undefined,不是陈旧时刻)");
180
+
181
+ const again = store.setArchived(m.id, true);
182
+ assert.ok(again.archived_at, "重新归档重新落时刻");
183
+ assert.notEqual(again.archived_at, archived.archived_at, "是新一次归档,不是复用旧时刻");
184
+ store.close();
185
+ });
186
+
187
+ test("幂等:重复归档不刷新 archived_at(否则重复调用又变成一次净增)", () => {
188
+ // 代理口径被推翻的两个假阳性之一就是「对同一行重复调 setArchived(true)」。精确
189
+ // 口径下重复归档不是新一次归档,时刻保持首次值;刷新它就等于把旧毛病换个入口复活。
190
+ const { store } = setup();
191
+ const m = store.save({ type: "history", title: "t", content: "c" });
192
+ const first = store.setArchived(m.id, true);
193
+ const second = store.setArchived(m.id, true);
194
+ const third = store.setArchived(m.id, true);
195
+ assert.equal(second.archived_at, first.archived_at);
196
+ assert.equal(third.archived_at, first.archived_at);
197
+ store.close();
198
+ });
199
+
200
+ test("存量归档行(archived_at 为 NULL)计入 undated,不静默顶替成窗口新进", () => {
201
+ // NULL = 本列落地前就已归档的行,归档时刻不可追溯。用 updated_at 顶替就是退回被
202
+ // 推翻的代理口径;按 0 充数则是把「不知道」伪装成「没有」。这里锁的是第三种做法:
203
+ // 单独计数、不计入 addedInWindow。
204
+ const { store, service } = setup();
205
+ const m = service.saveWithDedupe({ type: "history", title: "存量归档", content: "x", importance: 3 }).memory;
206
+ store.setArchived(m.id, true);
207
+ store.db.prepare("UPDATE memories SET archived_at = NULL WHERE id = ?").run(m.id);
208
+
209
+ const stats = service.recallStats({ windowDays: 30, exemptDays: 0 });
210
+ assert.equal(stats.archive.total, 1);
211
+ assert.equal(stats.archive.addedInWindow, 0, "时刻不可追溯 → 不算窗口新进");
212
+ assert.equal(stats.archive.undated, 1, "单独暴露,不伪装成 0");
213
+ store.close();
214
+ });
215
+
216
+ test("口径升级不波及其他四项指标:归档行照旧不进活跃 / 僵尸 / 豁免", () => {
217
+ const { store, service } = setup();
218
+ service.saveWithDedupe({ type: "project", title: "活跃", content: "x", importance: 3 });
219
+ const arch = service.saveWithDedupe({ type: "project", title: "归档", content: "y", importance: 3 }).memory;
220
+ store.setArchived(arch.id, true);
221
+
222
+ const stats = service.recallStats({ windowDays: 30, exemptDays: 0 });
223
+ assert.equal(stats.zombie.activeCount, 1, "归档行不算活跃");
224
+ assert.equal(stats.zombie.zombieCount, 1, "零曝光的活跃行照旧算僵尸");
225
+ assert.equal(stats.zombie.exemptCount, 0);
226
+ assert.equal(stats.archive.total, 1);
142
227
  store.close();
143
228
  });
144
229
 
@@ -183,7 +268,7 @@ test("没有归档行时给 0,不返回 null / NaN", () => {
183
268
  service.saveWithDedupe({ type: "project", title: "只有活跃", content: "x", importance: 3 });
184
269
  const stats = service.recallStats({ windowDays: 30, exemptDays: 0 });
185
270
  assert.deepEqual(stats.archive, {
186
- total: 0, addedInWindow: 0, perDay: 0, compressible: { rows: 0, groups: 0 }
271
+ total: 0, addedInWindow: 0, perDay: 0, undated: 0, compressible: { rows: 0, groups: 0 }
187
272
  });
188
273
  store.close();
189
274
  });