@modusensus/dsh-mneme 0.9.4 → 0.9.5

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 (80) hide show
  1. package/README.md +53 -10
  2. package/bin/cli.mjs +215 -2
  3. package/docs/CONFIGURATION.md +4 -2
  4. package/docs/DAEMON.md +9 -2
  5. package/docs/DISTILL.md +66 -4
  6. package/docs/SHARE.md +552 -0
  7. package/docs/SLEEP.md +1 -1
  8. package/docs/STORAGE.md +1 -1
  9. package/lib/api-standalone.js +286 -8
  10. package/lib/api.js +195 -1
  11. package/lib/client.js +783 -30
  12. package/lib/document.js +15 -2
  13. package/lib/dream/decisions.js +88 -3
  14. package/lib/dream/sleep.js +63 -13
  15. package/lib/dream.js +65 -9
  16. package/lib/lang.js +23 -6
  17. package/lib/memory-source.js +9 -0
  18. package/lib/organize.js +5 -1
  19. package/lib/pipeline.js +62 -1
  20. package/lib/provenance-http.js +126 -0
  21. package/lib/serve.js +13 -0
  22. package/lib/service.js +67 -4
  23. package/lib/share-http.js +281 -0
  24. package/lib/share-import.js +234 -0
  25. package/lib/share.js +335 -0
  26. package/lib/store.js +272 -11
  27. package/lib/summarize.js +60 -11
  28. package/package.json +1 -1
  29. package/src/api-standalone.js +286 -8
  30. package/src/api.js +195 -1
  31. package/src/document.js +15 -2
  32. package/src/dream/decisions.js +88 -3
  33. package/src/dream/sleep.js +63 -13
  34. package/src/dream.js +65 -9
  35. package/src/lang.js +23 -6
  36. package/src/memory-source.js +9 -0
  37. package/src/organize.js +5 -1
  38. package/src/pipeline.js +62 -1
  39. package/src/provenance-http.js +126 -0
  40. package/src/serve.js +13 -0
  41. package/src/service.js +67 -4
  42. package/src/share-http.js +281 -0
  43. package/src/share-import.js +234 -0
  44. package/src/share.js +335 -0
  45. package/src/store.js +272 -11
  46. package/src/summarize.js +60 -11
  47. package/test/api.test.js +370 -2
  48. package/test/audit.test.js +2 -2
  49. package/test/cli-receive.test.js +390 -0
  50. package/test/cli-share.test.js +437 -0
  51. package/test/client-share.test.js +225 -0
  52. package/test/client.test.js +139 -0
  53. package/test/dedupe-candidate-window.test.js +180 -0
  54. package/test/dream-candidate-hybrid.test.js +2 -2
  55. package/test/dream-fence.test.js +164 -0
  56. package/test/dream.test.js +87 -5
  57. package/test/helpers/dream-mock.js +6 -2
  58. package/test/llm-message-source.test.js +1 -1
  59. package/test/mint-source.test.js +1 -1
  60. package/test/pipeline-r1r2.test.js +129 -0
  61. package/test/policy-epoch.test.js +2 -2
  62. package/test/provenance-http.test.js +184 -0
  63. package/test/readme-claims.test.js +45 -0
  64. package/test/reasoning-effort.test.js +10 -10
  65. package/test/scope-candidates.test.js +7 -4
  66. package/test/scope-state.test.js +130 -1
  67. package/test/serve.test.js +62 -0
  68. package/test/share-foreign-rows.test.js +144 -0
  69. package/test/share-http.test.js +144 -0
  70. package/test/share-import.test.js +577 -0
  71. package/test/share-imported-guard.test.js +311 -0
  72. package/test/share-imported-identity.test.js +111 -0
  73. package/test/share-pack.test.js +511 -0
  74. package/test/share-receive-http.test.js +756 -0
  75. package/test/share-routes.test.js +337 -0
  76. package/test/share-store.test.js +133 -0
  77. package/test/share-validate.test.js +101 -0
  78. package/test/standalone-api.test.js +524 -5
  79. package/test/summarize-parse-diagnostics.test.js +75 -0
  80. package/test/summarize.test.js +15 -2
