@modusensus/dsh-mneme 0.8.13 → 0.8.15

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 (271) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +655 -626
  3. package/assets/icon.svg +3 -3
  4. package/bin/cli.mjs +689 -689
  5. package/bin/dsh-mneme-mcp.mjs +510 -510
  6. package/bin/dsh-mneme-serve.mjs +151 -0
  7. package/cordis.patch.yml +21 -21
  8. package/docs/CONFIGURATION.md +263 -0
  9. package/docs/CONTINUITY.md +124 -0
  10. package/docs/DAEMON.md +46 -0
  11. package/docs/ENTITIES.md +245 -0
  12. package/docs/LOCAL_MODEL.md +222 -0
  13. package/docs/SEMANTIC.md +256 -0
  14. package/docs/SLEEP.md +164 -0
  15. package/docs/STORAGE.md +89 -0
  16. package/lib/api-standalone.js +673 -572
  17. package/lib/api.js +1437 -1433
  18. package/lib/bootstrap.js +211 -211
  19. package/lib/client.js +5444 -5295
  20. package/lib/commands.js +95 -95
  21. package/lib/config.js +880 -836
  22. package/lib/content-hash.js +54 -54
  23. package/lib/continuity.js +220 -220
  24. package/lib/document-index.js +94 -94
  25. package/lib/document.js +303 -303
  26. package/lib/dream/clustering.js +118 -118
  27. package/lib/dream/decisions.js +674 -674
  28. package/lib/dream/narratives.js +44 -44
  29. package/lib/dream/sleep.js +819 -819
  30. package/lib/dream.js +1501 -1501
  31. package/lib/embedding.js +177 -177
  32. package/lib/entities/extractor.js +252 -252
  33. package/lib/graph/anchoring.js +179 -179
  34. package/lib/guide.js +74 -74
  35. package/lib/heat.js +144 -144
  36. package/lib/hot-memory.js +46 -46
  37. package/lib/index.js +535 -671
  38. package/lib/inject.js +464 -450
  39. package/lib/lang.js +248 -229
  40. package/lib/local-embedder.js +358 -358
  41. package/lib/maintenance.js +142 -142
  42. package/lib/mirror.js +330 -330
  43. package/lib/organize.js +450 -450
  44. package/lib/peak-hours.js +117 -117
  45. package/lib/placement.js +13 -0
  46. package/lib/pre-inject-gate.js +340 -0
  47. package/lib/quality-filter.js +126 -126
  48. package/lib/recall-stats.js +175 -175
  49. package/lib/reranker.js +269 -269
  50. package/lib/runtime/adopt.js +176 -176
  51. package/lib/runtime/closure.js +172 -172
  52. package/lib/runtime/download.js +247 -247
  53. package/lib/runtime/layout.js +265 -265
  54. package/lib/runtime/loader.js +252 -252
  55. package/lib/runtime/model-fetch.js +361 -361
  56. package/lib/runtime/provision.js +237 -237
  57. package/lib/runtime/tarball.js +130 -130
  58. package/lib/runtime/verify.js +223 -204
  59. package/lib/scope.js +146 -146
  60. package/lib/search/adaptive.js +60 -60
  61. package/lib/search/bm25.js +96 -96
  62. package/lib/semantic.js +229 -0
  63. package/lib/sensitive-scan.js +209 -209
  64. package/lib/serve.js +215 -0
  65. package/lib/service.js +2663 -2596
  66. package/lib/settings.js +517 -509
  67. package/lib/store.js +3007 -3007
  68. package/lib/summarize.js +891 -856
  69. package/lib/tools.js +743 -737
  70. package/lib/vector-index.js +110 -110
  71. package/lib/version-check.js +122 -122
  72. package/lib/write-admission.js +402 -402
  73. package/locale/en.json +6 -6
  74. package/locale/zh.json +6 -6
  75. package/package.json +8 -4
  76. package/runtime-manifest.json +526 -526
  77. package/scripts/benchmark-embed.js +201 -201
  78. package/scripts/benchmark-recall.js +249 -249
  79. package/scripts/benchmark-rerank.js +166 -166
  80. package/scripts/build-runtime-manifest.mjs +224 -224
  81. package/scripts/changelog-prep.mjs +36 -0
  82. package/scripts/check-sync.js +42 -42
  83. package/scripts/e2e-dsh.js +220 -220
  84. package/scripts/mneme-runtime.mjs +277 -277
  85. package/scripts/stress-dsh.js +255 -255
  86. package/scripts/sync-lib.js +47 -47
  87. package/scripts/sync-test-badge.mjs +69 -69
  88. package/scripts/test-count-sync.mjs +44 -44
  89. package/src/api-standalone.js +673 -572
  90. package/src/api.js +1437 -1433
  91. package/src/bootstrap.js +211 -211
  92. package/src/commands.js +95 -95
  93. package/src/config.js +880 -836
  94. package/src/content-hash.js +54 -54
  95. package/src/continuity.js +220 -220
  96. package/src/document-index.js +94 -94
  97. package/src/document.js +303 -303
  98. package/src/dream/clustering.js +118 -118
  99. package/src/dream/decisions.js +674 -674
  100. package/src/dream/narratives.js +44 -44
  101. package/src/dream/sleep.js +819 -819
  102. package/src/dream.js +1501 -1501
  103. package/src/embedding.js +177 -177
  104. package/src/entities/extractor.js +252 -252
  105. package/src/graph/anchoring.js +179 -179
  106. package/src/guide.js +74 -74
  107. package/src/heat.js +144 -144
  108. package/src/hot-memory.js +46 -46
  109. package/src/index.js +535 -671
  110. package/src/inject.js +464 -450
  111. package/src/lang.js +248 -229
  112. package/src/local-embedder.js +358 -358
  113. package/src/maintenance.js +142 -142
  114. package/src/mirror.js +330 -330
  115. package/src/organize.js +450 -450
  116. package/src/peak-hours.js +117 -117
  117. package/src/placement.js +13 -0
  118. package/src/pre-inject-gate.js +340 -0
  119. package/src/quality-filter.js +126 -126
  120. package/src/recall-stats.js +175 -175
  121. package/src/reranker.js +269 -269
  122. package/src/runtime/adopt.js +176 -176
  123. package/src/runtime/closure.js +172 -172
  124. package/src/runtime/download.js +247 -247
  125. package/src/runtime/layout.js +265 -265
  126. package/src/runtime/loader.js +252 -252
  127. package/src/runtime/model-fetch.js +361 -361
  128. package/src/runtime/provision.js +237 -237
  129. package/src/runtime/tarball.js +130 -130
  130. package/src/runtime/verify.js +223 -204
  131. package/src/scope.js +146 -146
  132. package/src/search/adaptive.js +60 -60
  133. package/src/search/bm25.js +96 -96
  134. package/src/semantic.js +229 -0
  135. package/src/sensitive-scan.js +209 -209
  136. package/src/serve.js +215 -0
  137. package/src/service.js +2663 -2596
  138. package/src/settings.js +517 -509
  139. package/src/store.js +3007 -3007
  140. package/src/summarize.js +891 -856
  141. package/src/tools.js +743 -737
  142. package/src/vector-index.js +110 -110
  143. package/src/version-check.js +122 -122
  144. package/src/write-admission.js +402 -402
  145. package/test/adaptive-inject-budget.test.js +85 -85
  146. package/test/api-routes-gaps.test.js +218 -218
  147. package/test/api.test.js +1702 -1688
  148. package/test/audit.test.js +448 -448
  149. package/test/baseline-persist.test.js +248 -248
  150. package/test/benchmark.test.js +94 -94
  151. package/test/bootstrap.test.js +220 -220
  152. package/test/changelog-prep.test.js +60 -0
  153. package/test/client.test.js +1230 -1169
  154. package/test/clustering.test.js +100 -100
  155. package/test/commands.test.js +106 -106
  156. package/test/config.test.js +66 -66
  157. package/test/conflict-freeze.test.js +300 -300
  158. package/test/content-hash.test.js +204 -204
  159. package/test/continuity.test.js +333 -333
  160. package/test/document-dir.test.js +233 -233
  161. package/test/document.test.js +671 -671
  162. package/test/dream-archive-guardrail.test.js +116 -116
  163. package/test/dream-candidate-hybrid.test.js +281 -281
  164. package/test/dream-coverage-degrade.test.js +86 -86
  165. package/test/dream-failure-backoff.test.js +173 -173
  166. package/test/dream-peak-hours.test.js +194 -194
  167. package/test/dream.test.js +1385 -1383
  168. package/test/embedding-ready.test.js +196 -196
  169. package/test/entities.test.js +627 -547
  170. package/test/entity-recall.test.js +179 -179
  171. package/test/epistemic.test.js +325 -325
  172. package/test/fnew-0112.test.js +311 -311
  173. package/test/fnew-03.test.js +422 -422
  174. package/test/graph-anchoring.test.js +184 -184
  175. package/test/graph-api.test.js +175 -175
  176. package/test/graph-inject-budget.test.js +103 -103
  177. package/test/graph-passive-confirm.test.js +91 -91
  178. package/test/graph-weight-evolution.test.js +104 -104
  179. package/test/heat.test.js +175 -175
  180. package/test/helpers/dream-mock.js +82 -82
  181. package/test/helpers/peer-worker.mjs +25 -25
  182. package/test/helpers/runtime-source.js +60 -60
  183. package/test/helpers/tar-builder.js +80 -80
  184. package/test/helpers/write-admission-samples.js +308 -308
  185. package/test/hot-memory.test.js +145 -145
  186. package/test/inject-content-cap.test.js +91 -91
  187. package/test/inject-first-round-bm25.test.js +89 -89
  188. package/test/inject-heat.test.js +77 -61
  189. package/test/inject-parent-gate.test.js +106 -106
  190. package/test/inject-pools.test.js +194 -194
  191. package/test/inject-recall.test.js +105 -105
  192. package/test/inject-rotation.test.js +161 -161
  193. package/test/inject.test.js +420 -420
  194. package/test/lang.test.js +87 -87
  195. package/test/lastrunat-persist.test.js +101 -101
  196. package/test/lib-smoke.test.js +96 -96
  197. package/test/llm-audit.test.js +598 -598
  198. package/test/llm-message-source.test.js +207 -207
  199. package/test/local-embedder.test.js +292 -292
  200. package/test/maintenance.test.js +391 -391
  201. package/test/mcp-stdio.test.js +385 -385
  202. package/test/mirror-dirty.test.js +424 -424
  203. package/test/mirror-edit-digest.test.js +187 -187
  204. package/test/mirror-generation.test.js +499 -499
  205. package/test/mirror-types.test.js +208 -208
  206. package/test/mirror.test.js +249 -249
  207. package/test/narrative-bars.test.js +162 -162
  208. package/test/organize.test.js +418 -418
  209. package/test/peer-blockers.test.js +193 -193
  210. package/test/placement.test.js +36 -0
  211. package/test/policy-epoch.test.js +259 -259
  212. package/test/pre-inject-gate.test.js +434 -0
  213. package/test/quality-filter.test.js +186 -186
  214. package/test/reasoning-effort.test.js +752 -752
  215. package/test/recall-anti-update.test.js +82 -82
  216. package/test/recall-evals.test.js +253 -253
  217. package/test/recall-layer.test.js +326 -326
  218. package/test/recall-stats.test.js +189 -189
  219. package/test/receipt-chain.test.js +451 -451
  220. package/test/reflection.test.js +226 -226
  221. package/test/reindex-backfill.test.js +62 -62
  222. package/test/reranker-single-logit.test.js +73 -73
  223. package/test/reranker.test.js +197 -197
  224. package/test/resident-status.test.js +83 -83
  225. package/test/runtime-adopt.test.js +206 -206
  226. package/test/runtime-cli.test.js +89 -89
  227. package/test/runtime-closure.test.js +193 -193
  228. package/test/runtime-download.test.js +302 -302
  229. package/test/runtime-layout.test.js +163 -163
  230. package/test/runtime-loader.test.js +255 -255
  231. package/test/runtime-manifest.test.js +159 -159
  232. package/test/runtime-model-fetch.test.js +348 -348
  233. package/test/runtime-provision.test.js +101 -101
  234. package/test/runtime-tarball.test.js +81 -81
  235. package/test/runtime-verify.test.js +319 -279
  236. package/test/scope-candidates.test.js +239 -239
  237. package/test/scope-explicit.test.js +452 -452
  238. package/test/scope-inject-soft.test.js +106 -106
  239. package/test/scope-retrieval.test.js +197 -197
  240. package/test/scope-storage.test.js +255 -255
  241. package/test/scope-strict.test.js +367 -343
  242. package/test/search-fusion.test.js +90 -90
  243. package/test/semantic.test.js +124 -124
  244. package/test/sensitive-scan.test.js +207 -207
  245. package/test/serve-bin.test.js +121 -0
  246. package/test/serve.test.js +196 -0
  247. package/test/service-search.test.js +174 -174
  248. package/test/service.test.js +405 -405
  249. package/test/settings.test.js +261 -261
  250. package/test/skip-invalid-restore.test.js +262 -262
  251. package/test/sleep-action-set.test.js +422 -422
  252. package/test/sleep-heat.test.js +126 -126
  253. package/test/sleep-scheduler-rearm.test.js +89 -89
  254. package/test/sleep.test.js +527 -527
  255. package/test/standalone-api.test.js +751 -544
  256. package/test/store-vector-cache.test.js +98 -98
  257. package/test/store.test.js +396 -396
  258. package/test/stress.test.js +209 -209
  259. package/test/summarize-occurred-at.test.js +127 -0
  260. package/test/summarize-reasoning-effort.test.js +189 -189
  261. package/test/summarize.test.js +1465 -1465
  262. package/test/supersede-state-tracking.test.js +394 -0
  263. package/test/test-count-sync.test.js +60 -60
  264. package/test/tools-dto-schema-matrix.test.js +325 -325
  265. package/test/tools-memory-get-schema.test.js +65 -65
  266. package/test/tools.test.js +428 -374
  267. package/test/updated-at-semantics.test.js +113 -113
  268. package/test/vector-index.test.js +221 -221
  269. package/test/version-check.test.js +152 -152
  270. package/test/webServer-optional.test.js +82 -82
  271. package/test/write-admission.test.js +668 -668
