@modusensus/dsh-mneme 0.8.6 → 0.8.7

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 (53) hide show
  1. package/README.md +18 -148
  2. package/bin/cli.mjs +689 -603
  3. package/lib/api-standalone.js +57 -1
  4. package/lib/api.js +34 -11
  5. package/lib/client.js +289 -59
  6. package/lib/config.js +54 -6
  7. package/lib/content-hash.js +54 -0
  8. package/lib/document-index.js +94 -0
  9. package/lib/document.js +93 -11
  10. package/lib/index.js +50 -11
  11. package/lib/inject.js +6 -2
  12. package/lib/lang.js +15 -4
  13. package/lib/maintenance.js +142 -0
  14. package/lib/mirror.js +147 -15
  15. package/lib/recall-stats.js +52 -2
  16. package/lib/scope.js +34 -6
  17. package/lib/service.js +86 -7
  18. package/lib/settings.js +2 -1
  19. package/lib/store.js +231 -21
  20. package/lib/tools.js +27 -9
  21. package/lib/write-admission.js +259 -0
  22. package/package.json +4 -4
  23. package/src/api-standalone.js +57 -1
  24. package/src/api.js +34 -11
  25. package/src/config.js +54 -6
  26. package/src/content-hash.js +54 -0
  27. package/src/document-index.js +94 -0
  28. package/src/document.js +93 -11
  29. package/src/index.js +50 -11
  30. package/src/inject.js +6 -2
  31. package/src/lang.js +15 -4
  32. package/src/maintenance.js +142 -0
  33. package/src/mirror.js +147 -15
  34. package/src/recall-stats.js +52 -2
  35. package/src/scope.js +34 -6
  36. package/src/service.js +86 -7
  37. package/src/settings.js +2 -1
  38. package/src/store.js +231 -21
  39. package/src/tools.js +27 -9
  40. package/src/write-admission.js +259 -0
  41. package/test/api.test.js +12 -0
  42. package/test/client.test.js +90 -5
  43. package/test/content-hash.test.js +204 -0
  44. package/test/document-dir.test.js +233 -0
  45. package/test/document.test.js +151 -3
  46. package/test/inject-parent-gate.test.js +106 -0
  47. package/test/inject-pools.test.js +8 -3
  48. package/test/llm-audit.test.js +42 -0
  49. package/test/maintenance.test.js +391 -0
  50. package/test/mirror-types.test.js +208 -0
  51. package/test/recall-evals.test.js +18 -0
  52. package/test/recall-stats.test.js +66 -0
  53. package/test/write-admission.test.js +345 -0
@@ -121,3 +121,69 @@ test("windowDays/exemptDays 越界回默认;扫描超上限标 truncated", ()
121
121
  assert.equal(total, 2, "窗口总数不截断 → truncated = total > rows.length");
122
122
  store.close();
123
123
  });