@@ -0,0 +1,184 @@
1
+ // 记忆级溯源读出口的 HTTP 口径 —— 共用模块自己的锁。
2
+ //
3
+ // 这个文件盯三类回归:
4
+ // a) 口径本身(id 解析、两种「空」的区分、窗口形状、原文按序);
5
+ // b) distillRecords.enabled 门(#509 评审整改):判据 fail-closed、409 赢 503、
6
+ // 关着时一次都不碰 service;
7
+ // c) **不许再长出第二份实现**。#509 第一版曾把整段复製到 api.js 与
8
+ // api-standalone.js 各一份,维护者在 review 里点名(本仓 #411 之后不收这个
9
+ // 形状)。静态锁把三件事写成断言:口径字面量与开关判据只许出现在本模块、两个
10
+ // 壳都真的把 config 转交进来、读侧判据与写侧 summarize.js 逐字同源。谁要是再
11
+ // 复制一段出去,这里直接转红。
12
+ import test from "node:test";
13
+ import assert from "node:assert/strict";
14
+ import { readFileSync } from "node:fs";
15
+ import { dirname, join, resolve } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+ import {
18
+ DISTILL_RECORDS_DISABLED,
19
+ PROVENANCE_UNAVAILABLE,
20
+ isDistillRecordsEnabled,
21
+ parseProvenanceQuery,
22
+ provenanceReply
23
+ } from "../src/provenance-http.js";
24
+
25
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
26
+
27
+ // 开着开关的 cfg:这个端点返回蒸馏窗口的逐字原文,所以测试里的「正常路径」必须
28
+ // 显式把开关打开——不传就是关(fail-closed),没有「默认开」这回事。
29
+ const RECORDS_ON = { distillRecords: { enabled: true } };
30
+
31
+ function fakeService(over = {}) {
32
+ return {
33
+ provenanceFor: (id) => over.links ?? [],
34
+ distillRecordsFor: (sessionId, from, to) => over.records ?? [],
35
+ ...over.service
36
+ };
37
+ }
38
+
39
+ test("parseProvenanceQuery:空/缺失 id 报 400,非空原样通过", () => {
40
+ for (const raw of [null, undefined, "", " "]) {
41
+ const r = parseProvenanceQuery(raw);
42
+ assert.equal(r.ok, false, `${JSON.stringify(raw)} 不该通过`);
43
+ assert.equal(r.status, 400);
44
+ assert.equal(r.error, "missing-id");
45
+ }
46
+ assert.deepEqual(parseProvenanceQuery(" m1 "), { ok: true, id: "m1" }, "id 两端空白要裁掉");
47
+ });
48
+
49
+ test("provenanceReply:一个链接一个窗口,原文按 seq 升序且不越界", () => {
50
+ const seen = [];
51
+ const service = {
52
+ provenanceFor: () => [{ session_id: "s1", seq_start: 10, seq_end: 12, created_at: "2026-10-23T00:00:00Z" }],
53
+ distillRecordsFor: (sessionId, from, to) => {
54
+ seen.push([sessionId, from, to]);
55
+ return [{ seq: 12 }, { seq: 10 }, { seq: 11 }];
56
+ }
57
+ };
58
+ const r = provenanceReply({ service, id: "m1", config: RECORDS_ON });
59
+ assert.equal(r.status, 200);
60
+ assert.equal(r.body.memoryId, "m1");
61
+ assert.equal(r.body.windows.length, 1);
62
+ const w = r.body.windows[0];
63
+ assert.equal(w.session_id, "s1");
64
+ assert.equal(w.seq_start, 10);
65
+ assert.equal(w.seq_end, 12);
66
+ assert.equal(w.created_at, "2026-10-23T00:00:00Z");
67
+ assert.deepEqual(w.records.map((x) => x.seq), [12, 10, 11], "次序由 store 的 ORDER BY seq 保证,答复层不改序");
68
+ assert.deepEqual(seen, [["s1", 10, 12]], "区间原样透传给 store");
69
+ });
70
+
71
+ test("provenanceReply:没有链接是有效答案——空 windows,200 不是 404", () => {
72
+ const r = provenanceReply({ service: fakeService(), id: "m1", config: RECORDS_ON });
73
+ assert.equal(r.status, 200);
74
+ assert.deepEqual(r.body, { memoryId: "m1", windows: [] });
75
+ });
76
+
77
+ test("provenanceReply:方法没接线是装配坏了——503,不与「没有来源」混为一个读数", () => {
78
+ // headless / 旧装配:service 上没有这两个方法。
79
+ const r = provenanceReply({ service: {}, id: "m1", config: RECORDS_ON });
80
+ assert.equal(r.status, 503);
81
+ assert.equal(r.body.error, PROVENANCE_UNAVAILABLE);
82
+ });
83
+
84
+ test("provenanceReply:读抛错只 warn 并回 500,不把异常漏给调用方", () => {
85
+ const warnings = [];
86
+ const service = {
87
+ provenanceFor: () => { throw new Error("db locked"); },
88
+ distillRecordsFor: () => []
89
+ };
90
+ const r = provenanceReply({ service, id: "m1", config: RECORDS_ON, warn: (m) => warnings.push(m) });
91
+ assert.equal(r.status, 500);
92
+ assert.equal(r.body.error, "internal");
93
+ assert.equal(warnings.length, 1, "失败要留痕");
94
+ assert.match(warnings[0], /memory provenance read failed/);
95
+ });
96
+
97
+ // --- distillRecords.enabled 门(#509 评审整改)--------------------------------
98
+ // 写侧(src/summarize.js:988)早就吃这个开关,读出口此前一处都不读:关着时历史
99
+ // 原文照旧留在库里、还多一个能读出去的口。门加在共用模块里,两个壳都吃它。
100
+
101
+ test("isDistillRecordsEnabled:只认严格的 true,其余一律算关(fail-closed)", () => {
102
+ assert.equal(isDistillRecordsEnabled({ distillRecords: { enabled: true } }), true);
103
+ for (const cfg of [
104
+ null, undefined, {}, { distillRecords: null }, { distillRecords: {} },
105
+ { distillRecords: { enabled: false } },
106
+ // 非布尔值:字符串 / 数字都不该被当成开——那是配置写错了,不是用户开了
107
+ { distillRecords: { enabled: "true" } },
108
+ { distillRecords: { enabled: 1 } }
109
+ ]) {
110
+ assert.equal(isDistillRecordsEnabled(cfg), false, `${JSON.stringify(cfg)} 必须算关`);
111
+ }
112
+ });
113
+
114
+ test("provenanceReply:开关没开 → 409 distill-records-disabled,一个键都不多带", () => {
115
+ // config 整个缺失(旧调用方直连、daemon 最小 cfg)也走这一格:默认方向必须是关。
116
+ for (const cfg of [null, undefined, {}, { distillRecords: { enabled: false } }]) {
117
+ const r = provenanceReply({ service: fakeService(), id: "m1", config: cfg });
118
+ assert.equal(r.status, 409, `${JSON.stringify(cfg)} → 409`);
119
+ assert.deepEqual(r.body, { error: DISTILL_RECORDS_DISABLED },
120
+ "body 只准有 error 一个键——不带 memoryId / windows,调用方不会把「功能关了」读成「这条记忆没有来源」");
121
+ }
122
+ });
123
+
124
+ test("provenanceReply:409 赢 503——「用户关了」比「装配坏了」更具体", () => {
125
+ // 关着 + service 上连方法都没有:报 503 会让人去查一个没坏的部署。
126
+ const r = provenanceReply({ service: {}, id: "m1", config: { distillRecords: { enabled: false } } });
127
+ assert.equal(r.status, 409);
128
+ assert.equal(r.body.error, DISTILL_RECORDS_DISABLED);
129
+ });
130
+
131
+ test("provenanceReply:开关关着时一次 SQL 都不发(不碰 service)", () => {
132
+ // 门要在读之前:关着还被调用方当成「能读」是次要的,白跑一趟库才是真浪费。
133
+ let touched = 0;
134
+ const service = fakeService({ service: {
135
+ provenanceFor: () => { touched++; return []; },
136
+ distillRecordsFor: () => { touched++; return []; }
137
+ } });
138
+ provenanceReply({ service, id: "m1", config: null });
139
+ assert.equal(touched, 0);
140
+ });
141
+
142
+ test("provenanceReply:开关开着才读得到——200 与 409 是同一把门的两个方向", () => {
143
+ const service = fakeService({ links: [{ session_id: "s1", seq_start: 1, seq_end: 1, created_at: "t" }], records: [{ seq: 1 }] });
144
+ const on = provenanceReply({ service, id: "m1", config: RECORDS_ON });
145
+ assert.equal(on.status, 200);
146
+ assert.equal(on.body.windows.length, 1);
147
+ const off = provenanceReply({ service, id: "m1", config: { distillRecords: { enabled: false } } });
148
+ assert.equal(off.status, 409);
149
+ assert.deepEqual(off.body, { error: DISTILL_RECORDS_DISABLED });
150
+ });
151
+
152
+ test("🔴 静态锁:口径字面量与共用答复只许出现在本模块,两个入口都要真的走它", () => {
153
+ // #509 第一版把整段复制到了两张皮各一份——本仓 #411 之后不收这个形状。
154
+ for (const name of ["api.js", "api-standalone.js"]) {
155
+ const src = readFileSync(join(ROOT, "src", name), "utf8");
156
+ assert.ok(src.includes("provenance-http.js"), `src/${name} 必须从共用模块取口径`);
157
+ assert.ok(src.includes("provenanceReply("), `src/${name} 要真的走共用答复`);
158
+ assert.ok(!src.includes("provenance-unavailable"), `src/${name} 不准自己写错误码字面量`);
159
+ assert.ok(!src.includes("distill-records-disabled"), `src/${name} 不准自己写「功能关了」的错误码字面量`);
160
+ // 判据也不许重写:两个壳各自算一遍 config?.distillRecords?.enabled === true,
161
+ // 就是读写两道门漂移的形状(本文件上面的注释写过这笔账)。
162
+ assert.ok(!src.includes("distillRecords?.enabled === true"),
163
+ `src/${name} 不准自己写开关判据——只许把 config 转交给共用模块`);
164
+ // 而且要真的转交了:壳里调 provenanceReply 时必须带上 config。
165
+ assert.match(src, /provenanceReply\(\{ service, id: parsed\.id, config,/,
166
+ `src/${name} 要把本入口的 config 传给共用模块,否则门永远读不到用户的开关`);
167
+ }
168
+ });
169
+
170
+ test("🔴 静态锁:读侧判据与写侧 src/summarize.js 逐字同源", () => {
171
+ // 「读写两道门永不分歧」不能只活在注释里:写侧那句话在 summarize.js,读侧在本
172
+ // 模块,两边必须是同一个字符串。谁改了其中一边,这里转红。
173
+ const shared = readFileSync(join(ROOT, "src", "provenance-http.js"), "utf8");
174
+ const writeSide = readFileSync(join(ROOT, "src", "summarize.js"), "utf8");
175
+ const readSide = readFileSync(join(ROOT, "src", "index.js"), "utf8");
176
+ const criterion = "config?.distillRecords?.enabled === true";
177
+ assert.ok(shared.includes(criterion), "共用模块要写判据(读侧唯一一份)");
178
+ assert.ok(writeSide.includes(criterion), "写侧 summarize.js 要还在用同一个判据");
179
+ // 读侧只准有一份:两个壳各写一遍的漂移形状,静态锁上一节已经禁了壳,这里禁
180
+ // 共用模块自己长出第二份。
181
+ assert.equal(shared.split(criterion).length - 1, 1, "共用模块里判据只许出现一次");
182
+ // 写侧那一处(启动期裁剪)与它同源,所以三处各一份、彼此逐字相同。
183
+ assert.ok(readSide.includes("cfg.distillRecords?.enabled === true"), "index.js 的启动裁剪判据应保持同族");
184
+ });
@@ -66,3 +66,48 @@ test("隐私节必须写出「巩固与摘要走宿主模型」这个出站事
66
66
  "English Privacy section should disclose that consolidation uses the host model",
67
67
  );
68
68
  });
