@modusensus/dsh-mneme 0.9.5 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +10 -5
  2. package/docs/CONFIGURATION.md +21 -0
  3. package/docs/DAEMON.md +6 -2
  4. package/docs/DISTILL.md +90 -6
  5. package/docs/SLEEP.md +20 -6
  6. package/docs/STORAGE.md +1 -0
  7. package/lib/api-standalone.js +11 -3
  8. package/lib/api.js +76 -6
  9. package/lib/client.js +259 -11
  10. package/lib/config.js +33 -1
  11. package/lib/continuity.js +11 -3
  12. package/lib/dream/decisions.js +88 -27
  13. package/lib/dream/sleep.js +467 -81
  14. package/lib/dream.js +110 -13
  15. package/lib/fence.js +24 -0
  16. package/lib/graph/anchoring.js +7 -2
  17. package/lib/index.js +15 -6
  18. package/lib/inject.js +9 -4
  19. package/lib/lang.js +17 -12
  20. package/lib/pipeline-nudge-auto.js +3 -0
  21. package/lib/pipeline-nudge.js +41 -8
  22. package/lib/pipeline-runner.js +15 -0
  23. package/lib/pre-inject-gate.js +14 -0
  24. package/lib/profile-extract.js +4 -1
  25. package/lib/provenance-http.js +162 -37
  26. package/lib/service.js +99 -8
  27. package/lib/session-gate.js +91 -0
  28. package/lib/settings.js +13 -1
  29. package/lib/store.js +681 -18
  30. package/lib/summarize.js +152 -15
  31. package/lib/tools.js +26 -6
  32. package/package.json +4 -2
  33. package/src/api-standalone.js +11 -3
  34. package/src/api.js +76 -6
  35. package/src/config.js +33 -1
  36. package/src/continuity.js +11 -3
  37. package/src/dream/decisions.js +88 -27
  38. package/src/dream/sleep.js +467 -81
  39. package/src/dream.js +110 -13
  40. package/src/fence.js +24 -0
  41. package/src/graph/anchoring.js +7 -2
  42. package/src/index.js +15 -6
  43. package/src/inject.js +9 -4
  44. package/src/lang.js +17 -12
  45. package/src/pipeline-nudge-auto.js +3 -0
  46. package/src/pipeline-nudge.js +41 -8
  47. package/src/pipeline-runner.js +15 -0
  48. package/src/pre-inject-gate.js +14 -0
  49. package/src/profile-extract.js +4 -1
  50. package/src/provenance-http.js +162 -37
  51. package/src/service.js +99 -8
  52. package/src/session-gate.js +91 -0
  53. package/src/settings.js +13 -1
  54. package/src/store.js +681 -18
  55. package/src/summarize.js +152 -15
  56. package/src/tools.js +26 -6
  57. package/test/api.test.js +153 -4
  58. package/test/client.test.js +380 -0
  59. package/test/config.test.js +8 -0
  60. package/test/conflict-reviewed.test.js +79 -0
  61. package/test/continuity.test.js +29 -1
  62. package/test/dependency-overrides.test.js +78 -0
  63. package/test/differentiate-feedback.test.js +248 -0
  64. package/test/dream-decision-evidence.test.js +314 -0
  65. package/test/dream-fence.test.js +43 -0
  66. package/test/graph-boosted-relations.test.js +189 -0
  67. package/test/graph-multipath.test.js +269 -0
  68. package/test/inject-pools.test.js +2 -1
  69. package/test/mint-source.test.js +83 -0
  70. package/test/pipeline-nudge-auto.test.js +26 -2
  71. package/test/pipeline-nudge.test.js +87 -0
  72. package/test/pipeline-runner.test.js +26 -0
  73. package/test/prompt-stance.test.js +128 -0
  74. package/test/provenance-http.test.js +200 -17
  75. package/test/release-docs.test.js +7 -1
  76. package/test/security-doc.test.js +43 -4
  77. package/test/session-gate.test.js +264 -0
  78. package/test/settings.test.js +4 -0
  79. package/test/sleep-action-set.test.js +16 -11
  80. package/test/sleep-catchup.test.js +315 -0
  81. package/test/sleep-failure-backoff.test.js +156 -0
  82. package/test/sleep.test.js +293 -0
  83. package/test/standalone-api.test.js +50 -0
  84. package/test/store.test.js +220 -0
  85. package/test/summarize.test.js +172 -0
@@ -37,6 +37,7 @@ import {
37
37
  nudgeForProject,
38
38
  resolveProjectSessionDir
39
39
  } from "../src/pipeline-nudge.js";
40
+ import { createSessionGate } from "../src/session-gate.js";
40
41
 
41
42
  // 这组测试守的是 M.O. 的**沉淀链**:一条工作流能不能从真实数据里自己长出来。
42
43
  // 三条不许越的分界线(见 src/pipeline-nudge.js 文件头),测试逐条守:
@@ -103,6 +104,92 @@ test("多帧 zstd 要逐帧解:只解第一帧会静默丢掉后面的回合",
103
104
  });
104
105
  });
105
106
 
