@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,209 @@
1
+ // 存储回收(maintenance/reclaim)的 HTTP 口径 —— 共用模块自己的锁。
2
+ //
3
+ // 这个文件盯两类回归:
4
+ // a) 口径本身(解析、校验、答复形状)。表驱动跑全量边界,因为这张脸有两个入口,
5
+ // 任何一处口径松掉都会同时污染面板与数据面;
6
+ // b) **不许再长出第二份实现**。历史上两个入口是整段复制的,于是 #410 修好面板侧
7
+ // 之后数据面还停在旧形态(#411)。下面的静态锁把「校验字面量只许出现在本模块」
8
+ // 写成断言:谁要是再复制一段出去,这里直接转红。
9
+ import test from "node:test";
10
+ import assert from "node:assert/strict";
11
+ import { readFileSync } from "node:fs";
12
+ import { dirname, join, resolve } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import {
15
+ MAX_RECLAIM_WINDOW_DAYS,
16
+ RECLAIM_CONFIRM_HINT,
17
+ parseReclaimQuery,
18
+ parseReclaimJson,
19
+ readReclaimFields,
20
+ reclaimReply
21
+ } from "../src/reclaim-http.js";
22
+
23
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
24
+
25
+ /** 假 maintenance:记下每次调用的实参,回一个固定报告对象(用来锁「不加包装层」)。 */
26
+ function fakeMaintenance(report = { dryRun: true, planned: { dream_run_inputs: 1 } }) {
27
+ const calls = [];
28
+ return {
29
+ calls,
30
+ reclaim(options) {
31
+ calls.push(options);
32
+ return report;
33
+ }
34
+ };
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // GET 侧:query 是字符串域
39
+ // ---------------------------------------------------------------------------
40
+ test("parseReclaimQuery: 只收十进制整数字面量,缺省才落默认窗口", () => {
41
+ assert.deepEqual(parseReclaimQuery(null), { ok: true, olderThanDays: undefined }, "不传 = 落默认窗口");
42
+ assert.deepEqual(parseReclaimQuery("0"), { ok: true, olderThanDays: 0 }, "0 是合法值(清全部快照)");
43
+ assert.deepEqual(parseReclaimQuery("3"), { ok: true, olderThanDays: 3 });
44
+ assert.deepEqual(parseReclaimQuery(String(MAX_RECLAIM_WINDOW_DAYS)), { ok: true, olderThanDays: MAX_RECLAIM_WINDOW_DAYS }, "上界本身合法");
45
+ });
46
+
47
+ test("parseReclaimQuery: 畸形输入一律 400,绝不被静默读成 0", () => {
48
+ // Number() 会把 ""/[]/" " 都化成 0,而 0 在这里是「清掉全部快照」——最坏解释绝不能
49
+ // 从一个畸形输入静默拿到。所以这里的每一条都必须拒,而不是回落。
50
+ for (const bad of ["", " ", "3.5", "-1", "1e3", "0x10", "٣", "3 ", "+3", "abc"]) {
51
+ assert.deepEqual(
52
+ parseReclaimQuery(bad),
53
+ { ok: false, error: "invalid-older-than-days" },
54
+ `olderThanDays=${JSON.stringify(bad)} 必须被拒`
55
+ );
56
+ }
57
+ });
58
+
59
+ test("parseReclaimQuery: 量级超界被拒(十进制字面量不保证是个能算日期的数)", () => {
60
+ // 400 位十进制字面量会化成 Infinity,maintenance 拿它算日期直接抛 RangeError。
61
+ for (const huge of [String(MAX_RECLAIM_WINDOW_DAYS + 1), "100000000000000000000", "9".repeat(400)]) {
62
+ assert.deepEqual(parseReclaimQuery(huge), { ok: false, error: "invalid-older-than-days" }, `olderThanDays=${huge.slice(0, 12)}… 必须被拒`);
63
+ }
64
+ });
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // POST 侧:JSON 数字域
68
+ // ---------------------------------------------------------------------------
69
+ test("parseReclaimJson: invalid-json 与 invalid-body 是两个不同的错", () => {
70
+ assert.deepEqual(parseReclaimJson(""), { ok: true, body: {} }, "空 body 等价于 {}");
71
+ assert.deepEqual(parseReclaimJson('{"a":1}'), { ok: true, body: { a: 1 } });
72
+ assert.deepEqual(parseReclaimJson("{bad"), { ok: false, error: "invalid-json" }, "发坏了");
73
+ assert.deepEqual(parseReclaimJson("null"), { ok: false, error: "invalid-body" }, "发成了 null");
74
+ assert.deepEqual(parseReclaimJson("[]"), { ok: false, error: "invalid-body" }, "发成了数组");
75
+ assert.deepEqual(parseReclaimJson('"x"'), { ok: false, error: "invalid-body" }, "发成了字符串");
76
+ });
77
+
78
+ test("readReclaimFields: 默认值与透传", () => {
79
+ assert.deepEqual(
80
+ readReclaimFields({}),
81
+ { ok: true, dryRun: true, olderThanDays: undefined, vacuum: false, confirm: false },
82
+ "缺省 = dry-run、默认窗口、不 VACUUM、未确认"
83
+ );
84
+ assert.deepEqual(
85
+ readReclaimFields({ dryRun: false, olderThanDays: 30, vacuum: true, confirm: true }),
86
+ { ok: true, dryRun: false, olderThanDays: 30, vacuum: true, confirm: true }
87
+ );
88
+ assert.deepEqual(readReclaimFields({ olderThanDays: null }).olderThanDays, undefined, "null 与缺省同义:落默认窗口,不是 0");
89
+ assert.deepEqual(readReclaimFields({ olderThanDays: 0 }).olderThanDays, 0, "0 是合法值");
90
+ assert.deepEqual(readReclaimFields({ olderThanDays: MAX_RECLAIM_WINDOW_DAYS }).olderThanDays, MAX_RECLAIM_WINDOW_DAYS);
91
+ });
92
+
93
+ test("readReclaimFields: 只收 JSON 数字,量级与形状都要过", () => {
94
+ for (const bogus of ["", [], false, "3", true, 3.5, -1]) {
95
+ assert.deepEqual(
96
+ readReclaimFields({ olderThanDays: bogus }),
97
+ { ok: false, error: "invalid-older-than-days" },
98
+ `olderThanDays=${JSON.stringify(bogus)} 必须被拒`
99
+ );
100
+ }
101
+ // 上界与 GET 侧同一条:1e20 也是「整数」,但 maintenance 拿它算日期会抛 RangeError。
102
+ for (const huge of [MAX_RECLAIM_WINDOW_DAYS + 1, 1e20, 1e21]) {
103
+ assert.deepEqual(readReclaimFields({ olderThanDays: huge }), { ok: false, error: "invalid-older-than-days" });
104
+ }
105
+ assert.deepEqual(readReclaimFields({ vacuum: "true" }), { ok: false, error: "invalid-vacuum" }, "vacuum 只收布尔");
106
+ assert.equal(readReclaimFields({ confirm: "true" }).confirm, false, "confirm 只认真布尔:字符串不算确认");
107
+ });
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // 答复:两个入口共用的那一份
111
+ // ---------------------------------------------------------------------------
112
+ test("reclaimReply: 没接线时 503,且根本不碰 maintenance", () => {
113
+ const out = reclaimReply({ maintenance: null, dryRun: true });
114
+ // 字面量而不是常量:这是调用方要分支的错误码,改一个字母就是对外行为变更。
115
+ assert.deepEqual(out, { status: 503, body: { error: "maintenance-unavailable" } });
116
+ });
117
+
118
+ test("reclaimReply: dry-run 报原样透传,不加包装层", () => {
119
+ const report = { dryRun: true, planned: { dream_run_inputs: 1 }, olderThanDays: 7 };
120
+ const maintenance = fakeMaintenance(report);
121
+ const out = reclaimReply({ maintenance, dryRun: true, olderThanDays: 7 });
122
+ assert.equal(out.status, 200);
123
+ assert.equal(out.body, report, "报告必须原样回,套一层 {report} 会打断既有消费方");
124
+ assert.deepEqual(maintenance.calls, [{ olderThanDays: 7, vacuum: false, dryRun: true }]);
125
+ });
126
+
127
+ test("reclaimReply: 缺 confirm 只回报告不执行,且报告描述的是将要执行的那个动作", () => {
128
+ const report = { dryRun: true, vacuum: { requested: true, ran: false } };
129
+ const maintenance = fakeMaintenance(report);
130
+ const out = reclaimReply({ maintenance, dryRun: false, olderThanDays: 30, vacuum: true });
131
+ assert.equal(out.status, 400);
132
+ assert.equal(out.body.error, "confirm-required");
133
+ assert.equal(out.body.hint, RECLAIM_CONFIRM_HINT, "提示是给调用方看的下一步动作,两个入口必须逐字一致");
134
+ assert.equal(out.body.report, report);
135
+ assert.deepEqual(
136
+ maintenance.calls,
137
+ [{ olderThanDays: 30, vacuum: true, dryRun: true }],
138
+ "报告必须带 vacuum:否则调用方看到的是「不 VACUUM」的数字,却带着 vacuum 去执行"
139
+ );
140
+ });
141
+
142
+ test("reclaimReply: 确认后按原样执行", () => {
143
+ const maintenance = fakeMaintenance({ dryRun: false, cleared: { dream_run_inputs: 1 } });
144
+ const out = reclaimReply({ maintenance, dryRun: false, olderThanDays: 30, vacuum: true, confirm: true });
145
+ assert.equal(out.status, 200);
146
+ assert.deepEqual(maintenance.calls, [{ olderThanDays: 30, vacuum: true, dryRun: false }]);
147
+ });
148
+
149
+ test("reclaimReply: 抛错回 500 并留日志,原始错误文本不能吞", () => {
150
+ const warnings = [];
151
+ const maintenance = {
152
+ reclaim() { throw new Error("database is locked"); }
153
+ };
154
+ for (const input of [
155
+ { dryRun: true },
156
+ { dryRun: false, confirm: true },
157
+ { dryRun: false } // confirm-required 那份报告也是这里算的(#410)
158
+ ]) {
159
+ const out = reclaimReply({ maintenance, ...input, warn: (m) => warnings.push(String(m)) });
160
+ assert.equal(out.status, 500, "三条会调 reclaim 的路径都必须回 500,不能无响应");
161
+ assert.deepEqual(out.body, { error: "internal" });
162
+ }
163
+ assert.equal(warnings.length, 3);
164
+ for (const w of warnings) {
165
+ assert.match(w, /storage reclaim failed/);
166
+ assert.match(w, /database is locked/, "原始错误文本要留住,否则现场无从下手");
167
+ }
168
+ });
169
+
170
+ test("reclaimReply: 没有 logger 也不许把异常漏出去", () => {
171
+ // warn 是可选的(数据面可能没接 logger)。少一个回调不能让异常穿出去变成
172
+ // unhandled rejection——那正是 #410 的形态:客户端拿到的是「一个字节都没回」。
173
+ const maintenance = { reclaim() { throw new Error("boom"); } };
174
+ const out = reclaimReply({ maintenance, dryRun: false });
175
+ assert.equal(out.status, 500);
176
+ });
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // 防分叉:口径只许有一份实现
180
+ // ---------------------------------------------------------------------------
181
+ test("两个入口都不许再自己写一份回收口径", () => {
182
+ // 每个字面量都是这张脸独有的:出现第二次就意味着又复制了一段校验/答复出去。
183
+ const FORKED = [
184
+ '"invalid-older-than-days"',
185
+ '"invalid-vacuum"',
186
+ '"confirm-required"',
187
+ '"maintenance-unavailable"',
188
+ "storage reclaim failed",
189
+ "MAX_RECLAIM_WINDOW_DAYS"
190
+ ];
191
+ for (const name of ["api.js", "api-standalone.js"]) {
192
+ const src = readFileSync(join(ROOT, "src", name), "utf8");
193
+ for (const literal of FORKED) {
194
+ assert.equal(
195
+ src.includes(literal),
196
+ false,
197
+ `src/${name} 里不该再出现 ${literal}——回收口径只有 src/reclaim-http.js 一份`
198
+ );
199
+ }
200
+ // 反向也要锁:光删掉字面量、不调共用模块,等于把功能删了。
201
+ assert.ok(src.includes("reclaim-http.js"), `src/${name} 必须从共用模块取口径`);
202
+ }
203
+ const panel = readFileSync(join(ROOT, "src", "api.js"), "utf8");
204
+ const data = readFileSync(join(ROOT, "src", "api-standalone.js"), "utf8");
205
+ assert.ok(panel.includes("reclaimReply("), "面板侧要真的走共用答复");
206
+ assert.ok(data.includes("reclaimReply("), "数据面要真的走共用答复");
207
+ assert.ok(panel.includes("readReclaimFields("), "面板侧 POST 要真的走共用校验");
208
+ assert.ok(data.includes("readReclaimFields("), "数据面要真的走共用校验");
209
+ });
@@ -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");
@@ -53,7 +53,10 @@ test("injectCandidates returns preferences + high-importance items within limit"
53
53
  const types = candidates.map((c) => c.type);
54
54
  assert.ok(types.includes("preference"), "preferences included");
55
55
  assert.ok(types.includes("project"), "high-importance project included");
56
- assert.ok(!types.includes("history"), "history excluded by default");
56
+ // #369:history 曾被注入白名单挡在门外,现按「注入侧收编」进池(理由与实测数字
57
+ // 见 src/service.js 的 INJECT_TYPES 注释)。这里从「锁排除」改成「锁收编」——
58
+ // 白名单日后若被收窄,这条会红,而不是静默把两成条目关回不可注入。
59
+ assert.ok(types.includes("history"), "history included since #369");
57
60
  assert.ok(candidates.length <= 5, "respects maxItems");
58
61
  });
59
62
 
@@ -220,7 +223,9 @@ test("Bug4: injectCandidates with an empty query keeps the legacy rule-based sel
220
223
  assert.deepEqual(noQuery.map((c) => c.id), legacy.map((c) => c.id), "empty query behaves identically to the old logic");
221
224
  assert.deepEqual(blankQuery.map((c) => c.id), legacy.map((c) => c.id), "whitespace-only query degrades too");
222
225
  assert.equal(legacy[0].type, "summary", "summary still leads");
223
- assert.ok(!legacy.some((c) => c.type === "history"), "history still excluded");
226
+ // #369 收编后 history 可进池;本用例的重心是「空查询与旧规则路逐字节同序」,
227
+ // 这条只确认收编没有撼动优先级分层——summary 仍打头。
228
+ assert.ok(legacy.some((c) => c.type === "history"), "history included since #369");
224
229
  });
225
230
 
226
231
  test("Bug4: injectCandidates ignores the query when hybridInject is disabled", () => {