69
+
70
+ // 「模型工具」表是代码的投影,不是愿望清单(AGENTS.md 给 SECURITY.md 定的那条规矩,
71
+ // 这张表一直没有)。它已经跑偏两次:#471 加 set_scope_state、#231 加 memory_organize,
72
+ // 两次都没人回头改表或改那个数字——因为没人核过。所以这里全部**从代码反推**:
73
+ // 工具集从 tools.js 的 name: "…" 取,opt-in 从 hiddenTools 的 `!== true` 判据取
74
+ // (`=== true` 那种是「默认在、用户可关」,不能算进 opt-in,否则默认注册数会算少)。
75
+ test("包 README 的「模型工具」表与 tools.js 注册表一致", () => {
76
+ const toolsSrc = readFileSync(path.join(pkgRoot, "src/tools.js"), "utf8");
77
+ const declared = [...toolsSrc.matchAll(/^ {6}name: "((?:memory_|set_scope)[a-z_]*)",/gm)].map((m) => m[1]);
78
+ assert.ok(declared.length >= 10, `tools.js 的工具名解析失败(只看到 ${declared.length} 个)——先修这条守卫,别改表`);
79
+ const optIn = [...toolsSrc.matchAll(/if \(config\?\.(\w+) !== true\) hiddenTools\.add\("([a-z_]+)"\)/g)]
80
+ .map((m) => ({ key: m[1], tool: m[2] }));
81
+ const defaultCount = declared.length - optIn.length;
82
+
83
+ const heading = pkgReadme.match(/^### 模型工具(([^)]*))$/m);
84
+ assert.ok(heading, "包 README 应有「### 模型工具(N 个)」这一节标题(标题格式变了就同步改这条守卫)");
85
+ const bodyStart = pkgReadme.indexOf(heading[0]);
86
+ const bodyRest = pkgReadme.slice(bodyStart + heading[0].length);
87
+ const nextSection = bodyRest.search(/\r?\n#{2,3} /);
88
+ const body = nextSection === -1 ? bodyRest : bodyRest.slice(0, nextSection);
89
+ const rows = [...body.matchAll(/^\| `([a-z_]+)` \|/gm)].map((m) => m[1]);
90
+
91
+ assert.deepEqual(
92
+ [...new Set(declared)].sort(),
93
+ [...new Set(rows)].sort(),
94
+ "表与注册表必须一一对应:少了是漏文档,多了是文档写了不存在的东西"
95
+ );
96
+ assert.equal(rows.length, new Set(rows).size, "表里不许有重复行(重复行会让数量核对失效)");
97
+ // 按**顺序**比数字,不是各自 includes:把「默认注册 11 个;开后 12 个」写成
98
+ // 「默认 12、开后 11」时,两个独立的 includes 都会绿——而那正是这条守卫要防的
99
+ // 第二种错法(第一种是数字与表不符,已被上面的集合比对盖住)。
100
+ const nums = (heading[1].match(/\d+/g) ?? []).map(Number);
101
+ const expected = optIn.length ? [defaultCount, declared.length] : [defaultCount];
102
+ assert.deepEqual(
103
+ nums,
104
+ expected,
105
+ `标题里的数字要按序是「默认注册数、全数」= ${expected.join(" 与 ")}(实跑 ${nums.join(" 与 ") || "没有数字"})`
106
+ );
107
+ // opt-in 工具那一行必须点名它的开关:读者看到表里有它,得知道默认是不出现的。
108
+ for (const { key, tool } of optIn) {
109
+ const row = body.match(new RegExp(`^\\| \`${tool}\` \\|(.*)$`, "m"));
110
+ assert.ok(row, `${tool} 必须在表里`);
111
+ assert.ok(row[1].includes(key), `${tool} 那行要写明它需要 ${key} 才注册`);
112
+ }
113
+ });
@@ -72,8 +72,8 @@ function dreamCtx({ onConsolidation, summaryText = "记忆库总览:用户偏
72
72
  async *stream(options) {
73
73
  captured.push(options);
74
74
  const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
75
- if (userText.startsWith("id=")) {
76
- yield { type: "text-delta", index: 0, text: onConsolidation ? onConsolidation(userText) : "[]" };
75
+ if (userText.includes("| type=")) {
76
+ yield { type: "text-delta", index: 0, text: onConsolidation ? onConsolidation(userText, options.purpose) : "[]" };
77
77
  } else {
78
78
  yield { type: "text-delta", index: 0, text: summaryText };
79
79
  }
@@ -282,7 +282,7 @@ test("issue#9: rejected reasoningEffort retries once without it and still consol
282
282
  async *stream(options) {
283
283
  calls.push(options);
284
284
  const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
285
- if (userText.startsWith("id=")) {
285
+ if (userText.includes("| type=")) {
286
286
  // First attempt forwards reasoningEffort: the provider rejects it.
287
287
  if (options.reasoningEffort) {
288
288
  throw new Error("UNSUPPORTED_REASONING_EFFORT: DeepSeek does not support reasoning effort \"low\"");
@@ -340,7 +340,7 @@ function sleepCtx(onConsolidation, selection = { provider: "mock", model: "sleep
340
340
  async *stream(options) {
341
341
  captured.push(options);
342
342
  const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
343
- yield { type: "text-delta", index: 0, text: onConsolidation ? onConsolidation(userText) : "[]" };
343
+ yield { type: "text-delta", index: 0, text: onConsolidation ? onConsolidation(userText, options.purpose) : "[]" };
344
344
  yield { type: "finish", reason: { kind: "stop" } };
345
345
  }
346
346
  }
@@ -355,7 +355,7 @@ test("issue#9: sleep forwards sleepReasoningEffort on its LLM passes", async ()
355
355
  vectorIndex.saveEmbedding(b.id, [1, 0, 0]);
356
356
  const captured = [];
357
357
  const ctx = sleepCtx(
358
- (userText) => userText.startsWith("候选冲突")
358
+ (userText, purpose) => purpose === "sleep-conflict"
359
359
  ? JSON.stringify([{ action: "conflict", winner: a.id, loser: b.id, reason: "重复覆盖" }])
360
360
  : "[]",
361
361
  { provider: "mock", model: "sleep-model" },
@@ -378,7 +378,7 @@ test("issue#135: sleep unset effort also resolves to the lowest supported", asyn
378
378
  vectorIndex.saveEmbedding(b.id, [1, 0, 0]);
379
379
  const captured = [];
380
380
  const ctx = sleepCtx(
381
- (userText) => userText.startsWith("候选冲突")
381
+ (userText, purpose) => purpose === "sleep-conflict"
382
382
  ? JSON.stringify([{ action: "conflict", winner: a.id, loser: b.id, reason: "重复覆盖" }])
383
383
  : "[]",
384
384
  { provider: "mock", model: "sleep-model" },
@@ -433,7 +433,7 @@ test("rc.1 stream-level effort rejection (error finish chunk) also triggers the
433
433
  return;
434
434
  }
435
435
  const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
436
- if (userText.startsWith("id=")) {
436
+ if (userText.includes("| type=")) {
437
437
  yield { type: "text-delta", index: 0, text: JSON.stringify([
438
438
  { action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "合并标题", content: "合并内容", importance: 4 }
439
439
  ]) };
@@ -500,7 +500,7 @@ test("sleep passes the stream failure accessor so a stream-level effort rejectio
500
500
  return;
501
501
  }
502
502
  const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
503
- yield { type: "text-delta", index: 0, text: userText.startsWith("候选冲突")
503
+ yield { type: "text-delta", index: 0, text: options.purpose === "sleep-conflict"
504
504
  ? JSON.stringify([{ action: "conflict", winner: a.id, loser: b.id, reason: "重复覆盖" }])
505
505
  : "[]" };
506
506
  yield { type: "finish", reason: { kind: "stop" } };
@@ -625,7 +625,7 @@ test("defaultEffort trap: capability query failure falls back to configured effo
625
625
  throw new Error("UNSUPPORTED_REASONING_EFFORT: mock does not support reasoning effort \"high\"");
626
626
  }
627
627
  const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
628
- if (userText.startsWith("id=")) {
628
+ if (userText.includes("| type=")) {
629
629
  yield { type: "text-delta", index: 0, text: JSON.stringify([
630
630
  { action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "合并标题", content: "合并内容", importance: 4 }
631
631
  ]) };
@@ -655,7 +655,7 @@ test("defaultEffort trap: sleep conflict pass remaps a poison effort too", async
655
655
  vectorIndex.saveEmbedding(b.id, [1, 0, 0]);
656
656
  const captured = [];
657
657
  const ctx = sleepCtx(
658
- (userText) => userText.startsWith("候选冲突")
658
+ (userText, purpose) => purpose === "sleep-conflict"
659
659
  ? JSON.stringify([{ action: "conflict", winner: a.id, loser: b.id, reason: "重复覆盖" }])
660
660
  : "[]",
661
661
  { provider: "mock", model: "sleep-model" },
@@ -53,7 +53,7 @@ function llmCtx(captured = [], selection = { provider: "mock", model: "sleep-mod
53
53
  async *stream(options) {
54
54
  captured.push(options);
55
55
  const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
56
- yield { type: "text-delta", index: 0, text: userText.startsWith("候选冲突") || userText.startsWith("Candidate conflicts") ? "[]" : "[]" };
56
+ yield { type: "text-delta", index: 0, text: options.purpose === "sleep-conflict" ? "[]" : "[]" };
57
57
  yield { type: "finish", reason: { kind: "stop" } };
58
58
  }
59
59
  }
@@ -210,7 +210,7 @@ test("non-freeze with an LLM: cross-scope pair bypasses adjudication; same-scope
210
210
  ctx.llm.stream = async function* (options) {
211
211
  captured.push(options);
212
212
  const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
213
- const isConflictPass = userText.startsWith("候选冲突") || userText.startsWith("Candidate conflicts");
213
+ const isConflictPass = options.purpose === "sleep-conflict";
214
214
  const text = isConflictPass
215
215
  ? JSON.stringify([{ action: "conflict", winner: yNew.id, loser: yOld.id, reason: "同归属重复" }])
216
216
  : "[]";
@@ -221,9 +221,12 @@ test("non-freeze with an LLM: cross-scope pair bypasses adjudication; same-scope
221
221
  const result = await runSleep(ctx, service, baseConfig(), ctx.logger, { embedder, vectorIndex }, null);
222
222
  assert.equal(result.status, "ok");
223
223
 
224
+ // 认「哪一次是冲突裁决」改按 purpose,不再看 user 段首行:#493 追加的围栏让 user 段以
225
+ // 角色声明开头,而败者行的溯源注释里也会出现「候选冲突」这个词——用 includes 会把模式
226
+ // 阶段误判成冲突阶段。purpose 与 prompt 形态彻底解耦。
224
227
  const conflictPassTexts = captured
225
- .map((o) => o.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "")
226
- .filter((t) => t.startsWith("候选冲突") || t.startsWith("Candidate conflicts"));
228
+ .filter((o) => o.purpose === "sleep-conflict")
229
+ .map((o) => o.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "");
227
230
  assert.ok(conflictPassTexts.length >= 1, "conflict pass ran for the same-scope pair");
228
231
  for (const t of conflictPassTexts) {
229
232
  assert.ok(!t.includes(xNew.id) && !t.includes(xOld.id), "cross-scope pair must never enter the LLM prompt");
@@ -421,6 +421,135 @@ test("睡眠侧:被按下作用域的行不进 pattern 扫描池,也就不
421
421
  store.close();
422
422
  });
423
423
 
424
+ // --- issue #515:闸门把可扫行挡在 LIMIT 窗口之外 ---------------------------
425
+ //
426
+ // 上面那条用例锁的是「政策轴排在截断之前」,但它的库只有 4 行,根本碰不到
427
+ // store.list 的 `LIMIT 200`,所以它测不到 #515 真正的症状:被按下的行**多到占满窗口**
428
+ // 时,过滤前它们吃掉全部名额、过滤后自家行一条都不剩,相位回 `no memories to scan`
429
+ // ——读起来像「库里没东西可扫」,实际是「闸门把可扫的行挡在了窗口里」。
430
+ // 修复方向是不动判定、只放宽取数:窗口过滤后不够本次要扫的名额时,不带窗口重取一次。
431
+ //
432
+ // 造「自家行在窗口之外」用的是 importance 而不是 updated_at:默认档位是
433
+ // `importance DESC, updated_at DESC, id`,而 store.save 的时间戳由实现内部取
434
+ // nowIso()(调用方传不进去),同毫秒落库的行会退到 id 平局键——靠时间戳排序会依赖
435
+ // 插入速度,快机器上自家行可能反而排进窗口,用例就成了偶发绿。importance 差(5 vs 1)
436
+ // 让被按下的那批恒占窗口前排,与毫秒无关。
437
+
438
+ /** 填满 200 窗口的按下行 + 两条落在窗口之外的自家行。 */
439
+ function seedStarvedPatternPool(store, service) {
440
+ const own = [];
441
+ for (const t of ["own-1", "own-2"]) {
442
+ own.push(store.save({ type: "project", title: t, content: `${t} 正文`, importance: 1 }));
443
+ }
444
+ for (let i = 0; i < 200; i++) {
445
+ store.save({
446
+ type: "project", title: `paused-${i}`, content: `paused-${i} 正文`, importance: 5,
447
+ workspace_scope: "proj-a", workspace_scope_source: "explicit"
448
+ });
449
+ }
450
+ service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "paused" });
451
+ return own;
452
+ }
453
+
454
+ /** 记录 service.list 的入参(只包 list,其余方法原样透出),用于「付了几次查询」。 */
455
+ function countingList(service, calls) {
456
+ return new Proxy(service, {
457
+ get(target, prop) {
458
+ if (prop === "list") {
459
+ return (query) => {
460
+ calls.push(query);
461
+ return target.list(query);
462
+ };
463
+ }
464
+ return Reflect.get(target, prop, target);
465
+ }
466
+ });
467
+ }
468
+
469
+ test("#515:被按下的行占满 LIMIT 200 窗口时,模式扫描不报「没东西可扫」", async () => {
470
+ const { store, service } = setup(sleepConfig());
471
+ seedStarvedPatternPool(store, service);
472
+
473
+ const seen = [];
474
+ const ctx = {
475
+ ...sleepCtx(),
476
+ llm: {
477
+ async *stream(options) {
478
+ const text = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
479
+ // 认清单**形状**(行里带 `| importance=`),不认首行——#501/#493 那批围栏让 user 段以角色声明开头了
480
+ if (text.includes("| importance=")) seen.push(text);
481
+ yield { type: "text-delta", index: 0, text: "[]" };
482
+ yield { type: "finish", reason: { kind: "stop" } };
483
+ }
484
+ }
485
+ };
486
+ const result = await runSleep(ctx, service, sleepConfig(), ctx.logger, null, null);
487
+ const patterns = result.phases.patterns;
488
+
489
+ assert.notEqual(patterns?.reason, "no memories to scan",
490
+ `窗口被按下行占满时不该静默跳过,实得 ${JSON.stringify(patterns)}`);
491
+ // 反向断言:这条才是「真的扫了」的证据——只断上面那句的话,一个「相位整个没跑」
492
+ // 的实现(连 reason 都不产出)也能过。
493
+ assert.equal(seen.length, 1, "模式扫描必须真发起了这次 LLM 调用");
494
+ assert.ok(seen[0].includes("own-1") && seen[0].includes("own-2"), "窗口外的自家行要被重取回来");
495
+ // 重取只放宽窗口,不放宽判定:被按下的行一条都不许混进提示。
496
+ assert.ok(!seen[0].includes("paused-"), "被按下的行不该因为重取而混进扫描池");
497
+ store.close();
498
+ });
499
+
500
+ test("#515:重取按需——闸门没按下且窗口够用时,仍只付一次有界查询", async () => {
501
+ const { store, service } = setup(sleepConfig());
502
+ // 120 条自家行(无闸门):过滤后的池子 ≥ sleepPatternMinMemories(100),窗口没被吃掉,
503
+ // 第二趟不该发生。这一条锁的是「常态仍只付一次有界查询」那半句承诺。
504
+ for (let i = 0; i < 120; i++) {
505
+ store.save({ type: "project", title: `m-${i}`, content: `m-${i} 正文`, importance: 3 });
506
+ }
507
+ const calls = [];
508
+ const ctx = sleepCtx();
509
+ await runSleep(ctx, countingList(service, calls), sleepConfig(), ctx.logger, null, null);
510
+
511
+ assert.equal(calls.length, 1, `常态只该取一次,实得 ${calls.length} 次`);
512
+ assert.equal(calls[0].limit, 200, "常态走的是有界窗口");
513
+ store.close();
514
+ });
515
+
516
+ test("#515:窗口跟着配额走——把 sleepPatternMinMemories 配到 200 之上时,常态仍不触发无界重取", async () => {
517
+ // 这条锁的是我自己差点留下的坑:合法上限是 1000(src/config.js:612),若窗口仍是死的
518
+ // 200,凡把这个值配到 200 以上的人**每一轮**都会白付一次全表重取,跟闸门按没按下无关。
519
+ const config = sleepConfig({ sleepPatternMinMemories: 300 });
520
+ const { store, service } = setup(config);
521
+ for (let i = 0; i < 400; i++) {
522
+ store.save({ type: "project", title: `m-${i}`, content: `m-${i} 正文`, importance: 3 });
523
+ }
524
+ const calls = [];
525
+ const ctx = sleepCtx();
526
+ await runSleep(ctx, countingList(service, calls), config, ctx.logger, null, null);
527
+
528
+ assert.equal(calls.length, 1, `窗口够用时不该重取,实得 ${calls.length} 次`);
529
+ assert.equal(calls[0].limit, 300, "窗口至少等于本次要扫的名额");
530
+ store.close();
531
+ });
532
+
533
+ test("#515:窗口饿死时重取一次,且只放宽 limit——三条过滤轴一条不许动", async () => {
534
+ const { store, service } = setup(sleepConfig());
535
+ seedStarvedPatternPool(store, service);
536
+
537
+ const calls = [];
538
+ const ctx = sleepCtx();
539
+ await runSleep(ctx, countingList(service, calls), sleepConfig(), ctx.logger, null, null);
540
+
541
+ assert.equal(calls.length, 2, `饿死时补一趟无界重取,实得 ${calls.length} 次`);
542
+ assert.equal(calls[0].limit, 200);
543
+ assert.equal(calls[1].limit, null, "重取是不带窗口(store.list 的 limit == null 即无界档)");
544
+ // 除 limit 之外的口径必须逐字段相同:重取放宽的是窗口,不是判定。
545
+ assert.deepEqual(
546
+ { ...calls[0], limit: null },
547
+ calls[1],
548
+ "第二趟只能比第一趟少一个窗口,excludeTypes/excludeImported/includeForgotten 都得在"
549
+ );
550
+ store.close();
551
+ });
552
+
424
553
  test("睡眠侧:被按下作用域的行不进关系补全的候选池", async () => {
425
554
  const { store, service } = setup(sleepConfig());
426
555
  const alpha = service.createEntity({ name: "Alpha", type: "project" });
@@ -453,7 +582,7 @@ test("巩固侧:被按下作用域的行不进总览输入——否则会被
453
582
  llm: {
454
583
  async *stream(options) {
455
584
  const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
456
- if (userText.startsWith("id=")) {
585
+ if (userText.includes("| type=")) {
457
586
  yield { type: "text-delta", index: 0, text: "[]" };
458
587
  yield { type: "finish", reason: { kind: "stop" } };
459
588
  return;
@@ -4,6 +4,8 @@ import { mkdtempSync, rmSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { createServeRuntime } from "../src/serve.js";
7
+ import { createStore } from "../src/store.js";
8
+ import { createSettings } from "../src/settings.js";
7
9
 
8
10
  // daemon 装配面锁(test/serve-bin.test.js 另有真子进程 + 多进程共存):
9
11
  // in-process 起真 HTTP(port 0,OS 分配),fetch 走 health / 401 / save / search /
@@ -194,3 +196,63 @@ test("serve: 非法 embed provider 拒装配;embed=off 时 semantic 为 null", a
194
196
  try { rmSync(dir, { recursive: true, force: true }); } catch { /* 同上 */ }
195
197
  }
196
198
  });
199
+
200
+ // --- GET /memory/provenance 的 distillRecords.enabled 门(#509 评审整改)--------
201
+ // 单独立一条 daemon 级用例的理由:standalone 的既有用例是手搭 config 骗过门的,
202
+ // 而 daemon 的 cfg 来自 serve.js 自己装配(原本是最小集,一个 distillRecords 键都没有)
203
+ // ——不测真实装配,就会漏掉「daemon 恒定回功能关了」这一整类回归。
204
+ async function seedFlags(dir, patch) {
205
+ const seed = createStore(join(dir, "memory.db"));
206
+ createSettings(seed.db).setFeatureFlags(patch);
207
+ seed.close();
208
+ }
209
+
210
+ test("serve: /memory/provenance 跟随面板开关(开着读得到)", async () => {
211
+ const dir = tmpDir();
212
+ await seedFlags(dir, { "distillRecords.enabled": true });
213
+ const rt = await createServeRuntime({ memoryDir: dir, port: 0, embed: "off" });
214
+ await rt.api.ready;
215
+ try {
216
+ const base = `http://127.0.0.1:${rt.api.port}`;
217
+ const auth = { authorization: `Bearer ${rt.api.token}` };
218
+ // 先经数据面写一条记忆,再直接落溯源链接与原文(写入面不关心是谁写的)。
219
+ const saved = await fetch(`${base}/memories`, {
220
+ method: "POST", headers: { ...auth, "content-type": "application/json" },
221
+ body: JSON.stringify({ type: "decision", title: "核心决策", content: "选定 SQLite 单文件库", importance: 5 })
222
+ });
223
+ const savedBody = await saved.json();
224
+ const memoryId = savedBody.memory?.id ?? savedBody.id;
225
+ rt.store.saveMemoryProvenance({ memoryId, sessionId: "s1", seqStart: 10, seqEnd: 11, createdAt: "2026-10-23T00:00:00Z" });
226
+ rt.store.saveDistillRecords([
227
+ { sessionId: "s1", seq: 10, role: "user", text: "第一句原文", windowId: "w1", distilledAt: "2026-10-23T00:00:00Z" },
228
+ { sessionId: "s1", seq: 11, role: "assistant", text: "第二句原文", windowId: "w1", distilledAt: "2026-10-23T00:00:00Z" }
229
+ ]);
230
+
231
+ const res = await fetch(`${base}/memory/provenance?id=${encodeURIComponent(memoryId)}`, { headers: auth });
232
+ assert.equal(res.status, 200, "面板开着的留存,daemon 必须读得到(serve.js 把开关展开进了 cfg)");
233
+ const body = await res.json();
234
+ assert.deepEqual(body.windows[0].records.map((r) => r.text), ["第一句原文", "第二句原文"]);
235
+ } finally {
236
+ rt.dispose();
237
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* win 文件锁偶发 */ }
238
+ }
239
+ });
240
+
241
+ test("serve: 面板从没设过 distillRecords.enabled → daemon 这一面按关处理(409)", async () => {
242
+ // 残留不对称(写进 docs/DAEMON.md):只靠 bundle 配置打开、从没在面板点过的用户,
243
+ // 宿主认、daemon 不认——daemon 结构上看不到宿主的 bundle config。这是已知的那一半,
244
+ // 不是本用例要修的;这里锁的是「没设过 → 关」这个 fail-closed 方向本身。
245
+ const dir = tmpDir();
246
+ const rt = await createServeRuntime({ memoryDir: dir, port: 0, embed: "off" });
247
+ await rt.api.ready;
248
+ try {
249
+ const res = await fetch(`http://127.0.0.1:${rt.api.port}/memory/provenance?id=m1`, {
250
+ headers: { authorization: `Bearer ${rt.api.token}` }
251
+ });
252
+ assert.equal(res.status, 409);
253
+ assert.deepEqual(await res.json(), { error: "distill-records-disabled" });
254
+ } finally {
255
+ rt.dispose();
256
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* win 文件锁偶发 */ }
257
+ }
258
+ });