107
+ test("#539:nudge 日志扫描按精确 id 与真实子代理头过滤会话", () => {
108
+ withDir((dir) => {
109
+ const sessions = join(dir, "sessions");
110
+ const manualDir = join(sessions, "ws", "manual");
111
+ const childDir = join(sessions, "ws", "child");
112
+ const captainDir = join(sessions, "ws", "captain");
113
+ mkdirSync(manualDir, { recursive: true });
114
+ mkdirSync(childDir, { recursive: true });
115
+ mkdirSync(captainDir, { recursive: true });
116
+ const privateCall = sessionLine({ turn: 1, calls: [skill("private-skill"), skill("private-next")] });
117
+ writeFileSync(join(manualDir, "session.v4.jsonl.zstd"), zlib.zstdCompressSync(Buffer.from(privateCall)));
118
+ const childHeader = JSON.stringify({
119
+ type: "session",
120
+ cwd: "C:\\proj",
121
+ parentSession: "captain",
122
+ origin: "subagent",
123
+ delegationDepth: 1
124
+ });
125
+ writeFileSync(join(childDir, "session.v4.jsonl.zstd"), zlib.zstdCompressSync(Buffer.from(`${childHeader}\n${privateCall}`)));
126
+ const publicCall = sessionLine({ turn: 1, calls: [skill("public-skill"), skill("public-next")] });
127
+ const captainHeader = JSON.stringify({ type: "session", cwd: "C:\\proj", origin: "main", delegationDepth: 0 });
128
+ writeFileSync(join(captainDir, "session.v4.jsonl.zstd"), zlib.zstdCompressSync(Buffer.from(`${captainHeader}\n${publicCall}`)));
129
+ // 只带 parentSession 的会话 = sessions.fork() 出来的主会话分支,不是子代理。
130
+ // 本机 641 个真实会话头里这样的有 3 个(各 74–136 条用户消息),把它们当子代理
131
+ // 过滤掉就是「fork 一下就失忆」。
132
+ const forkedDir = join(sessions, "ws", "forked");
133
+ mkdirSync(forkedDir, { recursive: true });
134
+ const forkedHeader = JSON.stringify({
135
+ type: "session",
136
+ version: 4,
137
+ cwd: "C:\\proj",
138
+ parentSession: "captain",
139
+ isSeeded: true,
140
+ delegationDepth: 0
141
+ });
142
+ const forkedCall = sessionLine({ turn: 1, calls: [skill("forked-skill"), skill("forked-next")] });
143
+ writeFileSync(join(forkedDir, "session.v4.jsonl.zstd"), zlib.zstdCompressSync(Buffer.from(`${forkedHeader}\n${forkedCall}`)));
144
+
145
+ const gate = createSessionGate({
146
+ sessionGateEnabled: true,
147
+ sessionGateSubagents: true,
148
+ sessionGateSessions: ["manual"]
149
+ });
150
+ const read = readSessionObservations({ sessionDir: sessions, sessionGate: gate });
151
+ assert.deepEqual([...new Set(read.observations.map((item) => item.sessionId))], ["captain", "forked"]);
152
+ assert.ok(!read.observations.some((item) => item.name.startsWith("private")));
153
+ assert.ok(read.observations.some((item) => item.name === "forked-skill"), "a forked captain session keeps its observations");
154
+
155
+ const disabled = createSessionGate({ sessionGateEnabled: false, sessionGateSessions: ["manual"] });
156
+ const unchanged = readSessionObservations({ sessionDir: sessions, sessionGate: disabled });
157
+ assert.ok(unchanged.observations.some((item) => item.sessionId === "manual"), "master-off keeps existing scan behavior");
158
+ });
159
+ });
160
+
161
+ // 形状锁:只有首行是 {"type":"session",...} 时才把整行当作会话头。
162
+ // 旧实现的兜底链末尾是 `?? row?.data?.header ?? row?.data?.config ?? row?.data ?? row`,
163
+ // 等于**任何**首行都能被当成 header。这不只是「会不会漏」的问题:一条非会话记录
164
+ // 里只要出现 `origin` / `delegationDepth`,就会被读成一条子代理头,把这个**正常
165
+ // 会话**的观测从扫描结果里整批划掉——M.O. 的提议因此静默少掉一块数据,而现场
166
+ // 回头看日志时那条会话明明在。本机 641 个真实会话首行的 `type` 全部是
167
+ // "session"(v3/v4 都是头字段直接躺在首行顶层),先认 `type` 不漏任何真实会话;
168
+ // 认不出来时返回 null、退回只按 id 判——**宁可少认一条头,也不要凭空造一个头**。
169
+ test("#539:非 session 首行不许把委派标记带进闸门", () => {
170
+ withDir((dir) => {
171
+ const sessions = join(dir, "sessions");
172
+ const captainDir = join(sessions, "ws", "captain");
173
+ mkdirSync(captainDir, { recursive: true });
174
+ const call = sessionLine({ turn: 1, calls: [skill("captain-skill")] });
175
+ // 首行是一条 permission/preset 记录,且 data 里带着委派标记——这一帧里
176
+ // 根本没有会话头,那两个字只是某条普通记录的字段。
177
+ const preset = JSON.stringify({ type: "permission/preset", seq: 0, time: 1, data: { origin: "subagent", delegationDepth: 1 } });
178
+ writeFileSync(join(captainDir, "session.v4.jsonl.zstd"), zlib.zstdCompressSync(Buffer.from(`${preset}\n${call}`)));
179
+
180
+ const automatic = createSessionGate({ sessionGateEnabled: true, sessionGateSubagents: true });
181
+ const read = readSessionObservations({ sessionDir: sessions, sessionGate: automatic });
182
+ assert.ok(
183
+ read.observations.some((item) => item.name === "captain-skill"),
184
+ "首行认不出头时不许凭它的字段判子代理——按 id 判、放行"
185
+ );
186
+
187
+ const manual = createSessionGate({ sessionGateEnabled: true, sessionGateSessions: ["captain"] });
188
+ const filtered = readSessionObservations({ sessionDir: sessions, sessionGate: manual });
189
+ assert.equal(filtered.observations.length, 0, "手动名单仍然按目录名里的精确 id 生效");
190
+ });
191
+ });
192
+
106
193
  test("截断帧:半行被丢掉,不抛错、也不把半行当成一条记录", () => {
107
194
  withDir((dir) => {
108
195
  const sessions = join(dir, "sessions");
@@ -5,6 +5,7 @@ import { writeFileSync, rmSync } from "node:fs";
5
5
  import { join } from "node:path";
6
6
  import { createExecutionRecorder, projectRootOf, MAX_BUFFERED_EVENTS } from "../src/pipeline-runner.js";
7
7
  import { validatePipeline } from "../src/pipeline.js";
8
+ import { createSessionGate } from "../src/session-gate.js";
8
9
 
9
10
  // 这组测试守的是串联层:把「事件 → 观察 → 归属 → 窗口 → 落盘」串起来的那一层。
10
11
  // 它最要紧的两条性质:
@@ -61,6 +62,31 @@ test("没开窗时一个字都不记:事件到了也不缓冲(这是整个
61
62
  assert.equal(rec.stop({ session }).reason, "no-open-window");
62
63
  });
63
64
 
65
+ test("#539:无痕会话不开始、缓冲或结算执行记录", () => {
66
+ const f = fakeCtx();
67
+ const config = { sessionGateEnabled: false, sessionGateSessions: ["s-private"] };
68
+ const gate = createSessionGate(config);
69
+ let writes = 0;
70
+ const rec = createExecutionRecorder({
71
+ ctx: f.ctx,
72
+ sessionGate: gate,
73
+ resolvePipeline: () => PIPE,
74
+ persistRun: () => { writes += 1; }
75
+ });
76
+ const session = { id: "s-private", header: { cwd: "C:\\proj" } };
77
+
78
+ assert.equal(rec.start({ session, pipelineId: "demo" }).ok, true);
79
+ for (const event of callAndResult("npm test")) f.emit(session, event);
80
+ assert.equal(rec.stop({ session }).state, "awaiting-confirmation");
81
+
82
+ config.sessionGateEnabled = true;
83
+ assert.equal(rec.status({ session }).incognito, true);
84
+ assert.equal(rec.confirm({ session, answers: { P6: "done" } }).reason, "session-incognito");
85
+ config.sessionGateEnabled = false;
86
+ assert.deepEqual(rec.status({ session }), { open: false }, "gating discards both buffered and pending session data");
87
+ assert.equal(writes, 0, "incognito observation never reaches persistent run storage");
88
+ });
89
+
64
90
  test("开窗 → 走一遍 → 结账:产出并落盘一条账", () => {
65
91
  const f = fakeCtx();
66
92
  let written = null;
@@ -0,0 +1,128 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { STR } from "../src/lang.js";
4
+ import { createStore } from "../src/store.js";
5
+ import { createService } from "../src/service.js";
6
+ import { createDreamScheduler } from "../src/dream.js";
7
+
8
+ // 防谄媚的**提示词侧**回归锁。
9
+ //
10
+ // 背景:#380 的注入前判定(pre-inject-gate)只拦「已落库的意见/立场」,而落到库里
11
+ // 之前的两个写作环节——蒸馏(summary / codingSummary)与总览叙述(dreamSummary)
12
+ // ——此前对谄媚内容一字未提。这两个环节写出的内容**完全不过** writeAdmission
13
+ // (write-admission.js 只在带 sessionKey 的 memory_save 路径生效),所以提示词是
14
+ // 唯一能施加约束的地方,本轮把「立场纪律 / 叙述纪律」写进字面量(Issue #530)。
15
+ //
16
+ // 这些用例锁的是**形状**(提示词里有没有这条约束、位置对不对),锁不了**行为**
17
+ // (模型是否真的照做)——后者要 LLM 在环 + 预注册判据,属 E12 那类实验,不在单测里
18
+ // 宣称。PR 与 CHANGELOG 不得把本文件写成「已知降低谄媚率」。
19
+
20
+ const LANGS = ["zh", "en"];
21
+
22
+ // 五组判据在两种语言里的对应词。写成「中英成对」是刻意的:少一种语言就等于
23
+ // 换语言的用户拿到一份没有防线的提示词(同 lang.test.js 对 profileExtract 的锁法)。
24
+ const STANCE_PATTERNS = [
25
+ [/立场纪律|Stance discipline/, "立场纪律小节本身"],
26
+ [/不要记录对用户本人|Never record evaluations of the user/, "规则 a:不记对用户本人的性格/能力/情绪评价"],
27
+ [/可信度不同|differ in trustworthiness/, "规则 b:转录三种角色可信度不同"],
28
+ [/事实归属以用户陈述为准|the user statement wins on the facts/, "规则 c:事实归属不记成助手推断"],
29
+ [/如实并存|keep both sides/, "规则 c:两种说法并存,不写成谁对谁错"],
30
+ [/不是提高 importance 的理由|never a reason to raise it/, "规则 d:赞许不抬 importance"],
31
+ ];
32
+
33
+ for (const key of ["summary", "codingSummary"]) {
34
+ test(`${key} 提示词带完整立场纪律(中英成对)`, () => {
35
+ for (const lang of LANGS) {
36
+ const text = STR.prompts[key][lang];
37
+ for (const [re, label] of STANCE_PATTERNS) {
38
+ assert.match(text, re, `${key}.${lang} 缺少${label}`);
39
+ }
40
+ }
41
+ });
42
+ }
43
+
44
+ test("立场纪律排在任何 JSON 输出形状之前,不会被后置的时间锚点覆盖", () => {
45
+ // summarize.js 在提示词**之后**再拼 STR.distillTimeAnchor(「据此换算 occurred_at」)。
46
+ // 规则若排在那句之后,就成了「先按时间换算、再谈记什么」——指令优先级反了。
47
+ // 同时它必须排在 schema 行之前:schema 是输出形状,纪律是内容判据,形状压不住判据。
48
+ for (const key of ["summary", "codingSummary"]) {
49
+ for (const lang of LANGS) {
50
+ const text = STR.prompts[key][lang];
51
+ const stance = text.search(/立场纪律|Stance discipline/);
52
+ assert.ok(stance > 0, `${key}.${lang}: 立场纪律必须存在于提示词字面量内部`);
53
+ assert.ok(stance < text.search(/occurred_at|occurred_at/), `${key}.${lang}: 立场纪律必须在 occurred_at 说明之前`);
54
+ assert.ok(stance < text.search(/只输出 JSON 数组|Output only a JSON array/), `${key}.${lang}: 立场纪律必须在输出形状之前`);
55
+ assert.ok(text.indexOf("立场纪律") < 0 || stance > text.indexOf("原子记忆原则") - 1,
56
+ `${key}.${lang}: 立场纪律要跟在原子记忆原则之后,别抢掉开头角色句`);
57
+ }
58
+ }
59
+ });
60
+
61
+ test("工具/报错结论的豁免条款必须在场(删掉它会误杀 pitfall 与 constraint)", () => {
62
+ // 规则 b 说「助手的结论是推断」,但编码类记忆(pitfall / rejected_solution /
63
+ // constraint)的内容本来就是工具输出与报错直接给出的技术结论。没有这半句豁免,
64
+ // 规则 b 会把整个编码回溯功能一并否掉——这是最容易被后人顺手删掉的高危句。
65
+ for (const lang of LANGS) {
66
+ const text = STR.prompts.summary[lang];
67
+ assert.match(text, /工具输出、报错、命令结果|tool output, errors or command results/,
68
+ `summary.${lang}: 工具结论豁免条款缺失,规则 b 会误杀 pitfall/constraint`);
69
+ }
70
+ });
71
+
72
+ test("dreamSummary 带叙述纪律,且散文契约不被破坏", () => {
73
+ for (const lang of LANGS) {
74
+ const text = STR.prompts.dreamSummary[lang];
75
+ assert.match(text, /叙述纪律|Narrative discipline/, `dreamSummary.${lang}: 缺叙述纪律`);
76
+ assert.match(text, /不要给用户添上|add no characterisation/, `dreamSummary.${lang}: 缺「不给用户添性格评价」`);
77
+ // 总览是**散文**契约:dream-fence 那条锁要求这里绝不能出现「JSON 数组」,
78
+ // 否则模型会按数组契约回 `[]`,而 summaryText.trim() 判非空通过,`[]` 就
79
+ // 被当成摘要存进库(dream.js:326-328 的注释记着这次评审实测)。
80
+ assert.doesNotMatch(text, /JSON 数组|JSON array/, `dreamSummary.${lang}: 散文契约里不得出现 JSON 数组`);
81
+ // 硬输出格式锁必须留在末尾,它是「只回叙述文本」的唯一保证。
82
+ assert.match(lang === "zh" ? text : text, /只输出叙述文本,不要其他内容。|Output only the narrative text, nothing else./,
83
+ `dreamSummary.${lang}: 末尾输出格式锁丢了`);
84
+ }
85
+ });
86
+
87
+ // 下面这条是**输出侧**的接线锁:提示词改了,落库判据不能跟着变。
88
+ // 空字符串仍必须判为「没有摘要」(degraded/noop),不能被新文案带成「存了个空叙述」。
89
+ test("总览落库判据不因提示词改动而放宽", async () => {
90
+ function mockCtx({ summaryText }) {
91
+ return {
92
+ logger: { warn: () => {} },
93
+ agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
94
+ llm: {
95
+ async *stream(options) {
96
+ const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
97
+ if (userText.includes("| type=")) {
98
+ yield { type: "block-start", index: 0, blockType: "text" };
99
+ yield { type: "text-delta", index: 0, text: "[]" };
100
+ yield { type: "block-end", index: 0, block: { type: "text" } };
101
+ yield { type: "finish", reason: { kind: "stop" } };
102
+ return;
103
+ }
104
+ yield { type: "text-delta", index: 0, text: summaryText };
105
+ yield { type: "finish", reason: { kind: "stop" } };
106
+ }
107
+ }
108
+ };
109
+ }
110
+
111
+ const store = createStore(":memory:");
112
+ const service = createService({ store, mirror: null, config: {} });
113
+ const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
114
+ service.saveWithDedupe({ type: "preference", title: "语言", content: "中文", importance: 3 });
115
+
116
+ const ok = await dream.runDream(mockCtx({ summaryText: "记忆库总览:用户偏好中文。" }), service, {});
117
+ assert.equal(ok.summary, true, "非空叙述照常落库");
118
+ const row = service.all().find((m) => m.type === "summary");
119
+ assert.ok(row, "总览行必须存在");
120
+ assert.equal(row.source, "dream");
121
+ assert.equal(row.importance, 5, "总览行的 importance 固定 5(最高注入档)");
122
+
123
+ const empty = await dream.runDream(mockCtx({ summaryText: "" }), service, {});
124
+ assert.equal(empty.summary, false, "空叙述不得落库");
125
+ const rows = service.all().filter((m) => m.type === "summary");
126
+ assert.equal(rows.length, 1, "旧总览存活,没有被空叙述覆盖");
127
+ store.close();
128
+ });
@@ -16,12 +16,19 @@ import { dirname, join, resolve } from "node:path";
16
16
  import { fileURLToPath } from "node:url";
17
17
  import {
18
18
  DISTILL_RECORDS_DISABLED,
19
+ PROVENANCE_DEFAULT_WINDOWS,
20
+ PROVENANCE_MAX_WINDOWS,
19
21
  PROVENANCE_UNAVAILABLE,
20
22
  isDistillRecordsEnabled,
21
- parseProvenanceQuery,
23
+ parseProvenanceLimit,
24
+ parseProvenanceSelector,
22
25
  provenanceReply
23
26
  } from "../src/provenance-http.js";
24
27
 
28
+ // 方向 1 的选择器:绝大多数用例只关心「读出去的那一侧」,方向本身用这个常量固定。
29
+ const MEM = { mode: "memory", memoryId: "m1", limit: PROVENANCE_DEFAULT_WINDOWS };
30
+ const SES = { mode: "session", sessionId: "s1", limit: PROVENANCE_DEFAULT_WINDOWS };
31
+
25
32
  const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
26
33
 
27
34
  // 开着开关的 cfg:这个端点返回蒸馏窗口的逐字原文,所以测试里的「正常路径」必须
@@ -31,19 +38,73 @@ const RECORDS_ON = { distillRecords: { enabled: true } };
31
38
  function fakeService(over = {}) {
32
39
  return {
33
40
  provenanceFor: (id) => over.links ?? [],
41
+ // #524 方向 2 的存储读法:按会话列窗口(store 侧 GROUP BY 成一行一窗口)。
42
+ distillWindowsFor: (sessionId, limit) => over.windows ?? [],
34
43
  distillRecordsFor: (sessionId, from, to) => over.records ?? [],
35
44
  ...over.service
36
45
  };
37
46
  }
38
47
 
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)} 不该通过`);
48
+ // --- 入参:两个互斥主键 + limit(#524)---------------------------------------
49
+ // 方向 2 复用同一条路径,于是「哪个主键说了算」必须是个**显式**判断:都缺是请求
50
+ // 不成立(400 missing-id),都给也要 400——择一意味着调用方发一个自己也不知道会
51
+ // 读哪个主键的请求,而两个方向的结果形状不同(memoryId / sessionId 各有自己的键)。
52
+
53
+ test("parseProvenanceSelector:两个主键都缺 → 400 missing-id", () => {
54
+ for (const params of [
55
+ {}, { memoryId: null, sessionId: null }, { memoryId: "", sessionId: "" },
56
+ { memoryId: " ", sessionId: "\t" }
57
+ ]) {
58
+ const r = parseProvenanceSelector(params);
59
+ assert.equal(r.ok, false, `${JSON.stringify(params)} 不该通过`);
43
60
  assert.equal(r.status, 400);
44
61
  assert.equal(r.error, "missing-id");
45
62
  }
46
- assert.deepEqual(parseProvenanceQuery(" m1 "), { ok: true, id: "m1" }, "id 两端空白要裁掉");
63
+ });
64
+
65
+ test("parseProvenanceSelector:两个主键都给 → 400 ambiguous-id(不静默择一)", () => {
66
+ const r = parseProvenanceSelector({ memoryId: "m1", sessionId: "s1" });
67
+ assert.equal(r.ok, false);
68
+ assert.equal(r.status, 400);
69
+ assert.equal(r.error, "ambiguous-id");
70
+ });
71
+
72
+ test("parseProvenanceSelector:两端空白裁掉,方向按哪个主键非空确定,limit 缺省 20", () => {
73
+ assert.deepEqual(parseProvenanceSelector({ memoryId: " m1 " }), {
74
+ ok: true,
75
+ selector: { mode: "memory", memoryId: "m1", limit: PROVENANCE_DEFAULT_WINDOWS }
76
+ });
77
+ assert.deepEqual(parseProvenanceSelector({ sessionId: " s1 ", memoryId: " " }), {
78
+ ok: true,
79
+ selector: { mode: "session", sessionId: "s1", limit: PROVENANCE_DEFAULT_WINDOWS }
80
+ }, "另一侧是空白串时按缺省处理,不算「都给」");
81
+ });
82
+
83
+ test("parseProvenanceLimit:只收十进制整数字面量,畸形与越界都算 400", () => {
84
+ assert.equal(parseProvenanceLimit(null).limit, PROVENANCE_DEFAULT_WINDOWS, "不带就是缺省");
85
+ assert.equal(parseProvenanceLimit("1").limit, 1);
86
+ assert.equal(parseProvenanceLimit(String(PROVENANCE_MAX_WINDOWS)).limit, PROVENANCE_MAX_WINDOWS);
87
+ // 两端空白是**裁掉**而不是判畸形——与 id 的处理保持一致(" 12 " 就是 12)。
88
+ // 但裁完必须是纯十进制字面量。
89
+ assert.equal(parseProvenanceLimit(" 12 ").limit, 12, "两端空白裁掉,与 id 一致");
90
+ // 下面这些如果走 Number(),"" / " " 会化成 0 个窗口,十六进制与指数写法也会被
91
+ // 静默接受——一个畸形输入拿到最坏解释。所以这里逐个钉住。
92
+ for (const raw of [
93
+ "", " ", "0", "-1", "1.5", "1e3", "0x10", "abc", "+1", "12abc",
94
+ String(PROVENANCE_MAX_WINDOWS + 1), "99999999999999999999"
95
+ ]) {
96
+ const r = parseProvenanceLimit(raw);
97
+ assert.equal(r.ok, false, `${JSON.stringify(raw)} 不该通过`);
98
+ assert.equal(r.status, 400);
99
+ assert.equal(r.error, "invalid-limit");
100
+ }
101
+ });
102
+
103
+ test("parseProvenanceSelector:limit 畸形时连着主键一起 400(入参形状先于功能开关)", () => {
104
+ const r = parseProvenanceSelector({ sessionId: "s1", limit: "abc" });
105
+ assert.equal(r.ok, false);
106
+ assert.equal(r.status, 400);
107
+ assert.equal(r.error, "invalid-limit");
47
108
  });
48
109
 
49
110
  test("provenanceReply:一个链接一个窗口,原文按 seq 升序且不越界", () => {
@@ -55,7 +116,7 @@ test("provenanceReply:一个链接一个窗口,原文按 seq 升序且不越
55
116
  return [{ seq: 12 }, { seq: 10 }, { seq: 11 }];
56
117
  }
57
118
  };
58
- const r = provenanceReply({ service, id: "m1", config: RECORDS_ON });
119
+ const r = provenanceReply({ service, selector: MEM, config: RECORDS_ON });
59
120
  assert.equal(r.status, 200);
60
121
  assert.equal(r.body.memoryId, "m1");
61
122
  assert.equal(r.body.windows.length, 1);
@@ -68,15 +129,57 @@ test("provenanceReply:一个链接一个窗口,原文按 seq 升序且不越
68
129
  assert.deepEqual(seen, [["s1", 10, 12]], "区间原样透传给 store");
69
130
  });
70
131
 
132
+ test("provenanceReply #512:窗口带得动归属的那一轮 run,读不到就整组缺省而不是猜", () => {
133
+ const runs = new Map([["r1", { run_type: "autoSummarize", trigger_source: "autoSummarize", status: "ok" }]]);
134
+ const service = {
135
+ provenanceFor: () => [
136
+ { session_id: "s1", seq_start: 10, seq_end: 12, created_at: "2026-11-02T00:00:00Z", distill_run_id: "r1" },
137
+ // 改造之前写下的链接:distill_run_id 是 NULL。
138
+ { session_id: "s1", seq_start: 20, seq_end: 22, created_at: "2026-09-01T00:00:00Z", distill_run_id: null }
139
+ ],
140
+ distillRecordsFor: () => [],
141
+ getDistillRun: (id) => runs.get(id),
142
+ mintsFor: () => [{ run_id: "d1", run_type: "dream", trigger_source: "autoDream", created_at: "2026-11-01T00:00:00Z" }]
143
+ };
144
+ const r = provenanceReply({ service, selector: MEM, config: RECORDS_ON });
145
+ assert.equal(r.status, 200);
146
+ const [fresh, legacy] = r.body.windows;
147
+ assert.equal(fresh.distill_run_id, "r1");
148
+ assert.equal(fresh.run_type, "autoSummarize");
149
+ assert.equal(fresh.trigger_source, "autoSummarize");
150
+ assert.equal(fresh.run_status, "ok");
151
+ // 历史链接:id 为 null,三个派生字段也必须是 null——不许拿区间或时间戳反推一轮。
152
+ assert.equal(legacy.distill_run_id, null);
153
+ assert.equal(legacy.run_type, null, "没有 run 就不给类型,否则等于替用户编了一次归因");
154
+ assert.equal(legacy.trigger_source, null);
155
+ assert.equal(legacy.run_status, null);
156
+ assert.equal(r.body.mints.length, 1);
157
+ assert.equal(r.body.mints[0].run_type, "dream");
158
+ });
159
+
160
+ test("provenanceReply #512:service 没有 run 读取能力时,窗口字段照旧齐全但归因为空", () => {
161
+ // headless / 旧装配:provenanceFor 与 distillRecordsFor 在,getDistillRun / mintsFor 不在。
162
+ const service = {
163
+ provenanceFor: () => [{ session_id: "s1", seq_start: 1, seq_end: 2, created_at: "2026-11-02T00:00:00Z", distill_run_id: "r9" }],
164
+ distillRecordsFor: () => []
165
+ };
166
+ const r = provenanceReply({ service, selector: MEM, config: RECORDS_ON });
167
+ assert.equal(r.status, 200, "读 run 的能力缺一档不算装配坏——窗口本身仍可读");
168
+ assert.equal(r.body.windows[0].distill_run_id, "r9");
169
+ assert.equal(r.body.windows[0].run_type, null);
170
+ assert.deepEqual(r.body.mints, []);
171
+ });
172
+
71
173
  test("provenanceReply:没有链接是有效答案——空 windows,200 不是 404", () => {
72
- const r = provenanceReply({ service: fakeService(), id: "m1", config: RECORDS_ON });
174
+ const r = provenanceReply({ service: fakeService(), selector: MEM, config: RECORDS_ON });
73
175
  assert.equal(r.status, 200);
74
- assert.deepEqual(r.body, { memoryId: "m1", windows: [] });
176
+ // #524 的 truncated 与 #512 的 mints 都是答复级读数,各自独立缺省。
177
+ assert.deepEqual(r.body, { memoryId: "m1", windows: [], truncated: false, mints: [] });
75
178
  });
76
179
 
77
180
  test("provenanceReply:方法没接线是装配坏了——503,不与「没有来源」混为一个读数", () => {
78
181
  // headless / 旧装配:service 上没有这两个方法。
79
- const r = provenanceReply({ service: {}, id: "m1", config: RECORDS_ON });
182
+ const r = provenanceReply({ service: {}, selector: MEM, config: RECORDS_ON });
80
183
  assert.equal(r.status, 503);
81
184
  assert.equal(r.body.error, PROVENANCE_UNAVAILABLE);
82
185
  });
@@ -87,7 +190,7 @@ test("provenanceReply:读抛错只 warn 并回 500,不把异常漏给调用
87
190
  provenanceFor: () => { throw new Error("db locked"); },
88
191
  distillRecordsFor: () => []
89
192
  };
90
- const r = provenanceReply({ service, id: "m1", config: RECORDS_ON, warn: (m) => warnings.push(m) });
193
+ const r = provenanceReply({ service, selector: MEM, config: RECORDS_ON, warn: (m) => warnings.push(m) });
91
194
  assert.equal(r.status, 500);
92
195
  assert.equal(r.body.error, "internal");
93
196
  assert.equal(warnings.length, 1, "失败要留痕");
@@ -114,7 +217,7 @@ test("isDistillRecordsEnabled:只认严格的 true,其余一律算关(fail
114
217
  test("provenanceReply:开关没开 → 409 distill-records-disabled,一个键都不多带", () => {
115
218
  // config 整个缺失(旧调用方直连、daemon 最小 cfg)也走这一格:默认方向必须是关。
116
219
  for (const cfg of [null, undefined, {}, { distillRecords: { enabled: false } }]) {
117
- const r = provenanceReply({ service: fakeService(), id: "m1", config: cfg });
220
+ const r = provenanceReply({ service: fakeService(), selector: MEM, config: cfg });
118
221
  assert.equal(r.status, 409, `${JSON.stringify(cfg)} → 409`);
119
222
  assert.deepEqual(r.body, { error: DISTILL_RECORDS_DISABLED },
120
223
  "body 只准有 error 一个键——不带 memoryId / windows,调用方不会把「功能关了」读成「这条记忆没有来源」");
@@ -123,7 +226,7 @@ test("provenanceReply:开关没开 → 409 distill-records-disabled,一个
123
226
 
124
227
  test("provenanceReply:409 赢 503——「用户关了」比「装配坏了」更具体", () => {
125
228
  // 关着 + service 上连方法都没有:报 503 会让人去查一个没坏的部署。
126
- const r = provenanceReply({ service: {}, id: "m1", config: { distillRecords: { enabled: false } } });
229
+ const r = provenanceReply({ service: {}, selector: MEM, config: { distillRecords: { enabled: false } } });
127
230
  assert.equal(r.status, 409);
128
231
  assert.equal(r.body.error, DISTILL_RECORDS_DISABLED);
129
232
  });
@@ -135,20 +238,100 @@ test("provenanceReply:开关关着时一次 SQL 都不发(不碰 service)"
135
238
  provenanceFor: () => { touched++; return []; },
136
239
  distillRecordsFor: () => { touched++; return []; }
137
240
  } });
138
- provenanceReply({ service, id: "m1", config: null });
241
+ provenanceReply({ service, selector: MEM, config: null });
139
242
  assert.equal(touched, 0);
140
243
  });
141
244
 
142
245
  test("provenanceReply:开关开着才读得到——200 与 409 是同一把门的两个方向", () => {
143
246
  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 });
247
+ const on = provenanceReply({ service, selector: MEM, config: RECORDS_ON });
145
248
  assert.equal(on.status, 200);
146
249
  assert.equal(on.body.windows.length, 1);
147
- const off = provenanceReply({ service, id: "m1", config: { distillRecords: { enabled: false } } });
250
+ const off = provenanceReply({ service, selector: MEM, config: { distillRecords: { enabled: false } } });
148
251
  assert.equal(off.status, 409);
149
252
  assert.deepEqual(off.body, { error: DISTILL_RECORDS_DISABLED });
150
253
  });
151
254
 
255
+ // --- 方向 2:会话 → 窗口(#524)--------------------------------------------
256
+
257
+ test("provenanceReply(session 方向):一行一窗口,原文按区间取,答复带 sessionId", () => {
258
+ const asked = [];
259
+ const service = {
260
+ distillWindowsFor: (sid, limit) => {
261
+ asked.push([sid, limit]);
262
+ return [
263
+ { session_id: "s1", seq_start: 5, seq_end: 9, created_at: "t2", link_count: 2 },
264
+ { session_id: "s1", seq_start: 1, seq_end: 4, created_at: "t1", link_count: 1 }
265
+ ];
266
+ },
267
+ distillRecordsFor: (sid, from, to) => [{ seq: from }, { seq: to }]
268
+ };
269
+ const r = provenanceReply({ service, selector: SES, config: RECORDS_ON });
270
+ assert.equal(r.status, 200);
271
+ assert.equal(r.body.sessionId, "s1", "session 方向的答复用 sessionId 认领,不冒充 memoryId");
272
+ assert.equal(r.body.truncated, false);
273
+ assert.deepEqual(r.body.windows.map((w) => [w.seq_start, w.seq_end]), [[5, 9], [1, 4]],
274
+ "次序由 store 的 ORDER BY seq_start 保证,答复层不改序");
275
+ assert.deepEqual(r.body.windows[0].records.map((x) => x.seq), [5, 9], "每个窗口装自己区间的原文");
276
+ assert.equal(r.body.windows[0].link_count, 2, "同一窗口派生了 2 条记忆——这个计数是「按窗口看」的第二维");
277
+ // 多取一行判截断:store 侧只照 limit 取,不替调用方判断「还有没有」。
278
+ assert.deepEqual(asked, [["s1", PROVENANCE_DEFAULT_WINDOWS + 1]]);
279
+ });
280
+
281
+ test("provenanceReply:撞上 limit 时 truncated=true,且多出来的那条不许漏出去", () => {
282
+ // 少了这个读数,被截断的答复与「本来就只有这么多」逐字节相同——调用方会把半份
283
+ // 转录当成全部,这正是这张脸最容易骗人的地方。
284
+ const service = {
285
+ distillWindowsFor: () => [1, 2, 3].map((n) => ({
286
+ session_id: "s1", seq_start: n, seq_end: n, created_at: "t", link_count: 1
287
+ })),
288
+ distillRecordsFor: () => []
289
+ };
290
+ const r = provenanceReply({ service, selector: { ...SES, limit: 2 }, config: RECORDS_ON });
291
+ assert.equal(r.status, 200);
292
+ assert.equal(r.body.truncated, true);
293
+ assert.equal(r.body.windows.length, 2, "limit=2 就只许回 2 个窗口,探针那一条不能混进答复");
294
+ });
295
+
296
+ test("provenanceReply:memory 方向也带 truncated(两个方向共一个读数)", () => {
297
+ const service = {
298
+ provenanceFor: () => [1, 2].map((n) => ({
299
+ session_id: "s1", seq_start: n, seq_end: n, created_at: "t"
300
+ })),
301
+ distillRecordsFor: () => []
302
+ };
303
+ const r = provenanceReply({ service, selector: { ...MEM, limit: 1 }, config: RECORDS_ON });
304
+ assert.equal(r.status, 200);
305
+ assert.equal(r.body.truncated, true);
306
+ assert.equal(r.body.windows.length, 1);
307
+ assert.equal(r.body.memoryId, "m1");
308
+ });
309
+
310
+ test("provenanceReply:503 按方向判——只缺 session 那侧的方法时,memory 查询照旧 200", () => {
311
+ // 把一个方向的问题报成整张脸坏了,会让人去查一个没坏的部署。
312
+ const onlyMemory = {
313
+ provenanceFor: () => [],
314
+ distillRecordsFor: () => []
315
+ };
316
+ const ses = provenanceReply({ service: onlyMemory, selector: SES, config: RECORDS_ON });
317
+ assert.equal(ses.status, 503);
318
+ assert.equal(ses.body.error, PROVENANCE_UNAVAILABLE);
319
+ const mem = provenanceReply({ service: onlyMemory, selector: MEM, config: RECORDS_ON });
320
+ assert.equal(mem.status, 200, "缺的只是方向 2 的方法,方向 1 不该跟着坏");
321
+ });
322
+
323
+ test("provenanceReply:开关关着时 session 方向也 409,且一次 SQL 都不发", () => {
324
+ let touched = 0;
325
+ const service = {
326
+ distillWindowsFor: () => { touched++; return []; },
327
+ distillRecordsFor: () => { touched++; return []; }
328
+ };
329
+ const r = provenanceReply({ service, selector: SES, config: { distillRecords: { enabled: false } } });
330
+ assert.equal(r.status, 409);
331
+ assert.deepEqual(r.body, { error: DISTILL_RECORDS_DISABLED }, "方向不同,门是同一把");
332
+ assert.equal(touched, 0);
333
+ });
334
+
152
335
  test("🔴 静态锁:口径字面量与共用答复只许出现在本模块,两个入口都要真的走它", () => {
153
336
  // #509 第一版把整段复制到了两张皮各一份——本仓 #411 之后不收这个形状。
154
337
  for (const name of ["api.js", "api-standalone.js"]) {
@@ -162,7 +345,7 @@ test("🔴 静态锁:口径字面量与共用答复只许出现在本模块,
162
345
  assert.ok(!src.includes("distillRecords?.enabled === true"),
163
346
  `src/${name} 不准自己写开关判据——只许把 config 转交给共用模块`);
164
347
  // 而且要真的转交了:壳里调 provenanceReply 时必须带上 config。
165
- assert.match(src, /provenanceReply\(\{ service, id: parsed\.id, config,/,
348
+ assert.match(src, /provenanceReply\(\{ service, selector: parsed\.selector, config,/,
166
349
  `src/${name} 要把本入口的 config 传给共用模块,否则门永远读不到用户的开关`);
167
350
  }
168
351
  });
@@ -51,7 +51,13 @@ test("根 README 的两段式路线图能插进新 minor 占位行(旧实现
51
51
  test("真实根 README 的中英两张路线图各插一行(同一版本号,不能只插一张)", () => {
52
52
  // 两张表版本行完全一样,旧实现把全文件版本行合成一个数组判断「已存在」,
53
53
  // 于是先插的中文表会让英文表被跳过——英文读者永远等不到那一行。
54
- const { actions } = syncReadmeTables(rootReadme, "0.10.0", "2026-11-01");
54
+ //
55
+ // 目标版本**不能写死**:写死当次要发的版本,这条用例在 release-prep 分支上必红——那一行
56
+ // 恰好已经由发版准备脚本插进去了,「各插一行」于是退化成零插入。取比现存的最高 major 再高一档,
57
+ // 断言就与「这次发的是哪一版」彻底无关,也不会在下一个 minor 上重新长成定时炸弹。
58
+ const maxMajor = Math.max(...scanReleaseRows(rootReadme).two.map((r) => Number(r.ver.split(".")[0])));
59
+ const target = `${maxMajor + 1}.0`;
60
+ const { actions } = syncReadmeTables(rootReadme, target, "2026-11-01");
55
61
  const inserts = actions.filter((a) => a.kind === "insert");
56
62
  assert.equal(inserts.length, 2, "中英路线图各一行,实际:" + JSON.stringify(actions));
57
63
  });
@@ -104,10 +104,22 @@ function routesInBullet(line) {
104
104
  return out;
105
105
  }
106
106
 
107
- function bullet(prefix) {
108
- const line = doc.split(/\r?\n/).find((l) => l.startsWith(prefix));
109
- assert.ok(line, `SECURITY.md 里应有以「${prefix}」开头的清单行`);
110
- return routesInBullet(line);
107
+ // 清单行必须**恰好一条**。原先用 .find() 只读第一条命中,于是「同一条目出现两份」
108
+ // 这件事守卫完全无感:#509 的 merge 把两份 Open reads / Gated routes 一起解进来,
109
+ // 其中一份 stale(少 /memory/provenance),CI 却全绿——因为 .find() 读的是第一份。
110
+ // 「文档与代码平价」的全部信心都来自这个文件,一旦它对重复条目零感知,第二份就是没有守卫的谎话。
111
+ // 所以这里先收全部命中,再判唯一;多于一条时把行号报出来,避免变成「哪份算数」的新谜题。
112
+ // source 参数只为可测:守卫本身是「唯一性」,喂一份合成的重复文档就能验,不必真去改 SECURITY.md。
113
+ function bullet(prefix, source = doc) {
114
+ const hits = [];
115
+ source.split(/\r?\n/).forEach((line, i) => {
116
+ if (line.startsWith(prefix)) hits.push({ line, no: i + 1 });
117
+ });
118
+ assert.ok(hits.length > 0, `SECURITY.md 里应有以「${prefix}」开头的清单行`);
119
+ assert.equal(hits.length, 1,
120
+ `SECURITY.md 里以「${prefix}」开头的清单行必须恰好一条,实际 ${hits.length} 条(行号 ${hits.map((h) => h.no).join(", ")})。` +
121
+ "清单只许有一份:多出来的那份不会被本文件校验,改它不会红,只能靠删掉来保持平价。");
122
+ return routesInBullet(hits[0].line);
111
123
  }
112
124
 
113
125
  // 文档里的 /semantic/graph/* 覆盖代码里的 /semantic/graph/ego 与 /semantic/graph/entity-attrs。
@@ -137,3 +149,30 @@ test("SECURITY.md 的受门控清单与代码里的 policy 声明一致", () =>
137
149
  assert.deepEqual(stale, [], `${prefix} 写了代码里并不受门控的路由:${stale.join(", ")}`);
138
150
  }
139
151
  });
152
+
153
+ // 上面那条唯一性断言本身要有回归:把 SECURITY.md 的清单行复制成两份必须红。
154
+ // 这条用例喂合成文档,不碰真文件——真文件的平价由上面两条双向比对负责。
155
+ // 变异自证(人工做过一次,记录在 PR 里):把 Gated routes 那一行原样复制一份 → 红;
156
+ // 复制成两份且第二份把 /memory/provenance 换成别的 → 也红。两种都只在「恰好一条」上红。
157
+ test("重复的清单行会被守卫抓住(#523)", () => {
158
+ const gated = doc.split(/\r?\n/).find((l) => l.startsWith("- **Gated routes"));
159
+ assert.ok(gated, "SECURITY.md 里应有 Gated routes 清单行");
160
+
161
+ const withDup = ["# synthetic", gated, gated].join("\n");
162
+ assert.throws(
163
+ () => bullet("- **Gated routes", withDup),
164
+ /必须恰好一条,实际 2 条(行号 2, 3)/,
165
+ "同一清单行出现两份时必须报出重复的行号——这正是 #509 merge 时守卫看不见的形状"
166
+ );
167
+
168
+ // 内容不同的两份同样要红:危险的不是写错了什么,而是那份副本对守卫隐形。
169
+ const staleDup = ["# synthetic", gated, gated.replace("/memory/provenance", "/memory/provenance-OLD")].join("\n");
170
+ assert.throws(
171
+ () => bullet("- **Gated routes", staleDup),
172
+ /必须恰好一条,实际 2 条/,
173
+ "两份内容不同的清单同样必须红,否则陈旧副本可以一直过期"
174
+ );
175
+
176
+ // 只有一份时不能误伤。
177
+ assert.ok(bullet("- **Gated routes", gated).length > 0);
178
+ });