@@ -1,668 +1,668 @@
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
- informationlessReason,
14
- ADMISSION_DENY_BLANK,
15
- ADMISSION_DENY_NOISE,
16
- ADMISSION_OPERATION_TYPE,
17
- ADMISSION_TRIGGER_SOURCE
18
- } from "../src/write-admission.js";
19
- import { POSITIVE_SAMPLES, NEGATIVE_SAMPLES, SAMPLE_VALUES, referenceScan } from "./helpers/write-admission-samples.js";
20
- // #164 A2 的真判据 + 真配置:这一组测的是**接线**(index.js 那条路径的等价物),
21
- // 判据自己的验收在 test/sensitive-scan.test.js。config 用真 schema 而不是手搓对象,
22
- // 钉住「键名/嵌套形状与生产一致」——手搓的 `{ sensitiveScanEnabled: true }` 在键名
23
- // 写错时照样过,真 config 才会红。
24
- import { Config } from "../src/config.js";
25
- import { createSensitiveScan } from "../src/sensitive-scan.js";
26
-
27
- // #254 写入准入(第一阶段:只计量,不拦截)。
28
- // 本批次没有阈值、没有拦截分支,验收看两件事:默认路径零行为变化(无会话身份的
29
- // 写入与未接线的服务都不记行),以及两个闸门的测量点可查(g1 会话写入预算按行聚
30
- // 合、g2 同话题重复带间隔)。第二阶段的拦截(memory_save({confirm:true}))不在这
31
- // 里锁——现在还没有那个分支。
32
-
33
- function setup({ wired = true, now, admissionConfig } = {}) {
34
- const store = createStore(":memory:");
35
- const writeAdmission = wired ? createWriteAdmission({ store, now, config: admissionConfig }) : null;
36
- const service = createService({ store, mirror: null, config: {}, writeAdmission });
37
- return { store, service, writeAdmission };
38
- }
39
-
40
- function admissionRows(store, sessionKey) {
41
- return store.listLlmAudits({ sessionKey }).filter((r) => r.operation_type === ADMISSION_OPERATION_TYPE);
42
- }
43
-
44
- test("无会话身份的写入不进预算,也不写准入行", () => {
45
- const { store, service } = setup();
46
- const created = service.saveWithDedupe({ type: "project", title: "T", content: "正文内容一段" });
47
- assert.equal(created.action, "created");
48
- assert.equal(store.listLlmAudits().length, 0, "系统写入(dream / summarize / import / organize)不记准入行");
49
-
50
- // 未接线写入准入的服务(老调用方 / 单测)同样零变化
51
- const bare = createService({ store, mirror: null, config: {} });
52
- assert.equal(bare.saveWithDedupe({ type: "project", title: "T2", content: "另一段内容" }).action, "created");
53
- assert.equal(store.listLlmAudits().length, 0, "未接线时不产生任何审计行");
54
- });
55
-
56
- test("g1:会话内每个新建行记一行,合并行不记", () => {
57
- const { store, service } = setup();
58
- const s = { _sessionKey: "sess-1" };
59
- service.saveWithDedupe({ ...s, type: "project", title: "A", content: "第一段内容" });
60
- service.saveWithDedupe({ ...s, type: "project", title: "A", content: "追加的第二段" });
61
- service.saveWithDedupe({ ...s, type: "project", title: "B", content: "另一件事" });
62
-
63
- const rows = admissionRows(store, "sess-1");
64
- assert.equal(rows.length, 2, "两个新建行 = 两行;并入已有行不算新增(维护者拍板第 2 项)");
65
- for (const row of rows) {
66
- assert.equal(row.trigger_source, ADMISSION_TRIGGER_SOURCE);
67
- assert.equal(row.status, "skipped", "阶段一不拦截,沿用既有 skip 口径");
68
- assert.equal(row.metadata.gate, "g1");
69
- assert.equal(row.session_key, "sess-1");
70
- assert.equal(row.model_id, "-", "不是模型调用,占位串避免混进路由花费统计");
71
- }
72
- // 行上的会话键可等值查询:按会话聚合即得「会话内新建条数」分布
73
- assert.equal(store.countLlmAudits({ sessionKey: "sess-1" }), 2);
74
- assert.equal(store.countLlmAudits({ sessionKey: "sess-2" }), 0);
75
- });
76
-
77
- test("pinned 类型(constraint / preference)不进预算,穿透频率仍可观测", () => {
78
- const { store, service } = setup();
79
- service.saveWithDedupe({ _sessionKey: "s", type: "constraint", title: "C", content: "边界条件" });
80
- service.saveWithDedupe({ _sessionKey: "s", type: "preference", title: "P", content: "用户偏好" });
81
- service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "T", content: "普通记录" });
82
-
83
- const rows = admissionRows(store, "s");
84
- assert.equal(rows.length, 3, "穿透口也记行——否则「预算为什么没算它」不可观测");
85
- assert.deepEqual(
86
- rows.map((r) => r.metadata.exempt ?? null).sort(),
87
- [null, "pinned", "pinned"]
88
- );
89
- });
90
-
91
- test("g2:同会话内同话题锚重复新建时记一行,带间隔", () => {
92
- let clock = 1_000_000;
93
- const { store, service } = setup({ now: () => clock });
94
- service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "开工", content: "先把 #254 的准入做完" });
95
- assert.equal(admissionRows(store, "s").length, 1, "话题首次出现不算重复");
96
-
97
- clock += 90_000;
98
- service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "换了个标题", content: "接着做 #254,另起一段" });
99
- const rows = admissionRows(store, "s");
100
- assert.equal(rows.length, 2, "一行一个新建行,g2 是这一行上的附加信号,不另起一行");
101
- assert.equal(rows[0].metadata.gate, "g1");
102
- assert.equal(rows[0].metadata.g2.topic, "#254");
103
- assert.equal(rows[0].metadata.g2.gap_ms, 90_000, "间隔按会话内最近一次同话题新建行算");
104
- assert.equal(rows[1].metadata.g2, undefined, "首次出现没有 g2");
105
- assert.equal(rows[0].status, "skipped");
106
- });
107
-
108
- test("g2:并入已有行不算同话题重写", () => {
109
- const { store, service } = setup();
110
- service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "同一标题", content: "看 #254" });
111
- service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "同一标题", content: "再看 #254" });
112
- const rows = admissionRows(store, "s");
113
- assert.equal(rows.length, 1, "合并是去重机制在正常工作,既不进预算也不产生 g2");
114
- });
115
-
116
- // #254 内容哈希(exact duplicate):判据是归一化后逐字节相同,不是相似度;候选集把
117
- // 归档行一起纳入——归档行不在 saveWithDedupe 的候选集里(store.list 默认排除归档),
118
- // 同一个事实再写一次就是新行,这正是这条信号要量的穿透。
119
- test("dup:命中归档行的重复写入被记下来(归档行在候选集内)", () => {
120
- const { store, service } = setup();
121
- const first = service.saveWithDedupe({ type: "project", title: "归档事实", content: "同一件事的正文" });
122
- store.setArchived(first.memory.id, true);
123
- const second = service.saveWithDedupe({
124
- _sessionKey: "sess-dup",
125
- type: "project",
126
- title: "归档事实",
127
- content: "同一件事的正文"
128
- });
129
- assert.equal(second.action, "created", "归档行不在去重候选集里,所以确实新建了一行");
130
-
131
- const rows = admissionRows(store, "sess-dup");
132
- assert.equal(rows.length, 1);
133
- assert.deepEqual(rows[0].metadata.dup, { memory_id: first.memory.id, archived: true, forgotten: false });
134
- });
135
-
136
- test("dup:只差格式(大小写/空白/标点)的重复也算命中", () => {
137
- const { store, service } = setup();
138
- const first = service.saveWithDedupe({ type: "project", title: "Task A", content: "Line1.\n\nLine2" });
139
- // 标题只差大小写 → saveWithDedupe 的精确标题匹配落空 → 走新建路径
140
- const second = service.saveWithDedupe({
141
- _sessionKey: "sess-fmt",
142
- type: "project",
143
- title: "task a",
144
- content: "line1 line2"
145
- });
146
- assert.equal(second.action, "created");
147
- const rows = admissionRows(store, "sess-fmt");
148
- assert.deepEqual(rows[0].metadata.dup, { memory_id: first.memory.id, archived: false, forgotten: false });
149
- });
150
-
151
- test("dup:同内容既有活跃行又有归档行时报活跃那条(活区优先,而不是谁最近被改过)", () => {
152
- // 回归点(单盲审查 L2):候选集按 updated_at 倒序返回,直接取第一条会把「命中归档
153
- // 区」这个信号盖掉(归档动作刚刷过 updated_at)。口径:有活跃命中就报它。
154
- const { store, service } = setup();
155
- const active = store.save({ type: "project", title: "Task A", content: "同一件事" });
156
- const archived = store.save({ type: "project", title: "task a", content: "同一件事" });
157
- store.setArchived(archived.id, true);
158
- const created = service.saveWithDedupe({
159
- _sessionKey: "sess-both", type: "project", title: "TASK a", content: "同一件事"
160
- });
161
- assert.equal(created.action, "created", "标题只差大小写 → 精确标题匹配落空,走新建路径");
162
- assert.deepEqual(admissionRows(store, "sess-both")[0].metadata.dup, { memory_id: active.id, archived: false, forgotten: false });
163
- });
164
-
165
- test("dup:只剩已遗忘未归档的命中时,不把它记成活区命中", () => {
166
- // 回归(自动评审 #311 write-admission.js:158):只回 archived 会把遗忘区命中写成
167
- // archived:false,读审计的人会以为存在活跃重复;而 saveWithDedupe 的候选集本来就排除
168
- // 遗忘行(store.list 默认 includeForgotten=false),这类命中同样是穿透。两个出口分字段记。
169
- // 两条行同标题同正文:遗忘之后标题候选集里已经没有它,第二次写入自然走新建路径。
170
- const { store, service } = setup();
171
- const first = service.saveWithDedupe({ type: "project", title: "遗忘事实", content: "同一段遗忘正文" });
172
- store.setForget(first.memory.id, true);
173
- const second = service.saveWithDedupe({
174
- _sessionKey: "sess-forgot", type: "project", title: "遗忘事实", content: "同一段遗忘正文"
175
- });
176
- assert.equal(second.action, "created", "遗忘行不在去重候选集里,所以确实新建了一行");
177
- assert.deepEqual(admissionRows(store, "sess-forgot")[0].metadata.dup, {
178
- memory_id: first.memory.id, archived: false, forgotten: true
179
- });
180
- });
181
-
182
- test("dup:不同内容不记 dup(负样本,免得信号恒真)", () => {
183
- const { store, service } = setup();
184
- service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "T1", content: "第一件事的正文" });
185
- service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "T2", content: "另一件事的正文" });
186
- const rows = admissionRows(store, "s");
187
- assert.equal(rows.length, 2);
188
- for (const row of rows) assert.equal(row.metadata.dup, undefined);
189
- });
190
-
191
- test("dup:pinned 行不查候选集(整个不在闸门里,与 g2 的不当基准同口径)", () => {
192
- const { store, service } = setup();
193
- service.saveWithDedupe({ _sessionKey: "s", type: "constraint", title: "边界 X", content: "同一段边界" });
194
- service.saveWithDedupe({ _sessionKey: "s", type: "constraint", title: "边界 x", content: "同一段边界" });
195
- const rows = admissionRows(store, "s");
196
- assert.equal(rows.length, 2);
197
- for (const row of rows) {
198
- assert.equal(row.metadata.exempt, "pinned");
199
- assert.equal(row.metadata.dup, undefined, "穿透口不发候选集查询,也不记 dup");
200
- }
201
- });
202
-
203
- test("话题表由审计行重建:新实例(进程重启)仍能认出同话题", () => {
204
- const { store, service } = setup();
205
- service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "A", content: "#99 的记录" });
206
-
207
- // 新实例 = 进程重启:内存视图为空,只能从审计行回填
208
- const revived = createWriteAdmission({ store });
209
- const service2 = createService({ store, mirror: null, config: {}, writeAdmission: revived });
210
- service2.saveWithDedupe({ _sessionKey: "s", type: "project", title: "B", content: "#99 又记了一笔" });
211
-
212
- const g2 = admissionRows(store, "s").filter((r) => r.metadata.g2);
213
- assert.equal(g2.length, 1, "审计行是唯一真相源,重启不回退");
214
- assert.equal(g2[0].metadata.g2.topic, "#99");
215
- });
216
-
217
- test("pinned 行不推进同话题基准(它整个不在闸门里)", () => {
218
- const { store, service } = setup();
219
- service.saveWithDedupe({ _sessionKey: "s", type: "constraint", title: "边界", content: "见 #254" });
220
- service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "记录", content: "见 #254 的另一段" });
221
- assert.equal(
222
- admissionRows(store, "s").filter((r) => r.metadata.g2).length,
223
- 0,
224
- "穿透行不当基准,g2 样本不掺穿透流量"
225
- );
226
- });
227
-
228
- test("llmAudit 关掉时一行都不写(那个开关同时关掉了审计行的保留期清理)", () => {
229
- const { store, service } = setup({ admissionConfig: { llmAudit: { enabled: false } } });
230
- service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "T", content: "内容 #254" });
231
- assert.equal(store.listLlmAudits().length, 0, "审计关掉时不得在无保留期的表里按写入频次增长");
232
- });
233
-
234
- test("话题锚只取机械可判的两类:issue 引用与文件路径", () => {
235
- assert.deepEqual(
236
- extractTopicKeys({ title: "修 #254,#254 已经报过", content: "见 src/service.js 与 docs/handbook/02-write-path.md" }),
237
- ["#254", "docs/handbook/02-write-path.md", "src/service.js"],
238
- "去重 + 排序 + 小写归一(同话题必须逐字节相等才能等值比较)"
239
- );
240
- assert.deepEqual(extractTopicKeys({ title: "SRC/Service.JS", content: "大小写不该造出两个话题" }), ["src/service.js"]);
241
- assert.deepEqual(
242
- extractTopicKeys({ title: "路径", content: "src\\service.js 与 src/service.js 是同一处" }),
243
- ["src/service.js"],
244
- "分隔符归一:同一个文件不能因为写法不同变成两个话题"
245
- );
246
- // 误报样本:Markdown 标题(###1)、比值(3.5/2.0)、版本号(v1.2.3)都不该成为话题锚
247
- assert.deepEqual(extractTopicKeys({ title: "###1 小节", content: "比例约 3.5/2.0,版本 v1.2.3" }), []);
248
- assert.deepEqual(extractTopicKeys({ title: "普通记录", content: "没有任何锚点的一段话" }), []);
249
- assert.deepEqual(extractTopicKeys({}), []);
250
- });
251
-
252
- test("计量失败不反噬写入(旁路,只 warn)", () => {
253
- const store = createStore(":memory:");
254
- const throwing = createService({
255
- store,
256
- mirror: null,
257
- config: {},
258
- writeAdmission: {
259
- evaluate() { throw new Error("evaluate boom"); },
260
- record() { throw new Error("record boom"); }
261
- }
262
- });
263
- assert.equal(
264
- throwing.saveWithDedupe({ _sessionKey: "s", type: "project", title: "T", content: "内容" }).action,
265
- "created",
266
- "evaluate 抛错不能挡下写入"
267
- );
268
-
269
- // evaluate 成功、record 抛错:写入照常返回
270
- const real = createWriteAdmission({ store });
271
- const halfBroken = createService({
272
- store,
273
- mirror: null,
274
- config: {},
275
- writeAdmission: { evaluate: real.evaluate, record() { throw new Error("record boom"); } }
276
- });
277
- assert.equal(
278
- halfBroken.saveWithDedupe({ _sessionKey: "s", type: "project", title: "T2", content: "内容二" }).action,
279
- "created",
280
- "record 抛错不能反噬写入"
281
- );
282
- });
283
-
284
- test("接线:memory_save 把会话键交给写入准入,缺会话身份的宿主落 null", async () => {
285
- const store = createStore(":memory:");
286
- const writeAdmission = createWriteAdmission({ store });
287
- const service = createService({ store, mirror: null, config: {}, writeAdmission });
288
- const registered = [];
289
- createTools({ tools: { register(def) { registered.push(def); return () => {}; } } }, service, {}, null);
290
- const save = registered.find((t) => t.name === "memory_save");
291
- assert.ok(save, "memory_save 应已注册");
292
-
293
- await save.execute(
294
- { type: "project", title: "T", content: "记录 #254" },
295
- { agent: { session: { id: "sess-9", header: { agentPreset: "coder", cwd: "D:\\proj" } } } }
296
- );
297
- assert.equal(store.countLlmAudits({ sessionKey: "sess-9" }), 1, "工具层要真的把会话键传下去");
298
-
299
- await save.execute({ type: "project", title: "T2", content: "无会话身份" }, {});
300
- assert.equal(store.countLlmAudits(), 1, "缺会话身份时既不记行也不报错");
301
- });
302
-
303
- test("旧库(llm_audit_logs 无 session_key)打开自动补列,准入行照常落", () => {
304
- const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-admission-"));
305
- const dbPath = join(dir, "memory.db");
306
- try {
307
- // 用裸 DatabaseSync 构造带 #254 之前表结构的旧库
308
- const old = new DatabaseSync(dbPath);
309
- old.exec(`
310
- CREATE TABLE llm_audit_logs (
311
- id INTEGER PRIMARY KEY AUTOINCREMENT,
312
- timestamp TEXT NOT NULL,
313
- trigger_source TEXT NOT NULL,
314
- operation_type TEXT NOT NULL,
315
- model_id TEXT NOT NULL,
316
- input_tokens INTEGER NOT NULL DEFAULT 0,
317
- output_tokens INTEGER NOT NULL DEFAULT 0,
318
- total_tokens INTEGER NOT NULL DEFAULT 0,
319
- cost_usd REAL NOT NULL DEFAULT 0,
320
- duration_ms INTEGER NOT NULL DEFAULT 0,
321
- status TEXT NOT NULL,
322
- error_message TEXT,
323
- related_memory_ids TEXT,
324
- metadata TEXT
325
- );
326
- INSERT INTO llm_audit_logs (timestamp, trigger_source, operation_type, model_id, status, metadata)
327
- VALUES ('2026-01-01T00:00:00.000Z', 'autoDream', 'dream_consolidate', 'm', 'success', '{"old":1}');
328
- `);
329
- old.close();
330
-
331
- let store = createStore(dbPath);
332
- const cols = store.db.prepare("PRAGMA table_info(llm_audit_logs)").all().map((c) => c.name);
333
- assert.ok(cols.includes("session_key"), "旧库打开必须自动补 session_key 列");
334
- assert.equal(store.listLlmAudits().length, 1, "存量审计行保留");
335
- assert.equal(store.listLlmAudits()[0].session_key, undefined, "存量行该列为空");
336
-
337
- const admission = createWriteAdmission({ store });
338
- const memory = { title: "开工", content: "#254 的准入" };
339
- admission.record({
340
- sessionKey: "s1",
341
- verdict: admission.evaluate({ memory, sessionKey: "s1" }),
342
- memoryId: "m1"
343
- });
344
- assert.equal(store.countLlmAudits({ sessionKey: "s1" }), 1, "迁移后的库上准入行可落可查");
345
-
346
- // 重复打开幂等:列不重复、既有行不丢
347
- store.close();
348
- store = createStore(dbPath);
349
- assert.equal(store.countLlmAudits({ sessionKey: "s1" }), 1, "重复打开不丢数据");
350
- assert.equal(store.countLlmAudits(), 2, "既有的 LLM 审计行与准入行都在");
351
- store.close();
352
- } finally {
353
- rmSync(dir, { recursive: true, force: true });
354
- }
355
- });
356
-
357
- // --- 第 1 级确定性拒绝(#254 第二阶段) ----------------------------------------
358
- // 判据面按 09-21 / 09-22 两轮收窄到两类:空白 / 纯噪声 + 注入进来的密钥 / PII。
359
- // 去重键命中**不**在这里(归 write-update 放行),G1/G2 阈值也只是计量。
360
- //
361
- // 两个开关都默认关,所以这一组测试的第一件事是证明「关掉时逐字段等于只计量那一
362
- // 阶段」。拦截面本身要证明三件事:拒了(真的没落库)、可解释(审计行里有 reason)、
363
- // 不误杀(正常文本一条不动)。
364
-
365
- function level1Store(opts = {}) {
366
- const { enabled = false, enforce = false, sensitiveScan = null, now } = opts;
367
- const store = createStore(":memory:");
368
- const writeAdmission = createWriteAdmission({
369
- store,
370
- now,
371
- config: { writeAdmission: { enabled, enforce } },
372
- sensitiveScan
373
- });
374
- const service = createService({ store, mirror: null, config: {}, writeAdmission });
375
- return { store, service, writeAdmission };
376
- }
377
-
378
- /** 落库行数(含归档 / 遗忘:被拒的写入一条都不该多)。 */
379
- function rowCount(store) {
380
- return store.list({ limit: 500, includeArchived: true, includeForgotten: true }).length;
381
- }
382
-
383
- test("信息量判据:blank 与 noise 分开报,正常文本不命中", () => {
384
- assert.equal(informationlessReason({ title: "", content: "" }), "blank");
385
- assert.equal(informationlessReason({ title: " ", content: "\n\t " }), "blank", "纯空白折掉之后什么都没写");
386
- assert.equal(informationlessReason({ title: "标题", content: "" }), null, "有一边有内容就不是空写入");
387
- assert.equal(informationlessReason({ title: "!!!", content: "--- ..." }), "noise", "只有标点 = 归一化后什么都不剩");
388
- assert.equal(informationlessReason({ title: "a", content: "..." }), null, "只剩一个字母也算有信息");
389
- // 两种 reason 分开报是为了可解释:blank 是提交了空表单,noise 是只填了标点,修法不同。
390
- assert.notEqual(ADMISSION_DENY_BLANK, ADMISSION_DENY_NOISE);
391
- });
392
-
393
- test("默认关:enabled 关时判据根本不跑,连 enforce 开着也没用", () => {
394
- const { store, service } = level1Store({ enabled: false, enforce: true });
395
- const result = service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "", content: " " });
396
- assert.equal(result.action, "created", "判据没开就不该有任何拒绝分支");
397
- assert.equal(rowCount(store), 1);
398
- const rows = admissionRows(store, "s");
399
- assert.equal(rows.length, 1, "只计量那一阶段的行为逐字段保留");
400
- // 验收第 1 条是「默认路径逐字节一致」,所以这里断言的是**整个 metadata**与只计量
401
- // 阶段相同:连一个恒为 "allow" 的 decision 键都不该多出来。多一个键就意味着
402
- // 「默认关零行为变化」从事实变成了需要解释的说法。
403
- assert.deepEqual(rows[0].metadata, { gate: "g1", topics: [] });
404
- assert.equal(rows[0].metadata.deny, undefined, "判据没跑就不该有拒绝面");
405
- });
406
-
407
- test("仅告警档:enabled 开、enforce 关 → 写入成功,审计行带拒绝面", () => {
408
- const { store, service } = level1Store({ enabled: true, enforce: false });
409
- const result = service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "!!", content: "..." });
410
- assert.equal(result.action, "created", "只开检测不拦写入");
411
- assert.equal(rowCount(store), 1);
412
- const rows = admissionRows(store, "s");
413
- assert.equal(rows.length, 1);
414
- assert.equal(rows[0].metadata.decision, "allow", "仅告警档的决策仍是放行");
415
- assert.deepEqual(rows[0].metadata.deny, { reason: "noise", enforced: false });
416
- // 「deny 非空但 decision=allow」就是仅告警档的指纹,不必再读配置才能解释这一行。
417
- });
418
-
419
- test("硬拒:enabled + enforce → 不落库、返回 reason、审计行标 enforced", () => {
420
- const { store, service } = level1Store({ enabled: true, enforce: true });
421
- const result = service.saveWithDedupe({ _sessionKey: "s", type: "project", title: " ", content: "" });
422
- assert.equal(result.action, "denied");
423
- assert.equal(result.reason, "blank");
424
- assert.equal(result.memory, null, "被拒的写入没有行可返回");
425
- assert.equal(rowCount(store), 0, "store.save 在拒绝时根本没有被调用");
426
-
427
- const rows = admissionRows(store, "s");
428
- assert.equal(rows.length, 1, "拒绝面必须留审计(验收第 2 条:可解释、不静默丢弃)");
429
- assert.equal(rows[0].related_memory_ids.length, 0, "没有产生任何行,不能挂一个不存在的 id");
430
- assert.equal(rows[0].metadata.decision, "deny");
431
- assert.deepEqual(rows[0].metadata.deny, { reason: "blank", enforced: true });
432
-
433
- // 标题有内容就不是空写入:标题有信息(去重键就是它),归一化后非空不该被拒。
434
- const titled = service.saveWithDedupe({ _sessionKey: "s2", type: "project", title: "标题", content: "" });
435
- assert.equal(titled.action, "created", "title 有内容 = 有信息,不能判成 blank");
436
- });
437
-
438
- test("被拦下的写入不推进话题基准(它没进库,不能成为下一次 g2 的基准)", () => {
439
- const { store, service } = level1Store({
440
- enabled: true, enforce: true, sensitiveScan: referenceScan, now: () => 1_000_000
441
- });
442
- // 用密钥类而不是空白类来测这件事:空白/噪声行**不可能**带话题锚(锚是 `#254` /
443
- // 路径这类含数字或字母的串,normalizeForHash 之后仍非空,所以它在第 1 级根本不
444
- // 会命中)——这条回归只有在 sensitive 那一档才可复现,而密钥行通常也写着上下文。
445
- const denied = service.saveWithDedupe({
446
- _sessionKey: "s",
447
- type: "project",
448
- title: "见 #254",
449
- content: `token 是 ${SAMPLE_VALUES.githubPat}`
450
- });
451
- assert.equal(denied.action, "denied");
452
- assert.equal(denied.deny.kind, "github_token");
453
- // 同一话题再来一条正常写入:如果被拒那条推进了基准,这里就会凭空多出一个 g2。
454
- service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "记录", content: "#254 的正文" });
455
- const g2 = admissionRows(store, "s").filter((r) => r.metadata.g2);
456
- assert.equal(g2.length, 0, "拒绝行不能造出一条不存在的时间线");
457
- assert.equal(rowCount(store), 1, "只有第二条真的落库了");
458
- });
459
-
460
- test("硬拒在 pinned 豁免之前:写着空内容的 constraint 一样被拒", () => {
461
- // pinned 豁免的是**预算与冷却**(constraint / preference 是逐字保真池,不该被会话
462
- // 预算拦),不是「这条能不能落库」。顺序反了就会出现「pinned 行绕过第 1 级」。
463
- const { store, service } = level1Store({ enabled: true, enforce: true });
464
- const result = service.saveWithDedupe({ _sessionKey: "s", type: "constraint", title: "...", content: "" });
465
- assert.equal(result.action, "denied");
466
- assert.equal(result.reason, "noise");
467
- assert.equal(rowCount(store), 0);
468
- assert.equal(admissionRows(store, "s")[0].metadata.deny.reason, "noise");
469
- });
470
-
471
- test("sensitiveScan 注入:正样本一条都不漏放(密钥 + PII)", () => {
472
- const { store, service } = level1Store({ enabled: true, enforce: true, sensitiveScan: referenceScan });
473
- for (const sample of POSITIVE_SAMPLES) {
474
- const result = service.saveWithDedupe({
475
- _sessionKey: `sess-${sample.id}`,
476
- type: "project",
477
- title: sample.title,
478
- content: sample.content
479
- });
480
- assert.equal(result.action, "denied", `${sample.id} 被漏放(${sample.why})`);
481
- assert.equal(result.reason, "sensitive", `${sample.id} 的 reason 该是 sensitive`);
482
- const deny = admissionRows(store, `sess-${sample.id}`)[0].metadata.deny;
483
- assert.equal(deny.kind, sample.kind, `${sample.id} 的 kind 要落进审计,enforce 前的分布才可按类看`);
484
- assert.equal(deny.enforced, true);
485
- }
486
- assert.equal(rowCount(store), 0, "正样本一条都不该落库");
487
- });
488
-
489
- test("sensitiveScan 注入:负样本一条都不误杀(含敏感词但没有值)", () => {
490
- const { store, service } = level1Store({ enabled: true, enforce: true, sensitiveScan: referenceScan });
491
- for (const sample of NEGATIVE_SAMPLES) {
492
- const result = service.saveWithDedupe({
493
- _sessionKey: `sess-${sample.id}`,
494
- type: "project",
495
- title: sample.title,
496
- content: sample.content
497
- });
498
- assert.equal(result.action, "created", `${sample.id} 被误杀(${sample.why})`);
499
- }
500
- assert.equal(rowCount(store), NEGATIVE_SAMPLES.length, "负样本应当全部落库");
501
- // 负样本一条 deny 面都不该有——误杀面是这条闸门唯一会伤人的地方。
502
- for (const sample of NEGATIVE_SAMPLES) {
503
- for (const row of admissionRows(store, `sess-${sample.id}`)) {
504
- assert.equal(row.metadata.deny, undefined, `${sample.id} 不该带任何拒绝面`);
505
- }
506
- }
507
- });
508
-
509
- test("sensitiveScan 抛错按「未命中」处理:判据故障不能让写入变成不可用", () => {
510
- const { store, service } = level1Store({
511
- enabled: true,
512
- enforce: true,
513
- sensitiveScan() { throw new Error("scanner boom"); }
514
- });
515
- const result = service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "正常", content: "正常正文" });
516
- assert.equal(result.action, "created", "扫描器坏了必须放行,不能把所有写入拦死");
517
- assert.equal(rowCount(store), 1);
518
- });
519
-
520
- test("命中且 enforce 时不发候选集查询(决策已定,白花成本)", () => {
521
- const { store, service } = level1Store({ enabled: true, enforce: true });
522
- // 先落一条同内容(走正常开关),再做一次同内容写入:若还查候选集,就会带上 dup。
523
- store.save({ type: "project", title: "已存在", content: "已存在的正文" });
524
- const result = service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "...", content: "" });
525
- assert.equal(result.action, "denied");
526
- const rows = admissionRows(store, "s");
527
- assert.equal(rows.length, 1, "只该有拒绝那一行");
528
- assert.equal(rows[0].metadata.dup, undefined, "拒绝路径不查候选集");
529
- });
530
-
531
- test("接线:被拒时 memory_save 返回 action=denied 与可行动的 reason", async () => {
532
- const store = createStore(":memory:");
533
- const writeAdmission = createWriteAdmission({
534
- store,
535
- config: { writeAdmission: { enabled: true, enforce: true } }
536
- });
537
- const service = createService({ store, mirror: null, config: {}, writeAdmission });
538
- const registered = [];
539
- createTools({ tools: { register(def) { registered.push(def); return () => {}; } } }, service, {}, null);
540
- const save = registered.find((t) => t.name === "memory_save");
541
-
542
- const denied = await save.execute(
543
- { type: "project", title: "...", content: "" },
544
- { agent: { session: { id: "sess-deny" } } }
545
- );
546
- assert.equal(denied.action, "denied");
547
- assert.equal(denied.reason, "noise");
548
- assert.equal(denied.id, undefined, "没有落库就不该编一个 id 出来");
549
- assert.equal(store.countLlmAudits({ sessionKey: "sess-deny" }), 1, "工具层拒绝也要留审计");
550
-
551
- // 正常写入不受影响,返回形状与既有调用方一致
552
- const ok = await save.execute(
553
- { type: "project", title: "正常标题", content: "正常正文内容" },
554
- { agent: { session: { id: "sess-deny" } } }
555
- );
556
- assert.equal(ok.action, "created");
557
- assert.ok(ok.id, "正常路径仍然返回 id");
558
- });
559
-
560
- // --- #164 A2 接线:判据自己的闸 × 闸门的 enforce -------------------------------
561
- // 这一组是 index.js 那条装配路径的等价物:真 config(Config({}))+ createSensitiveScan
562
- // 工厂 + 写入准入。三层开关的语义由此钉住——判据关(默认)不参与、判据开而 enforce 关
563
- // 只留审计、两个都开才拦。判据本身的假阳/漏放验收在 test/sensitive-scan.test.js。
564
-
565
- /** 用真 `Config` 解析默认值,再叠加测试要覆盖的那几个键。 */
566
- function a2Store(overrides = {}) {
567
- const cfg = Config(overrides);
568
- const store = createStore(":memory:");
569
- const writeAdmission = createWriteAdmission({
570
- store,
571
- config: cfg,
572
- sensitiveScan: createSensitiveScan({ config: cfg })
573
- });
574
- const service = createService({ store, mirror: null, config: cfg, writeAdmission });
575
- return { store, service, cfg };
576
- }
577
-
578
- const SECRET_ROW = {
579
- type: "project",
580
- title: "调试记录",
581
- content: `临时代码里贴了 ${SAMPLE_VALUES.githubPat},回头删掉`
582
- };
583
-
584
- test("A2 默认关:写有密钥的行照常落库,且连拒绝面都不该出现", () => {
585
- const { store, service } = a2Store();
586
- const result = service.saveWithDedupe({ ...SECRET_ROW, _sessionKey: "s" });
587
- assert.equal(result.action, "created", "判据默认关时这条路径与 #332 逐字段一致");
588
- assert.equal(rowCount(store), 1);
589
- // metadata 里不该多出任何 deny/decision 键——「默认关零行为变化」是验收第 1 条,
590
- // 多一个恒为 allow 的键就把它从事实变成需要解释的说法(同上面 #254 那条)。
591
- const row = admissionRows(store, "s")[0];
592
- assert.equal(row.metadata.deny, undefined);
593
- assert.equal(row.metadata.decision, undefined);
594
- });
595
-
596
- test("A2 观察档:判据开、enforce 关 → 写入照常,审计行带 kind 与 enforced=false", () => {
597
- const { store, service } = a2Store({ sensitiveScanEnabled: true, writeAdmission: { enabled: true } });
598
- const result = service.saveWithDedupe({ ...SECRET_ROW, _sessionKey: "s" });
599
- assert.equal(result.action, "created", "#164 口径:默认仅告警");
600
- assert.equal(rowCount(store), 1);
601
- const deny = admissionRows(store, "s")[0].metadata.deny;
602
- assert.equal(deny.reason, "sensitive");
603
- assert.equal(deny.kind, "github_token");
604
- assert.equal(deny.enforced, false, "仅告警档的指纹:deny 非空、enforced=false、写入仍发生");
605
- });
606
-
607
- test("A2 拦截档:判据开 + enforce 开 → 拒了、没落库、审计标 enforced", () => {
608
- const { store, service } = a2Store({
609
- sensitiveScanEnabled: true,
610
- writeAdmission: { enabled: true, enforce: true }
611
- });
612
- const result = service.saveWithDedupe({ ...SECRET_ROW, _sessionKey: "s" });
613
- assert.equal(result.action, "denied");
614
- assert.equal(result.reason, "sensitive");
615
- assert.equal(rowCount(store), 0, "被拒的写入一条都不该落库");
616
- const deny = admissionRows(store, "s")[0].metadata.deny;
617
- assert.equal(deny.kind, "github_token");
618
- assert.equal(deny.enforced, true);
619
- });
620
-
621
- test("A2 与第 1 级空白判据互不依赖:只开 A2 不会把空白写入拦下", () => {
622
- // 两个键是两批判据的闸(空白/噪声 vs 密钥/PII),分开是为了能单独观察 A2 的
623
- // 命中分布——绑在一个开关上就没法只看这一类的假阳率。
624
- const { store, service } = a2Store({ sensitiveScanEnabled: true });
625
- const blank = service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "...", content: "" });
626
- assert.equal(blank.action, "created", "A2 开着不等于第 1 级判据也开");
627
- assert.equal(admissionRows(store, "s")[0].metadata.deny, undefined);
628
- // 反过来:只开第 1 级(writeAdmission.enabled)、A2 关,密钥行照常落库。
629
- const { store: s2, service: svc2 } = a2Store({ writeAdmission: { enabled: true } });
630
- assert.equal(svc2.saveWithDedupe({ ...SECRET_ROW, _sessionKey: "s" }).action, "created");
631
- assert.equal(s2.list({ limit: 10 }).length, 1);
632
- });
633
-
634
- test("A2 跑在闸门里:只开 sensitiveScanEnabled(闸门关)时扫描器根本不参与", () => {
635
- // 判据的唯一调用点是 write-admission 的 firstLevelHit,而闸门要
636
- // writeAdmission.enabled 打开才走第 1 级判据。所以「本键开着」不等于「会扫」——
637
- // 配置注释与面板文案都按这条写,否则用户以为单开本键就能拿到 A2 的命中分布。
638
- const { store, service } = a2Store({ sensitiveScanEnabled: true });
639
- const result = service.saveWithDedupe({ ...SECRET_ROW, _sessionKey: "s" });
640
- assert.equal(result.action, "created", "闸门没开时 A2 不参与判定");
641
- assert.equal(rowCount(store), 1, "写入照常落库");
642
- const rows = admissionRows(store, "s");
643
- assert.ok(rows.length > 0, "闸门仍走计量路径(去重 / g2 不受第 1 级开关影响)");
644
- assert.ok(rows.every((row) => row.metadata.deny === undefined), "一条 deny 审计都没有:第 1 级判据整体没跑");
645
- });
646
-
647
- test("A2 负样本在真装配下一条都不误杀(含敏感词但没有值)", () => {
648
- const { store, service } = a2Store({
649
- sensitiveScanEnabled: true,
650
- writeAdmission: { enabled: true, enforce: true }
651
- });
652
- for (const sample of NEGATIVE_SAMPLES) {
653
- const result = service.saveWithDedupe({
654
- _sessionKey: `sess-${sample.id}`,
655
- type: "project",
656
- title: sample.title,
657
- content: sample.content
658
- });
659
- assert.equal(result.action, "created", `${sample.id} 被误杀(${sample.why})`);
660
- }
661
- assert.equal(rowCount(store), NEGATIVE_SAMPLES.length);
662
- for (const sample of NEGATIVE_SAMPLES) {
663
- for (const row of admissionRows(store, `sess-${sample.id}`)) {
664
- assert.equal(row.metadata.deny, undefined, `${sample.id} 不该带任何拒绝面`);
665
- }
666
- }
667
- });
668
-
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
+ informationlessReason,
14
+ ADMISSION_DENY_BLANK,
15
+ ADMISSION_DENY_NOISE,
16
+ ADMISSION_OPERATION_TYPE,
17
+ ADMISSION_TRIGGER_SOURCE
18
+ } from "../src/write-admission.js";
19
+ import { POSITIVE_SAMPLES, NEGATIVE_SAMPLES, SAMPLE_VALUES, referenceScan } from "./helpers/write-admission-samples.js";
20
+ // #164 A2 的真判据 + 真配置:这一组测的是**接线**(index.js 那条路径的等价物),
21
+ // 判据自己的验收在 test/sensitive-scan.test.js。config 用真 schema 而不是手搓对象,
22
+ // 钉住「键名/嵌套形状与生产一致」——手搓的 `{ sensitiveScanEnabled: true }` 在键名
23
+ // 写错时照样过,真 config 才会红。
24
+ import { Config } from "../src/config.js";
25
+ import { createSensitiveScan } from "../src/sensitive-scan.js";
26
+
27
+ // #254 写入准入(第一阶段:只计量,不拦截)。
28
+ // 本批次没有阈值、没有拦截分支,验收看两件事:默认路径零行为变化(无会话身份的
29
+ // 写入与未接线的服务都不记行),以及两个闸门的测量点可查(g1 会话写入预算按行聚
30
+ // 合、g2 同话题重复带间隔)。第二阶段的拦截(memory_save({confirm:true}))不在这
31
+ // 里锁——现在还没有那个分支。
32
+
33
+ function setup({ wired = true, now, admissionConfig } = {}) {
34
+ const store = createStore(":memory:");
35
+ const writeAdmission = wired ? createWriteAdmission({ store, now, config: admissionConfig }) : null;
36
+ const service = createService({ store, mirror: null, config: {}, writeAdmission });
37
+ return { store, service, writeAdmission };
38
+ }
39
+
40
+ function admissionRows(store, sessionKey) {
41
+ return store.listLlmAudits({ sessionKey }).filter((r) => r.operation_type === ADMISSION_OPERATION_TYPE);
42
+ }
43
+
44
+ test("无会话身份的写入不进预算,也不写准入行", () => {
45
+ const { store, service } = setup();
46
+ const created = service.saveWithDedupe({ type: "project", title: "T", content: "正文内容一段" });
47
+ assert.equal(created.action, "created");
48
+ assert.equal(store.listLlmAudits().length, 0, "系统写入(dream / summarize / import / organize)不记准入行");
49
+
50
+ // 未接线写入准入的服务(老调用方 / 单测)同样零变化
51
+ const bare = createService({ store, mirror: null, config: {} });
52
+ assert.equal(bare.saveWithDedupe({ type: "project", title: "T2", content: "另一段内容" }).action, "created");
53
+ assert.equal(store.listLlmAudits().length, 0, "未接线时不产生任何审计行");
54
+ });
55
+
56
+ test("g1:会话内每个新建行记一行,合并行不记", () => {
57
+ const { store, service } = setup();
58
+ const s = { _sessionKey: "sess-1" };
59
+ service.saveWithDedupe({ ...s, type: "project", title: "A", content: "第一段内容" });
60
+ service.saveWithDedupe({ ...s, type: "project", title: "A", content: "追加的第二段" });
61
+ service.saveWithDedupe({ ...s, type: "project", title: "B", content: "另一件事" });
62
+
63
+ const rows = admissionRows(store, "sess-1");
64
+ assert.equal(rows.length, 2, "两个新建行 = 两行;并入已有行不算新增(维护者拍板第 2 项)");
65
+ for (const row of rows) {
66
+ assert.equal(row.trigger_source, ADMISSION_TRIGGER_SOURCE);
67
+ assert.equal(row.status, "skipped", "阶段一不拦截,沿用既有 skip 口径");
68
+ assert.equal(row.metadata.gate, "g1");
69
+ assert.equal(row.session_key, "sess-1");
70
+ assert.equal(row.model_id, "-", "不是模型调用,占位串避免混进路由花费统计");
71
+ }
72
+ // 行上的会话键可等值查询:按会话聚合即得「会话内新建条数」分布
73
+ assert.equal(store.countLlmAudits({ sessionKey: "sess-1" }), 2);
74
+ assert.equal(store.countLlmAudits({ sessionKey: "sess-2" }), 0);
75
+ });
76
+
77
+ test("pinned 类型(constraint / preference)不进预算,穿透频率仍可观测", () => {
78
+ const { store, service } = setup();
79
+ service.saveWithDedupe({ _sessionKey: "s", type: "constraint", title: "C", content: "边界条件" });
80
+ service.saveWithDedupe({ _sessionKey: "s", type: "preference", title: "P", content: "用户偏好" });
81
+ service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "T", content: "普通记录" });
82
+
83
+ const rows = admissionRows(store, "s");
84
+ assert.equal(rows.length, 3, "穿透口也记行——否则「预算为什么没算它」不可观测");
85
+ assert.deepEqual(
86
+ rows.map((r) => r.metadata.exempt ?? null).sort(),
87
+ [null, "pinned", "pinned"]
88
+ );
89
+ });
90
+
91
+ test("g2:同会话内同话题锚重复新建时记一行,带间隔", () => {
92
+ let clock = 1_000_000;
93
+ const { store, service } = setup({ now: () => clock });
94
+ service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "开工", content: "先把 #254 的准入做完" });
95
+ assert.equal(admissionRows(store, "s").length, 1, "话题首次出现不算重复");
96
+
97
+ clock += 90_000;
98
+ service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "换了个标题", content: "接着做 #254,另起一段" });
99
+ const rows = admissionRows(store, "s");
100
+ assert.equal(rows.length, 2, "一行一个新建行,g2 是这一行上的附加信号,不另起一行");
101
+ assert.equal(rows[0].metadata.gate, "g1");
102
+ assert.equal(rows[0].metadata.g2.topic, "#254");
103
+ assert.equal(rows[0].metadata.g2.gap_ms, 90_000, "间隔按会话内最近一次同话题新建行算");
104
+ assert.equal(rows[1].metadata.g2, undefined, "首次出现没有 g2");
105
+ assert.equal(rows[0].status, "skipped");
106
+ });
107
+
108
+ test("g2:并入已有行不算同话题重写", () => {
109
+ const { store, service } = setup();
110
+ service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "同一标题", content: "看 #254" });
111
+ service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "同一标题", content: "再看 #254" });
112
+ const rows = admissionRows(store, "s");
113
+ assert.equal(rows.length, 1, "合并是去重机制在正常工作,既不进预算也不产生 g2");
114
+ });
115
+
116
+ // #254 内容哈希(exact duplicate):判据是归一化后逐字节相同,不是相似度;候选集把
117
+ // 归档行一起纳入——归档行不在 saveWithDedupe 的候选集里(store.list 默认排除归档),
118
+ // 同一个事实再写一次就是新行,这正是这条信号要量的穿透。
119
+ test("dup:命中归档行的重复写入被记下来(归档行在候选集内)", () => {
120
+ const { store, service } = setup();
121
+ const first = service.saveWithDedupe({ type: "project", title: "归档事实", content: "同一件事的正文" });
122
+ store.setArchived(first.memory.id, true);
123
+ const second = service.saveWithDedupe({
124
+ _sessionKey: "sess-dup",
125
+ type: "project",
126
+ title: "归档事实",
127
+ content: "同一件事的正文"
128
+ });
129
+ assert.equal(second.action, "created", "归档行不在去重候选集里,所以确实新建了一行");
130
+
131
+ const rows = admissionRows(store, "sess-dup");
132
+ assert.equal(rows.length, 1);
133
+ assert.deepEqual(rows[0].metadata.dup, { memory_id: first.memory.id, archived: true, forgotten: false });
134
+ });
135
+
136
+ test("dup:只差格式(大小写/空白/标点)的重复也算命中", () => {
137
+ const { store, service } = setup();
138
+ const first = service.saveWithDedupe({ type: "project", title: "Task A", content: "Line1.\n\nLine2" });
139
+ // 标题只差大小写 → saveWithDedupe 的精确标题匹配落空 → 走新建路径
140
+ const second = service.saveWithDedupe({
141
+ _sessionKey: "sess-fmt",
142
+ type: "project",
143
+ title: "task a",
144
+ content: "line1 line2"
145
+ });
146
+ assert.equal(second.action, "created");
147
+ const rows = admissionRows(store, "sess-fmt");
148
+ assert.deepEqual(rows[0].metadata.dup, { memory_id: first.memory.id, archived: false, forgotten: false });
149
+ });
150
+
151
+ test("dup:同内容既有活跃行又有归档行时报活跃那条(活区优先,而不是谁最近被改过)", () => {
152
+ // 回归点(单盲审查 L2):候选集按 updated_at 倒序返回,直接取第一条会把「命中归档
153
+ // 区」这个信号盖掉(归档动作刚刷过 updated_at)。口径:有活跃命中就报它。
154
+ const { store, service } = setup();
155
+ const active = store.save({ type: "project", title: "Task A", content: "同一件事" });
156
+ const archived = store.save({ type: "project", title: "task a", content: "同一件事" });
157
+ store.setArchived(archived.id, true);
158
+ const created = service.saveWithDedupe({
159
+ _sessionKey: "sess-both", type: "project", title: "TASK a", content: "同一件事"
160
+ });
161
+ assert.equal(created.action, "created", "标题只差大小写 → 精确标题匹配落空,走新建路径");
162
+ assert.deepEqual(admissionRows(store, "sess-both")[0].metadata.dup, { memory_id: active.id, archived: false, forgotten: false });
163
+ });
164
+
165
+ test("dup:只剩已遗忘未归档的命中时,不把它记成活区命中", () => {
166
+ // 回归(自动评审 #311 write-admission.js:158):只回 archived 会把遗忘区命中写成
167
+ // archived:false,读审计的人会以为存在活跃重复;而 saveWithDedupe 的候选集本来就排除
168
+ // 遗忘行(store.list 默认 includeForgotten=false),这类命中同样是穿透。两个出口分字段记。
169
+ // 两条行同标题同正文:遗忘之后标题候选集里已经没有它,第二次写入自然走新建路径。
170
+ const { store, service } = setup();
171
+ const first = service.saveWithDedupe({ type: "project", title: "遗忘事实", content: "同一段遗忘正文" });
172
+ store.setForget(first.memory.id, true);
173
+ const second = service.saveWithDedupe({
174
+ _sessionKey: "sess-forgot", type: "project", title: "遗忘事实", content: "同一段遗忘正文"
175
+ });
176
+ assert.equal(second.action, "created", "遗忘行不在去重候选集里,所以确实新建了一行");
177
+ assert.deepEqual(admissionRows(store, "sess-forgot")[0].metadata.dup, {
178
+ memory_id: first.memory.id, archived: false, forgotten: true
179
+ });
180
+ });
181
+
182
+ test("dup:不同内容不记 dup(负样本,免得信号恒真)", () => {
183
+ const { store, service } = setup();
184
+ service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "T1", content: "第一件事的正文" });
185
+ service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "T2", content: "另一件事的正文" });
186
+ const rows = admissionRows(store, "s");
187
+ assert.equal(rows.length, 2);
188
+ for (const row of rows) assert.equal(row.metadata.dup, undefined);
189
+ });
190
+
191
+ test("dup:pinned 行不查候选集(整个不在闸门里,与 g2 的不当基准同口径)", () => {
192
+ const { store, service } = setup();
193
+ service.saveWithDedupe({ _sessionKey: "s", type: "constraint", title: "边界 X", content: "同一段边界" });
194
+ service.saveWithDedupe({ _sessionKey: "s", type: "constraint", title: "边界 x", content: "同一段边界" });
195
+ const rows = admissionRows(store, "s");
196
+ assert.equal(rows.length, 2);
197
+ for (const row of rows) {
198
+ assert.equal(row.metadata.exempt, "pinned");
199
+ assert.equal(row.metadata.dup, undefined, "穿透口不发候选集查询,也不记 dup");
200
+ }
201
+ });
202
+
203
+ test("话题表由审计行重建:新实例(进程重启)仍能认出同话题", () => {
204
+ const { store, service } = setup();
205
+ service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "A", content: "#99 的记录" });
206
+
207
+ // 新实例 = 进程重启:内存视图为空,只能从审计行回填
208
+ const revived = createWriteAdmission({ store });
209
+ const service2 = createService({ store, mirror: null, config: {}, writeAdmission: revived });
210
+ service2.saveWithDedupe({ _sessionKey: "s", type: "project", title: "B", content: "#99 又记了一笔" });
211
+
212
+ const g2 = admissionRows(store, "s").filter((r) => r.metadata.g2);
213
+ assert.equal(g2.length, 1, "审计行是唯一真相源,重启不回退");
214
+ assert.equal(g2[0].metadata.g2.topic, "#99");
215
+ });
216
+
217
+ test("pinned 行不推进同话题基准(它整个不在闸门里)", () => {
218
+ const { store, service } = setup();
219
+ service.saveWithDedupe({ _sessionKey: "s", type: "constraint", title: "边界", content: "见 #254" });
220
+ service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "记录", content: "见 #254 的另一段" });
221
+ assert.equal(
222
+ admissionRows(store, "s").filter((r) => r.metadata.g2).length,
223
+ 0,
224
+ "穿透行不当基准,g2 样本不掺穿透流量"
225
+ );
226
+ });
227
+
228
+ test("llmAudit 关掉时一行都不写(那个开关同时关掉了审计行的保留期清理)", () => {
229
+ const { store, service } = setup({ admissionConfig: { llmAudit: { enabled: false } } });
230
+ service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "T", content: "内容 #254" });
231
+ assert.equal(store.listLlmAudits().length, 0, "审计关掉时不得在无保留期的表里按写入频次增长");
232
+ });
233
+
234
+ test("话题锚只取机械可判的两类:issue 引用与文件路径", () => {
235
+ assert.deepEqual(
236
+ extractTopicKeys({ title: "修 #254,#254 已经报过", content: "见 src/service.js 与 docs/handbook/02-write-path.md" }),
237
+ ["#254", "docs/handbook/02-write-path.md", "src/service.js"],
238
+ "去重 + 排序 + 小写归一(同话题必须逐字节相等才能等值比较)"
239
+ );
240
+ assert.deepEqual(extractTopicKeys({ title: "SRC/Service.JS", content: "大小写不该造出两个话题" }), ["src/service.js"]);
241
+ assert.deepEqual(
242
+ extractTopicKeys({ title: "路径", content: "src\\service.js 与 src/service.js 是同一处" }),
243
+ ["src/service.js"],
244
+ "分隔符归一:同一个文件不能因为写法不同变成两个话题"
245
+ );
246
+ // 误报样本:Markdown 标题(###1)、比值(3.5/2.0)、版本号(v1.2.3)都不该成为话题锚
247
+ assert.deepEqual(extractTopicKeys({ title: "###1 小节", content: "比例约 3.5/2.0,版本 v1.2.3" }), []);
248
+ assert.deepEqual(extractTopicKeys({ title: "普通记录", content: "没有任何锚点的一段话" }), []);
249
+ assert.deepEqual(extractTopicKeys({}), []);
250
+ });
251
+
252
+ test("计量失败不反噬写入(旁路,只 warn)", () => {
253
+ const store = createStore(":memory:");
254
+ const throwing = createService({
255
+ store,
256
+ mirror: null,
257
+ config: {},
258
+ writeAdmission: {
259
+ evaluate() { throw new Error("evaluate boom"); },
260
+ record() { throw new Error("record boom"); }
261
+ }
262
+ });
263
+ assert.equal(
264
+ throwing.saveWithDedupe({ _sessionKey: "s", type: "project", title: "T", content: "内容" }).action,
265
+ "created",
266
+ "evaluate 抛错不能挡下写入"
267
+ );
268
+
269
+ // evaluate 成功、record 抛错:写入照常返回
270
+ const real = createWriteAdmission({ store });
271
+ const halfBroken = createService({
272
+ store,
273
+ mirror: null,
274
+ config: {},
275
+ writeAdmission: { evaluate: real.evaluate, record() { throw new Error("record boom"); } }
276
+ });
277
+ assert.equal(
278
+ halfBroken.saveWithDedupe({ _sessionKey: "s", type: "project", title: "T2", content: "内容二" }).action,
279
+ "created",
280
+ "record 抛错不能反噬写入"
281
+ );
282
+ });
283
+
284
+ test("接线:memory_save 把会话键交给写入准入,缺会话身份的宿主落 null", async () => {
285
+ const store = createStore(":memory:");
286
+ const writeAdmission = createWriteAdmission({ store });
287
+ const service = createService({ store, mirror: null, config: {}, writeAdmission });
288
+ const registered = [];
289
+ createTools({ tools: { register(def) { registered.push(def); return () => {}; } } }, service, {}, null);
290
+ const save = registered.find((t) => t.name === "memory_save");
291
+ assert.ok(save, "memory_save 应已注册");
292
+
293
+ await save.execute(
294
+ { type: "project", title: "T", content: "记录 #254" },
295
+ { agent: { session: { id: "sess-9", header: { agentPreset: "coder", cwd: "D:\\proj" } } } }
296
+ );
297
+ assert.equal(store.countLlmAudits({ sessionKey: "sess-9" }), 1, "工具层要真的把会话键传下去");
298
+
299
+ await save.execute({ type: "project", title: "T2", content: "无会话身份" }, {});
300
+ assert.equal(store.countLlmAudits(), 1, "缺会话身份时既不记行也不报错");
301
+ });
302
+
303
+ test("旧库(llm_audit_logs 无 session_key)打开自动补列,准入行照常落", () => {
304
+ const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-admission-"));
305
+ const dbPath = join(dir, "memory.db");
306
+ try {
307
+ // 用裸 DatabaseSync 构造带 #254 之前表结构的旧库
308
+ const old = new DatabaseSync(dbPath);
309
+ old.exec(`
310
+ CREATE TABLE llm_audit_logs (
311
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
312
+ timestamp TEXT NOT NULL,
313
+ trigger_source TEXT NOT NULL,
314
+ operation_type TEXT NOT NULL,
315
+ model_id TEXT NOT NULL,
316
+ input_tokens INTEGER NOT NULL DEFAULT 0,
317
+ output_tokens INTEGER NOT NULL DEFAULT 0,
318
+ total_tokens INTEGER NOT NULL DEFAULT 0,
319
+ cost_usd REAL NOT NULL DEFAULT 0,
320
+ duration_ms INTEGER NOT NULL DEFAULT 0,
321
+ status TEXT NOT NULL,
322
+ error_message TEXT,
323
+ related_memory_ids TEXT,
324
+ metadata TEXT
325
+ );
326
+ INSERT INTO llm_audit_logs (timestamp, trigger_source, operation_type, model_id, status, metadata)
327
+ VALUES ('2026-01-01T00:00:00.000Z', 'autoDream', 'dream_consolidate', 'm', 'success', '{"old":1}');
328
+ `);
329
+ old.close();
330
+
331
+ let store = createStore(dbPath);
332
+ const cols = store.db.prepare("PRAGMA table_info(llm_audit_logs)").all().map((c) => c.name);
333
+ assert.ok(cols.includes("session_key"), "旧库打开必须自动补 session_key 列");
334
+ assert.equal(store.listLlmAudits().length, 1, "存量审计行保留");
335
+ assert.equal(store.listLlmAudits()[0].session_key, undefined, "存量行该列为空");
336
+
337
+ const admission = createWriteAdmission({ store });
338
+ const memory = { title: "开工", content: "#254 的准入" };
339
+ admission.record({
340
+ sessionKey: "s1",
341
+ verdict: admission.evaluate({ memory, sessionKey: "s1" }),
342
+ memoryId: "m1"
343
+ });
344
+ assert.equal(store.countLlmAudits({ sessionKey: "s1" }), 1, "迁移后的库上准入行可落可查");
345
+
346
+ // 重复打开幂等:列不重复、既有行不丢
347
+ store.close();
348
+ store = createStore(dbPath);
349
+ assert.equal(store.countLlmAudits({ sessionKey: "s1" }), 1, "重复打开不丢数据");
350
+ assert.equal(store.countLlmAudits(), 2, "既有的 LLM 审计行与准入行都在");
351
+ store.close();
352
+ } finally {
353
+ rmSync(dir, { recursive: true, force: true });
354
+ }
355
+ });
356
+
357
+ // --- 第 1 级确定性拒绝(#254 第二阶段) ----------------------------------------
358
+ // 判据面按 09-21 / 09-22 两轮收窄到两类:空白 / 纯噪声 + 注入进来的密钥 / PII。
359
+ // 去重键命中**不**在这里(归 write-update 放行),G1/G2 阈值也只是计量。
360
+ //
361
+ // 两个开关都默认关,所以这一组测试的第一件事是证明「关掉时逐字段等于只计量那一
362
+ // 阶段」。拦截面本身要证明三件事:拒了(真的没落库)、可解释(审计行里有 reason)、
363
+ // 不误杀(正常文本一条不动)。
364
+
365
+ function level1Store(opts = {}) {
366
+ const { enabled = false, enforce = false, sensitiveScan = null, now } = opts;
367
+ const store = createStore(":memory:");
368
+ const writeAdmission = createWriteAdmission({
369
+ store,
370
+ now,
371
+ config: { writeAdmission: { enabled, enforce } },
372
+ sensitiveScan
373
+ });
374
+ const service = createService({ store, mirror: null, config: {}, writeAdmission });
375
+ return { store, service, writeAdmission };
376
+ }
377
+
378
+ /** 落库行数(含归档 / 遗忘:被拒的写入一条都不该多)。 */
379
+ function rowCount(store) {
380
+ return store.list({ limit: 500, includeArchived: true, includeForgotten: true }).length;
381
+ }
382
+
383
+ test("信息量判据:blank 与 noise 分开报,正常文本不命中", () => {
384
+ assert.equal(informationlessReason({ title: "", content: "" }), "blank");
385
+ assert.equal(informationlessReason({ title: " ", content: "\n\t " }), "blank", "纯空白折掉之后什么都没写");
386
+ assert.equal(informationlessReason({ title: "标题", content: "" }), null, "有一边有内容就不是空写入");
387
+ assert.equal(informationlessReason({ title: "!!!", content: "--- ..." }), "noise", "只有标点 = 归一化后什么都不剩");
388
+ assert.equal(informationlessReason({ title: "a", content: "..." }), null, "只剩一个字母也算有信息");
389
+ // 两种 reason 分开报是为了可解释:blank 是提交了空表单,noise 是只填了标点,修法不同。
390
+ assert.notEqual(ADMISSION_DENY_BLANK, ADMISSION_DENY_NOISE);
391
+ });
392
+
393
+ test("默认关:enabled 关时判据根本不跑,连 enforce 开着也没用", () => {
394
+ const { store, service } = level1Store({ enabled: false, enforce: true });
395
+ const result = service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "", content: " " });
396
+ assert.equal(result.action, "created", "判据没开就不该有任何拒绝分支");
397
+ assert.equal(rowCount(store), 1);
398
+ const rows = admissionRows(store, "s");
399
+ assert.equal(rows.length, 1, "只计量那一阶段的行为逐字段保留");
400
+ // 验收第 1 条是「默认路径逐字节一致」,所以这里断言的是**整个 metadata**与只计量
401
+ // 阶段相同:连一个恒为 "allow" 的 decision 键都不该多出来。多一个键就意味着
402
+ // 「默认关零行为变化」从事实变成了需要解释的说法。
403
+ assert.deepEqual(rows[0].metadata, { gate: "g1", topics: [] });
404
+ assert.equal(rows[0].metadata.deny, undefined, "判据没跑就不该有拒绝面");
405
+ });
406
+
407
+ test("仅告警档:enabled 开、enforce 关 → 写入成功,审计行带拒绝面", () => {
408
+ const { store, service } = level1Store({ enabled: true, enforce: false });
409
+ const result = service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "!!", content: "..." });
410
+ assert.equal(result.action, "created", "只开检测不拦写入");
411
+ assert.equal(rowCount(store), 1);
412
+ const rows = admissionRows(store, "s");
413
+ assert.equal(rows.length, 1);
414
+ assert.equal(rows[0].metadata.decision, "allow", "仅告警档的决策仍是放行");
415
+ assert.deepEqual(rows[0].metadata.deny, { reason: "noise", enforced: false });
416
+ // 「deny 非空但 decision=allow」就是仅告警档的指纹,不必再读配置才能解释这一行。
417
+ });
418
+
419
+ test("硬拒:enabled + enforce → 不落库、返回 reason、审计行标 enforced", () => {
420
+ const { store, service } = level1Store({ enabled: true, enforce: true });
421
+ const result = service.saveWithDedupe({ _sessionKey: "s", type: "project", title: " ", content: "" });
422
+ assert.equal(result.action, "denied");
423
+ assert.equal(result.reason, "blank");
424
+ assert.equal(result.memory, null, "被拒的写入没有行可返回");
425
+ assert.equal(rowCount(store), 0, "store.save 在拒绝时根本没有被调用");
426
+
427
+ const rows = admissionRows(store, "s");
428
+ assert.equal(rows.length, 1, "拒绝面必须留审计(验收第 2 条:可解释、不静默丢弃)");
429
+ assert.equal(rows[0].related_memory_ids.length, 0, "没有产生任何行,不能挂一个不存在的 id");
430
+ assert.equal(rows[0].metadata.decision, "deny");
431
+ assert.deepEqual(rows[0].metadata.deny, { reason: "blank", enforced: true });
432
+
433
+ // 标题有内容就不是空写入:标题有信息(去重键就是它),归一化后非空不该被拒。
434
+ const titled = service.saveWithDedupe({ _sessionKey: "s2", type: "project", title: "标题", content: "" });
435
+ assert.equal(titled.action, "created", "title 有内容 = 有信息,不能判成 blank");
436
+ });
437
+
438
+ test("被拦下的写入不推进话题基准(它没进库,不能成为下一次 g2 的基准)", () => {
439
+ const { store, service } = level1Store({
440
+ enabled: true, enforce: true, sensitiveScan: referenceScan, now: () => 1_000_000
441
+ });
442
+ // 用密钥类而不是空白类来测这件事:空白/噪声行**不可能**带话题锚(锚是 `#254` /
443
+ // 路径这类含数字或字母的串,normalizeForHash 之后仍非空,所以它在第 1 级根本不
444
+ // 会命中)——这条回归只有在 sensitive 那一档才可复现,而密钥行通常也写着上下文。
445
+ const denied = service.saveWithDedupe({
446
+ _sessionKey: "s",
447
+ type: "project",
448
+ title: "见 #254",
449
+ content: `token 是 ${SAMPLE_VALUES.githubPat}`
450
+ });
451
+ assert.equal(denied.action, "denied");
452
+ assert.equal(denied.deny.kind, "github_token");
453
+ // 同一话题再来一条正常写入:如果被拒那条推进了基准,这里就会凭空多出一个 g2。
454
+ service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "记录", content: "#254 的正文" });
455
+ const g2 = admissionRows(store, "s").filter((r) => r.metadata.g2);
456
+ assert.equal(g2.length, 0, "拒绝行不能造出一条不存在的时间线");
457
+ assert.equal(rowCount(store), 1, "只有第二条真的落库了");
458
+ });
459
+
460
+ test("硬拒在 pinned 豁免之前:写着空内容的 constraint 一样被拒", () => {
461
+ // pinned 豁免的是**预算与冷却**(constraint / preference 是逐字保真池,不该被会话
462
+ // 预算拦),不是「这条能不能落库」。顺序反了就会出现「pinned 行绕过第 1 级」。
463
+ const { store, service } = level1Store({ enabled: true, enforce: true });
464
+ const result = service.saveWithDedupe({ _sessionKey: "s", type: "constraint", title: "...", content: "" });
465
+ assert.equal(result.action, "denied");
466
+ assert.equal(result.reason, "noise");
467
+ assert.equal(rowCount(store), 0);
468
+ assert.equal(admissionRows(store, "s")[0].metadata.deny.reason, "noise");
469
+ });
470
+
471
+ test("sensitiveScan 注入:正样本一条都不漏放(密钥 + PII)", () => {
472
+ const { store, service } = level1Store({ enabled: true, enforce: true, sensitiveScan: referenceScan });
473
+ for (const sample of POSITIVE_SAMPLES) {
474
+ const result = service.saveWithDedupe({
475
+ _sessionKey: `sess-${sample.id}`,
476
+ type: "project",
477
+ title: sample.title,
478
+ content: sample.content
479
+ });
480
+ assert.equal(result.action, "denied", `${sample.id} 被漏放(${sample.why})`);
481
+ assert.equal(result.reason, "sensitive", `${sample.id} 的 reason 该是 sensitive`);
482
+ const deny = admissionRows(store, `sess-${sample.id}`)[0].metadata.deny;
483
+ assert.equal(deny.kind, sample.kind, `${sample.id} 的 kind 要落进审计,enforce 前的分布才可按类看`);
484
+ assert.equal(deny.enforced, true);
485
+ }
486
+ assert.equal(rowCount(store), 0, "正样本一条都不该落库");
487
+ });
488
+
489
+ test("sensitiveScan 注入:负样本一条都不误杀(含敏感词但没有值)", () => {
490
+ const { store, service } = level1Store({ enabled: true, enforce: true, sensitiveScan: referenceScan });
491
+ for (const sample of NEGATIVE_SAMPLES) {
492
+ const result = service.saveWithDedupe({
493
+ _sessionKey: `sess-${sample.id}`,
494
+ type: "project",
495
+ title: sample.title,
496
+ content: sample.content
497
+ });
498
+ assert.equal(result.action, "created", `${sample.id} 被误杀(${sample.why})`);
499
+ }
500
+ assert.equal(rowCount(store), NEGATIVE_SAMPLES.length, "负样本应当全部落库");
501
+ // 负样本一条 deny 面都不该有——误杀面是这条闸门唯一会伤人的地方。
502
+ for (const sample of NEGATIVE_SAMPLES) {
503
+ for (const row of admissionRows(store, `sess-${sample.id}`)) {
504
+ assert.equal(row.metadata.deny, undefined, `${sample.id} 不该带任何拒绝面`);
505
+ }
506
+ }
507
+ });
508
+
509
+ test("sensitiveScan 抛错按「未命中」处理:判据故障不能让写入变成不可用", () => {
510
+ const { store, service } = level1Store({
511
+ enabled: true,
512
+ enforce: true,
513
+ sensitiveScan() { throw new Error("scanner boom"); }
514
+ });
515
+ const result = service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "正常", content: "正常正文" });
516
+ assert.equal(result.action, "created", "扫描器坏了必须放行,不能把所有写入拦死");
517
+ assert.equal(rowCount(store), 1);
518
+ });
519
+
520
+ test("命中且 enforce 时不发候选集查询(决策已定,白花成本)", () => {
521
+ const { store, service } = level1Store({ enabled: true, enforce: true });
522
+ // 先落一条同内容(走正常开关),再做一次同内容写入:若还查候选集,就会带上 dup。
523
+ store.save({ type: "project", title: "已存在", content: "已存在的正文" });
524
+ const result = service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "...", content: "" });
525
+ assert.equal(result.action, "denied");
526
+ const rows = admissionRows(store, "s");
527
+ assert.equal(rows.length, 1, "只该有拒绝那一行");
528
+ assert.equal(rows[0].metadata.dup, undefined, "拒绝路径不查候选集");
529
+ });
530
+
531
+ test("接线:被拒时 memory_save 返回 action=denied 与可行动的 reason", async () => {
532
+ const store = createStore(":memory:");
533
+ const writeAdmission = createWriteAdmission({
534
+ store,
535
+ config: { writeAdmission: { enabled: true, enforce: true } }
536
+ });
537
+ const service = createService({ store, mirror: null, config: {}, writeAdmission });
538
+ const registered = [];
539
+ createTools({ tools: { register(def) { registered.push(def); return () => {}; } } }, service, {}, null);
540
+ const save = registered.find((t) => t.name === "memory_save");
541
+
542
+ const denied = await save.execute(
543
+ { type: "project", title: "...", content: "" },
544
+ { agent: { session: { id: "sess-deny" } } }
545
+ );
546
+ assert.equal(denied.action, "denied");
547
+ assert.equal(denied.reason, "noise");
548
+ assert.equal(denied.id, undefined, "没有落库就不该编一个 id 出来");
549
+ assert.equal(store.countLlmAudits({ sessionKey: "sess-deny" }), 1, "工具层拒绝也要留审计");
550
+
551
+ // 正常写入不受影响,返回形状与既有调用方一致
552
+ const ok = await save.execute(
553
+ { type: "project", title: "正常标题", content: "正常正文内容" },
554
+ { agent: { session: { id: "sess-deny" } } }
555
+ );
556
+ assert.equal(ok.action, "created");
557
+ assert.ok(ok.id, "正常路径仍然返回 id");
558
+ });
559
+
560
+ // --- #164 A2 接线:判据自己的闸 × 闸门的 enforce -------------------------------
561
+ // 这一组是 index.js 那条装配路径的等价物:真 config(Config({}))+ createSensitiveScan
562
+ // 工厂 + 写入准入。三层开关的语义由此钉住——判据关(默认)不参与、判据开而 enforce 关
563
+ // 只留审计、两个都开才拦。判据本身的假阳/漏放验收在 test/sensitive-scan.test.js。
564
+
565
+ /** 用真 `Config` 解析默认值,再叠加测试要覆盖的那几个键。 */
566
+ function a2Store(overrides = {}) {
567
+ const cfg = Config(overrides);
568
+ const store = createStore(":memory:");
569
+ const writeAdmission = createWriteAdmission({
570
+ store,
571
+ config: cfg,
572
+ sensitiveScan: createSensitiveScan({ config: cfg })
573
+ });
574
+ const service = createService({ store, mirror: null, config: cfg, writeAdmission });
575
+ return { store, service, cfg };
576
+ }
577
+
578
+ const SECRET_ROW = {
579
+ type: "project",
580
+ title: "调试记录",
581
+ content: `临时代码里贴了 ${SAMPLE_VALUES.githubPat},回头删掉`
582
+ };
583
+
584
+ test("A2 默认关:写有密钥的行照常落库,且连拒绝面都不该出现", () => {
585
+ const { store, service } = a2Store();
586
+ const result = service.saveWithDedupe({ ...SECRET_ROW, _sessionKey: "s" });
587
+ assert.equal(result.action, "created", "判据默认关时这条路径与 #332 逐字段一致");
588
+ assert.equal(rowCount(store), 1);
589
+ // metadata 里不该多出任何 deny/decision 键——「默认关零行为变化」是验收第 1 条,
590
+ // 多一个恒为 allow 的键就把它从事实变成需要解释的说法(同上面 #254 那条)。
591
+ const row = admissionRows(store, "s")[0];
592
+ assert.equal(row.metadata.deny, undefined);
593
+ assert.equal(row.metadata.decision, undefined);
594
+ });
595
+
596
+ test("A2 观察档:判据开、enforce 关 → 写入照常,审计行带 kind 与 enforced=false", () => {
597
+ const { store, service } = a2Store({ sensitiveScanEnabled: true, writeAdmission: { enabled: true } });
598
+ const result = service.saveWithDedupe({ ...SECRET_ROW, _sessionKey: "s" });
599
+ assert.equal(result.action, "created", "#164 口径:默认仅告警");
600
+ assert.equal(rowCount(store), 1);
601
+ const deny = admissionRows(store, "s")[0].metadata.deny;
602
+ assert.equal(deny.reason, "sensitive");
603
+ assert.equal(deny.kind, "github_token");
604
+ assert.equal(deny.enforced, false, "仅告警档的指纹:deny 非空、enforced=false、写入仍发生");
605
+ });
606
+
607
+ test("A2 拦截档:判据开 + enforce 开 → 拒了、没落库、审计标 enforced", () => {
608
+ const { store, service } = a2Store({
609
+ sensitiveScanEnabled: true,
610
+ writeAdmission: { enabled: true, enforce: true }
611
+ });
612
+ const result = service.saveWithDedupe({ ...SECRET_ROW, _sessionKey: "s" });
613
+ assert.equal(result.action, "denied");
614
+ assert.equal(result.reason, "sensitive");
615
+ assert.equal(rowCount(store), 0, "被拒的写入一条都不该落库");
616
+ const deny = admissionRows(store, "s")[0].metadata.deny;
617
+ assert.equal(deny.kind, "github_token");
618
+ assert.equal(deny.enforced, true);
619
+ });
620
+
621
+ test("A2 与第 1 级空白判据互不依赖:只开 A2 不会把空白写入拦下", () => {
622
+ // 两个键是两批判据的闸(空白/噪声 vs 密钥/PII),分开是为了能单独观察 A2 的
623
+ // 命中分布——绑在一个开关上就没法只看这一类的假阳率。
624
+ const { store, service } = a2Store({ sensitiveScanEnabled: true });
625
+ const blank = service.saveWithDedupe({ _sessionKey: "s", type: "project", title: "...", content: "" });
626
+ assert.equal(blank.action, "created", "A2 开着不等于第 1 级判据也开");
627
+ assert.equal(admissionRows(store, "s")[0].metadata.deny, undefined);
628
+ // 反过来:只开第 1 级(writeAdmission.enabled)、A2 关,密钥行照常落库。
629
+ const { store: s2, service: svc2 } = a2Store({ writeAdmission: { enabled: true } });
630
+ assert.equal(svc2.saveWithDedupe({ ...SECRET_ROW, _sessionKey: "s" }).action, "created");
631
+ assert.equal(s2.list({ limit: 10 }).length, 1);
632
+ });
633
+
634
+ test("A2 跑在闸门里:只开 sensitiveScanEnabled(闸门关)时扫描器根本不参与", () => {
635
+ // 判据的唯一调用点是 write-admission 的 firstLevelHit,而闸门要
636
+ // writeAdmission.enabled 打开才走第 1 级判据。所以「本键开着」不等于「会扫」——
637
+ // 配置注释与面板文案都按这条写,否则用户以为单开本键就能拿到 A2 的命中分布。
638
+ const { store, service } = a2Store({ sensitiveScanEnabled: true });
639
+ const result = service.saveWithDedupe({ ...SECRET_ROW, _sessionKey: "s" });
640
+ assert.equal(result.action, "created", "闸门没开时 A2 不参与判定");
641
+ assert.equal(rowCount(store), 1, "写入照常落库");
642
+ const rows = admissionRows(store, "s");
643
+ assert.ok(rows.length > 0, "闸门仍走计量路径(去重 / g2 不受第 1 级开关影响)");
644
+ assert.ok(rows.every((row) => row.metadata.deny === undefined), "一条 deny 审计都没有:第 1 级判据整体没跑");
645
+ });
646
+
647
+ test("A2 负样本在真装配下一条都不误杀(含敏感词但没有值)", () => {
648
+ const { store, service } = a2Store({
649
+ sensitiveScanEnabled: true,
650
+ writeAdmission: { enabled: true, enforce: true }
651
+ });
652
+ for (const sample of NEGATIVE_SAMPLES) {
653
+ const result = service.saveWithDedupe({
654
+ _sessionKey: `sess-${sample.id}`,
655
+ type: "project",
656
+ title: sample.title,
657
+ content: sample.content
658
+ });
659
+ assert.equal(result.action, "created", `${sample.id} 被误杀(${sample.why})`);
660
+ }
661
+ assert.equal(rowCount(store), NEGATIVE_SAMPLES.length);
662
+ for (const sample of NEGATIVE_SAMPLES) {
663
+ for (const row of admissionRows(store, `sess-${sample.id}`)) {
664
+ assert.equal(row.metadata.deny, undefined, `${sample.id} 不该带任何拒绝面`);
665
+ }
666
+ }
667
+ });
668
+