124
+
125
+ // ============================ 第五指标(#275):归档净增速率 + 可压掉行数
126
+
127
+ test("归档净增速率:按 updated_at 取归档时刻,窗外归档只进总数不进净增", () => {
128
+ const { store, service } = setup();
129
+ const fresh = service.saveWithDedupe({ type: "history", title: "刚归档", content: "x", importance: 3 }).memory;
130
+ const old = service.saveWithDedupe({ type: "history", title: "早归档", content: "y", importance: 3 }).memory;
131
+ service.saveWithDedupe({ type: "history", title: "活跃件", content: "z", importance: 3 });
132
+ store.setArchived(fresh.id, true);
133
+ store.setArchived(old.id, true);
134
+ // 归档时刻 = 最后一次写(setArchived 刷 updated_at):把这行推到窗口外
135
+ store.db.prepare("UPDATE memories SET updated_at = ? WHERE id = ?")
136
+ .run(new Date(Date.now() - 100 * 86400000).toISOString(), old.id);
137
+
138
+ const stats = service.recallStats({ windowDays: 30, exemptDays: 0 });
139
+ assert.equal(stats.archive.total, 2, "归档区现有行数(活跃件不算)");
140
+ assert.equal(stats.archive.addedInWindow, 1, "窗外那次归档不算本窗口净增");
141
+ assert.equal(stats.archive.perDay, Math.round((1 / 30) * 100) / 100);
142
+ store.close();
143
+ });
144
+
145
+ test("可压掉行数:同 type 同 scope 的内容哈希精确重复(跨 scope / 活跃行都不算)", () => {
146
+ const { store, service } = setup();
147
+ // 标题只差大小写、正文只差标点与空白 → 归一化后同哈希(#254 的口径)
148
+ const a = store.save({ type: "history", title: "Task A", content: "line1.\n\nline2" });
149
+ const b = store.save({ type: "history", title: "task a", content: "line1 line2" });
150
+ const otherScope = store.save({ type: "history", title: "Task A", content: "line1 line2", agent_scope: "other" });
151
+ store.save({ type: "history", title: "Task A", content: "line1 line2" });
152
+ for (const id of [a.id, b.id, otherScope.id]) store.setArchived(id, true);
153
+
154
+ const stats = service.recallStats({ windowDays: 30, exemptDays: 0 });
155
+ assert.equal(stats.archive.compressible.groups, 1, "跨 scope 不互判(与去重候选集同一把尺)");
156
+ assert.equal(stats.archive.compressible.rows, 1, "每组留一行,多出来的才叫可压掉");
157
+ assert.equal(stats.archive.total, 3, "跨 scope 行照进总数,只是不参与可压掉");
158
+ assert.equal(stats.archive.addedInWindow, 3, "三行都是本窗口内归档的");
159
+ store.close();
160
+ });
161
+
162
+ test("可压掉行数:向量口径不可用时的兜底——指标不依赖 embedding", () => {
163
+ // 回归点:回收动作会清掉归档行向量(clearArchivedEmbeddings),若指标建在向量近
164
+ // 重复上,回收一跑它就归零。这里清完向量后指标必须不变。
165
+ const { store, service } = setup();
166
+ const a = store.save({ type: "history", title: "同题", content: "同一件事" });
167
+ const b = store.save({ type: "history", title: "同题", content: "同一件事" });
168
+ store.setArchived(a.id, true);
169
+ store.setArchived(b.id, true);
170
+ assert.equal(service.recallStats({ windowDays: 30, exemptDays: 0 }).archive.compressible.rows, 1);
171
+
172
+ store.clearArchivedEmbeddings();
173
+ assert.equal(
174
+ service.recallStats({ windowDays: 30, exemptDays: 0 }).archive.compressible.rows,
175
+ 1,
176
+ "清向量不影响可压掉行数"
177
+ );
178
+ store.close();
179
+ });
180
+
181
+ test("没有归档行时给 0,不返回 null / NaN", () => {
182
+ const { store, service } = setup();
183
+ service.saveWithDedupe({ type: "project", title: "只有活跃", content: "x", importance: 3 });
184
+ const stats = service.recallStats({ windowDays: 30, exemptDays: 0 });
185
+ assert.deepEqual(stats.archive, {
186
+ total: 0, addedInWindow: 0, perDay: 0, compressible: { rows: 0, groups: 0 }
187
+ });
188
+ store.close();
189
+ });
@@ -0,0 +1,345 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { DatabaseSync } from "node:sqlite";
4
+ import { mkdtempSync, rmSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import { createStore } from "../src/store.js";
8
+ import { createService } from "../src/service.js";
9
+ import { createTools } from "../src/tools.js";
10
+ import {
11
+ createWriteAdmission,
12
+ extractTopicKeys,
13
+ ADMISSION_OPERATION_TYPE,
14
+ ADMISSION_TRIGGER_SOURCE
15
+ } from "../src/write-admission.js";
16
+
17
+ // #254 写入准入(第一阶段:只计量,不拦截)。
18
+ // 本批次没有阈值、没有拦截分支,验收看两件事:默认路径零行为变化(无会话身份的
19
+ // 写入与未接线的服务都不记行),以及两个闸门的测量点可查(g1 会话写入预算按行聚
20
+ // 合、g2 同话题重复带间隔)。第二阶段的拦截(memory_save({confirm:true}))不在这
21
+ // 里锁——现在还没有那个分支。
22
+
23
+ function setup({ wired = true, now, admissionConfig } = {}) {
24
+ const store = createStore(":memory:");
25
+ const writeAdmission = wired ? createWriteAdmission({ store, now, config: admissionConfig }) : null;
26
+ const service = createService({ store, mirror: null, config: {}, writeAdmission });
27
+ return { store, service, writeAdmission };
28
+ }
29
+
30
+ function admissionRows(store, sessionKey) {
31
+ return store.listLlmAudits({ sessionKey }).filter((r) => r.operation_type === ADMISSION_OPERATION_TYPE);
32
+ }
33
+
34
+ test("无会话身份的写入不进预算,也不写准入行", () => {
35
+ const { store, service } = setup();
36
+ const created = service.saveWithDedupe({ type: "project", title: "T", content: "正文内容一段" });
37
+ assert.equal(created.action, "created");
38
+ assert.equal(store.listLlmAudits().length, 0, "系统写入(dream / summarize / import / organize)不记准入行");
39
+
40
+ // 未接线写入准入的服务(老调用方 / 单测)同样零变化
41
+ const bare = createService({ store, mirror: null, config: {} });
42
+ assert.equal(bare.saveWithDedupe({ type: "project", title: "T2", content: "另一段内容" }).action, "created");
43
+ assert.equal(store.listLlmAudits().length, 0, "未接线时不产生任何审计行");
44
+ });
45
+
46
+ test("g1:会话内每个新建行记一行,合并行不记", () => {
47
+ const { store, service } = setup();
48
+ const s = { _sessionKey: "sess-1" };
49
+ service.saveWithDedupe({ ...s, type: "project", title: "A", content: "第一段内容" });
50
+ service.saveWithDedupe({ ...s, type: "project", title: "A", content: "追加的第二段" });
51
+ service.saveWithDedupe({ ...s, type: "project", title: "B", content: "另一件事" });
52
+
53
+ const rows = admissionRows(store, "sess-1");
54
+ assert.equal(rows.length, 2, "两个新建行 = 两行;并入已有行不算新增(维护者拍板第 2 项)");
55
+ for (const row of rows) {
56
+ assert.equal(row.trigger_source, ADMISSION_TRIGGER_SOURCE);
57
+ assert.equal(row.status, "skipped", "阶段一不拦截,沿用既有 skip 口径");
58
+ assert.equal(row.metadata.gate, "g1");
59
+ assert.equal(row.session_key, "sess-1");
60
+ assert.equal(row.model_id, "-", "不是模型调用,占位串避免混进路由花费统计");
61
+ }
62
+ // 行上的会话键可等值查询:按会话聚合即得「会话内新建条数」分布
63
+ assert.equal(store.countLlmAudits({ sessionKey: "sess-1" }), 2);
64
+ assert.equal(store.countLlmAudits({ sessionKey: "sess-2" }), 0);
65
+ });
66
+
67
+ test("pinned 类型(constraint / preference)不进预算,穿透频率仍可观测", () => {
68
+ const { store, service } = setup();
69
+ service.saveWithDedupe({ _sessionKey: "s", type: "constraint", title: "C", content: "边界条件" });
70
+ service.saveWithDedupe({ _sessionKey: "s", type: "preference", title: "P", content: "用户偏好" });
71
+ service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "T", content: "普通记录" });
72
+
73
+ const rows = admissionRows(store, "s");
74
+ assert.equal(rows.length, 3, "穿透口也记行——否则「预算为什么没算它」不可观测");
75
+ assert.deepEqual(
76
+ rows.map((r) => r.metadata.exempt ?? null).sort(),
77
+ [null, "pinned", "pinned"]
78
+ );
79
+ });
80
+
81
+ test("g2:同会话内同话题锚重复新建时记一行,带间隔", () => {
82
+ let clock = 1_000_000;
83
+ const { store, service } = setup({ now: () => clock });
84
+ service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "开工", content: "先把 #254 的准入做完" });
85
+ assert.equal(admissionRows(store, "s").length, 1, "话题首次出现不算重复");
86
+
87
+ clock += 90_000;
88
+ service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "换了个标题", content: "接着做 #254,另起一段" });
89
+ const rows = admissionRows(store, "s");
90
+ assert.equal(rows.length, 2, "一行一个新建行,g2 是这一行上的附加信号,不另起一行");
91
+ assert.equal(rows[0].metadata.gate, "g1");
92
+ assert.equal(rows[0].metadata.g2.topic, "#254");
93
+ assert.equal(rows[0].metadata.g2.gap_ms, 90_000, "间隔按会话内最近一次同话题新建行算");
94
+ assert.equal(rows[1].metadata.g2, undefined, "首次出现没有 g2");
95
+ assert.equal(rows[0].status, "skipped");
96
+ });
97
+
98
+ test("g2:并入已有行不算同话题重写", () => {
99
+ const { store, service } = setup();
100
+ service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "同一标题", content: "看 #254" });
101
+ service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "同一标题", content: "再看 #254" });
102
+ const rows = admissionRows(store, "s");
103
+ assert.equal(rows.length, 1, "合并是去重机制在正常工作,既不进预算也不产生 g2");
104
+ });
105
+
106
+ // #254 内容哈希(exact duplicate):判据是归一化后逐字节相同,不是相似度;候选集把
107
+ // 归档行一起纳入——归档行不在 saveWithDedupe 的候选集里(store.list 默认排除归档),
108
+ // 同一个事实再写一次就是新行,这正是这条信号要量的穿透。
109
+ test("dup:命中归档行的重复写入被记下来(归档行在候选集内)", () => {
110
+ const { store, service } = setup();
111
+ const first = service.saveWithDedupe({ type: "project", title: "归档事实", content: "同一件事的正文" });
112
+ store.setArchived(first.memory.id, true);
113
+ const second = service.saveWithDedupe({
114
+ _sessionKey: "sess-dup",
115
+ type: "project",
116
+ title: "归档事实",
117
+ content: "同一件事的正文"
118
+ });
119
+ assert.equal(second.action, "created", "归档行不在去重候选集里,所以确实新建了一行");
120
+
121
+ const rows = admissionRows(store, "sess-dup");
122
+ assert.equal(rows.length, 1);
123
+ assert.deepEqual(rows[0].metadata.dup, { memory_id: first.memory.id, archived: true, forgotten: false });
124
+ });
125
+
126
+ test("dup:只差格式(大小写/空白/标点)的重复也算命中", () => {
127
+ const { store, service } = setup();
128
+ const first = service.saveWithDedupe({ type: "project", title: "Task A", content: "Line1.\n\nLine2" });
129
+ // 标题只差大小写 → saveWithDedupe 的精确标题匹配落空 → 走新建路径
130
+ const second = service.saveWithDedupe({
131
+ _sessionKey: "sess-fmt",
132
+ type: "project",
133
+ title: "task a",
134
+ content: "line1 line2"
135
+ });
136
+ assert.equal(second.action, "created");
137
+ const rows = admissionRows(store, "sess-fmt");
138
+ assert.deepEqual(rows[0].metadata.dup, { memory_id: first.memory.id, archived: false, forgotten: false });
139
+ });
140
+
141
+ test("dup:同内容既有活跃行又有归档行时报活跃那条(活区优先,而不是谁最近被改过)", () => {
142
+ // 回归点(单盲审查 L2):候选集按 updated_at 倒序返回,直接取第一条会把「命中归档
143
+ // 区」这个信号盖掉(归档动作刚刷过 updated_at)。口径:有活跃命中就报它。
144
+ const { store, service } = setup();
145
+ const active = store.save({ type: "project", title: "Task A", content: "同一件事" });
146
+ const archived = store.save({ type: "project", title: "task a", content: "同一件事" });
147
+ store.setArchived(archived.id, true);
148
+ const created = service.saveWithDedupe({
149
+ _sessionKey: "sess-both", type: "project", title: "TASK a", content: "同一件事"
150
+ });
151
+ assert.equal(created.action, "created", "标题只差大小写 → 精确标题匹配落空,走新建路径");
152
+ assert.deepEqual(admissionRows(store, "sess-both")[0].metadata.dup, { memory_id: active.id, archived: false, forgotten: false });
153
+ });
154
+
155
+ test("dup:只剩已遗忘未归档的命中时,不把它记成活区命中", () => {
156
+ // 回归(自动评审 #311 write-admission.js:158):只回 archived 会把遗忘区命中写成
157
+ // archived:false,读审计的人会以为存在活跃重复;而 saveWithDedupe 的候选集本来就排除
158
+ // 遗忘行(store.list 默认 includeForgotten=false),这类命中同样是穿透。两个出口分字段记。
159
+ // 两条行同标题同正文:遗忘之后标题候选集里已经没有它,第二次写入自然走新建路径。
160
+ const { store, service } = setup();
161
+ const first = service.saveWithDedupe({ type: "project", title: "遗忘事实", content: "同一段遗忘正文" });
162
+ store.setForget(first.memory.id, true);
163
+ const second = service.saveWithDedupe({
164
+ _sessionKey: "sess-forgot", type: "project", title: "遗忘事实", content: "同一段遗忘正文"
165
+ });
166
+ assert.equal(second.action, "created", "遗忘行不在去重候选集里,所以确实新建了一行");
167
+ assert.deepEqual(admissionRows(store, "sess-forgot")[0].metadata.dup, {
168
+ memory_id: first.memory.id, archived: false, forgotten: true
169
+ });
170
+ });
171
+
172
+ test("dup:不同内容不记 dup(负样本,免得信号恒真)", () => {
173
+ const { store, service } = setup();
174
+ service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "T1", content: "第一件事的正文" });
175
+ service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "T2", content: "另一件事的正文" });
176
+ const rows = admissionRows(store, "s");
177
+ assert.equal(rows.length, 2);
178
+ for (const row of rows) assert.equal(row.metadata.dup, undefined);
179
+ });
180
+
181
+ test("dup:pinned 行不查候选集(整个不在闸门里,与 g2 的不当基准同口径)", () => {
182
+ const { store, service } = setup();
183
+ service.saveWithDedupe({ _sessionKey: "s", type: "constraint", title: "边界 X", content: "同一段边界" });
184
+ service.saveWithDedupe({ _sessionKey: "s", type: "constraint", title: "边界 x", content: "同一段边界" });
185
+ const rows = admissionRows(store, "s");
186
+ assert.equal(rows.length, 2);
187
+ for (const row of rows) {
188
+ assert.equal(row.metadata.exempt, "pinned");
189
+ assert.equal(row.metadata.dup, undefined, "穿透口不发候选集查询,也不记 dup");
190
+ }
191
+ });
192
+
193
+ test("话题表由审计行重建:新实例(进程重启)仍能认出同话题", () => {
194
+ const { store, service } = setup();
195
+ service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "A", content: "#99 的记录" });
196
+
197
+ // 新实例 = 进程重启:内存视图为空,只能从审计行回填
198
+ const revived = createWriteAdmission({ store });
199
+ const service2 = createService({ store, mirror: null, config: {}, writeAdmission: revived });
200
+ service2.saveWithDedupe({ _sessionKey: "s", type: "project", title: "B", content: "#99 又记了一笔" });
201
+
202
+ const g2 = admissionRows(store, "s").filter((r) => r.metadata.g2);
203
+ assert.equal(g2.length, 1, "审计行是唯一真相源,重启不回退");
204
+ assert.equal(g2[0].metadata.g2.topic, "#99");
205
+ });
206
+
207
+ test("pinned 行不推进同话题基准(它整个不在闸门里)", () => {
208
+ const { store, service } = setup();
209
+ service.saveWithDedupe({ _sessionKey: "s", type: "constraint", title: "边界", content: "见 #254" });
210
+ service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "记录", content: "见 #254 的另一段" });
211
+ assert.equal(
212
+ admissionRows(store, "s").filter((r) => r.metadata.g2).length,
213
+ 0,
214
+ "穿透行不当基准,g2 样本不掺穿透流量"
215
+ );
216
+ });
217
+
218
+ test("llmAudit 关掉时一行都不写(那个开关同时关掉了审计行的保留期清理)", () => {
219
+ const { store, service } = setup({ admissionConfig: { llmAudit: { enabled: false } } });
220
+ service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "T", content: "内容 #254" });
221
+ assert.equal(store.listLlmAudits().length, 0, "审计关掉时不得在无保留期的表里按写入频次增长");
222
+ });
223
+
224
+ test("话题锚只取机械可判的两类:issue 引用与文件路径", () => {
225
+ assert.deepEqual(
226
+ extractTopicKeys({ title: "修 #254,#254 已经报过", content: "见 src/service.js 与 docs/handbook/02-write-path.md" }),
227
+ ["#254", "docs/handbook/02-write-path.md", "src/service.js"],
228
+ "去重 + 排序 + 小写归一(同话题必须逐字节相等才能等值比较)"
229
+ );
230
+ assert.deepEqual(extractTopicKeys({ title: "SRC/Service.JS", content: "大小写不该造出两个话题" }), ["src/service.js"]);
231
+ assert.deepEqual(
232
+ extractTopicKeys({ title: "路径", content: "src\\service.js 与 src/service.js 是同一处" }),
233
+ ["src/service.js"],
234
+ "分隔符归一:同一个文件不能因为写法不同变成两个话题"
235
+ );
236
+ // 误报样本:Markdown 标题(###1)、比值(3.5/2.0)、版本号(v1.2.3)都不该成为话题锚
237
+ assert.deepEqual(extractTopicKeys({ title: "###1 小节", content: "比例约 3.5/2.0,版本 v1.2.3" }), []);
238
+ assert.deepEqual(extractTopicKeys({ title: "普通记录", content: "没有任何锚点的一段话" }), []);
239
+ assert.deepEqual(extractTopicKeys({}), []);
240
+ });
241
+
242
+ test("计量失败不反噬写入(旁路,只 warn)", () => {
243
+ const store = createStore(":memory:");
244
+ const throwing = createService({
245
+ store,
246
+ mirror: null,
247
+ config: {},
248
+ writeAdmission: {
249
+ evaluate() { throw new Error("evaluate boom"); },
250
+ record() { throw new Error("record boom"); }
251
+ }
252
+ });
253
+ assert.equal(
254
+ throwing.saveWithDedupe({ _sessionKey: "s", type: "project", title: "T", content: "内容" }).action,
255
+ "created",
256
+ "evaluate 抛错不能挡下写入"
257
+ );
258
+
259
+ // evaluate 成功、record 抛错:写入照常返回
260
+ const real = createWriteAdmission({ store });
261
+ const halfBroken = createService({
262
+ store,
263
+ mirror: null,
264
+ config: {},
265
+ writeAdmission: { evaluate: real.evaluate, record() { throw new Error("record boom"); } }
266
+ });
267
+ assert.equal(
268
+ halfBroken.saveWithDedupe({ _sessionKey: "s", type: "project", title: "T2", content: "内容二" }).action,
269
+ "created",
270
+ "record 抛错不能反噬写入"
271
+ );
272
+ });
273
+
274
+ test("接线:memory_save 把会话键交给写入准入,缺会话身份的宿主落 null", async () => {
275
+ const store = createStore(":memory:");
276
+ const writeAdmission = createWriteAdmission({ store });
277
+ const service = createService({ store, mirror: null, config: {}, writeAdmission });
278
+ const registered = [];
279
+ createTools({ tools: { register(def) { registered.push(def); return () => {}; } } }, service, {}, null);
280
+ const save = registered.find((t) => t.name === "memory_save");
281
+ assert.ok(save, "memory_save 应已注册");
282
+
283
+ await save.execute(
284
+ { type: "project", title: "T", content: "记录 #254" },
285
+ { agent: { session: { id: "sess-9", header: { agentPreset: "coder", cwd: "D:\\proj" } } } }
286
+ );
287
+ assert.equal(store.countLlmAudits({ sessionKey: "sess-9" }), 1, "工具层要真的把会话键传下去");
288
+
289
+ await save.execute({ type: "project", title: "T2", content: "无会话身份" }, {});
290
+ assert.equal(store.countLlmAudits(), 1, "缺会话身份时既不记行也不报错");
291
+ });
292
+
293
+ test("旧库(llm_audit_logs 无 session_key)打开自动补列,准入行照常落", () => {
294
+ const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-admission-"));
295
+ const dbPath = join(dir, "memory.db");
296
+ try {
297
+ // 用裸 DatabaseSync 构造带 #254 之前表结构的旧库
298
+ const old = new DatabaseSync(dbPath);
299
+ old.exec(`
300
+ CREATE TABLE llm_audit_logs (
301
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
302
+ timestamp TEXT NOT NULL,
303
+ trigger_source TEXT NOT NULL,
304
+ operation_type TEXT NOT NULL,
305
+ model_id TEXT NOT NULL,
306
+ input_tokens INTEGER NOT NULL DEFAULT 0,
307
+ output_tokens INTEGER NOT NULL DEFAULT 0,
308
+ total_tokens INTEGER NOT NULL DEFAULT 0,
309
+ cost_usd REAL NOT NULL DEFAULT 0,
310
+ duration_ms INTEGER NOT NULL DEFAULT 0,
311
+ status TEXT NOT NULL,
312
+ error_message TEXT,
313
+ related_memory_ids TEXT,
314
+ metadata TEXT
315
+ );
316
+ INSERT INTO llm_audit_logs (timestamp, trigger_source, operation_type, model_id, status, metadata)
317
+ VALUES ('2026-01-01T00:00:00.000Z', 'autoDream', 'dream_consolidate', 'm', 'success', '{"old":1}');
318
+ `);
319
+ old.close();
320
+
321
+ let store = createStore(dbPath);
322
+ const cols = store.db.prepare("PRAGMA table_info(llm_audit_logs)").all().map((c) => c.name);
323
+ assert.ok(cols.includes("session_key"), "旧库打开必须自动补 session_key 列");
324
+ assert.equal(store.listLlmAudits().length, 1, "存量审计行保留");
325
+ assert.equal(store.listLlmAudits()[0].session_key, undefined, "存量行该列为空");
326
+
327
+ const admission = createWriteAdmission({ store });
328
+ const memory = { title: "开工", content: "#254 的准入" };
329
+ admission.record({
330
+ sessionKey: "s1",
331
+ verdict: admission.evaluate({ memory, sessionKey: "s1" }),
332
+ memoryId: "m1"
333
+ });
334
+ assert.equal(store.countLlmAudits({ sessionKey: "s1" }), 1, "迁移后的库上准入行可落可查");
335
+
336
+ // 重复打开幂等:列不重复、既有行不丢
337
+ store.close();
338
+ store = createStore(dbPath);
339
+ assert.equal(store.countLlmAudits({ sessionKey: "s1" }), 1, "重复打开不丢数据");
340
+ assert.equal(store.countLlmAudits(), 2, "既有的 LLM 审计行与准入行都在");
341
+ store.close();
342
+ } finally {
343
+ rmSync(dir, { recursive: true, force: true });
344
+ }
345
+ });