@jjh32456/dsh-longlive 1.6.7 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (289) hide show
  1. package/CHANGELOG.md +70 -1508
  2. package/LICENSE +1 -1
  3. package/QUICKSTART.md +34 -13
  4. package/README.en.md +18 -7
  5. package/README.md +34 -9
  6. package/SPEC.md +5 -5
  7. package/cordis.patch.yml +23 -9
  8. package/docs/ANSWER-GUARD.md +92 -0
  9. package/docs/ARCHITECTURE.md +4 -3
  10. package/docs/AUTO-RECALL-WIRING.md +1 -1
  11. package/docs/AUTO-RECALL.md +15 -9
  12. package/docs/CONFIG.md +168 -35
  13. package/docs/ERRORS.md +17 -5
  14. package/docs/FAQ.md +7 -7
  15. package/docs/FEATURES.md +96 -28
  16. package/docs/MIGRATION-1.6.0.md +2 -2
  17. package/docs/OPERATIONS.md +6 -6
  18. package/docs/PANEL-INTEGRATION.md +6 -0
  19. package/docs/PRIVACY.md +88 -0
  20. package/docs/QUICKSTART.md +5 -5
  21. package/docs/README.en.md +4 -2
  22. package/docs/RESIDENT-WATCHER.md +1 -1
  23. package/docs/SESSION-WATCH.md +6 -6
  24. package/docs/TROUBLESHOOTING.md +4 -4
  25. package/package.json +47 -117
  26. package/runbook.md +2 -2
  27. package/scripts/backfill-auto-cards.mjs +98 -0
  28. package/scripts/install-surgery-task.ps1 +5 -2
  29. package/scripts/lib/session-watch-fixture.mjs +1 -1
  30. package/scripts/longlive-doctor.mjs +6 -0
  31. package/scripts/migrate-180.mjs +163 -0
  32. package/scripts/recall-index-build.mjs +128 -0
  33. package/scripts/session-watch-drill.mjs +1 -1
  34. package/scripts/surgery-run.cmd +1 -1
  35. package/scripts/unattended-surgery.ps1 +5 -2
  36. package/scripts/verify-card-anchors.mjs +138 -0
  37. package/src/actions/audit.js +125 -0
  38. package/src/actions/evidence.js +122 -0
  39. package/src/actions/fs.js +162 -0
  40. package/src/activity/index.js +760 -0
  41. package/src/activity/model.js +351 -0
  42. package/src/activity/store.js +644 -0
  43. package/src/activity/thinking.js +176 -0
  44. package/src/adapters/constants.js +11 -0
  45. package/src/adapters/engram/data/aliases.json +51 -0
  46. package/src/adapters/engram/data/policy.json +8 -0
  47. package/src/adapters/engram/data/routing-rules.json +546 -0
  48. package/src/adapters/engram/index.js +235 -0
  49. package/src/adapters/registry.js +315 -0
  50. package/src/answer/audit.js +193 -0
  51. package/src/answer/evidence.js +30 -0
  52. package/src/answer/guard.js +1370 -0
  53. package/src/answer/index.js +1067 -0
  54. package/src/answer/query.js +111 -0
  55. package/src/answer/second-hop.mjs +101 -0
  56. package/src/auto-truncate.js +1 -1
  57. package/src/config.js +171 -47
  58. package/src/deps.js +6 -4
  59. package/src/features.js +182 -6
  60. package/src/heartbeat.js +156 -23
  61. package/src/identity/handover.js +52 -1
  62. package/src/identity/index.js +13 -5
  63. package/src/identity/inject-position.js +2 -2
  64. package/src/index.js +97 -7
  65. package/src/lifecycle/gap-clocks.js +328 -0
  66. package/src/lifecycle/index.js +269 -0
  67. package/src/live.js +22 -2
  68. package/src/memory/activity-capture.js +229 -0
  69. package/src/memory/anchors.js +194 -0
  70. package/src/memory/audit-records.js +222 -0
  71. package/src/memory/auto-cards.js +401 -0
  72. package/src/memory/capture.js +442 -0
  73. package/src/memory/card-anchors.js +203 -0
  74. package/src/memory/conflicts.js +106 -0
  75. package/src/memory/decay.js +311 -0
  76. package/src/memory/distill.js +471 -0
  77. package/src/memory/event-index.js +917 -0
  78. package/src/memory/evidence-chain.js +295 -0
  79. package/src/memory/evidence.js +145 -0
  80. package/src/memory/filter-runner.js +1 -1
  81. package/src/memory/filter.js +5 -1
  82. package/src/memory/forget-v2.js +410 -0
  83. package/src/memory/forget.js +71 -0
  84. package/src/memory/frames-activation.js +247 -0
  85. package/src/memory/fts-query.js +174 -0
  86. package/src/memory/governance.js +604 -0
  87. package/src/memory/index.js +1 -0
  88. package/src/memory/l2-gate.js +169 -0
  89. package/src/memory/lock.js +121 -0
  90. package/src/memory/migrate.js +139 -0
  91. package/src/memory/recall-v2.js +223 -0
  92. package/src/memory/recall.js +65 -2
  93. package/src/memory/records.js +425 -0
  94. package/src/memory/retention.js +128 -0
  95. package/src/memory/review.js +124 -0
  96. package/src/memory/trust.js +88 -0
  97. package/src/memory/version-chain.js +264 -0
  98. package/src/memory/wal.js +137 -0
  99. package/src/memory/working-frame.js +516 -0
  100. package/src/metrics/classify.mjs +5 -3
  101. package/src/metrics/index.mjs +21 -1
  102. package/src/metrics/prestep-latency.mjs +300 -0
  103. package/src/metrics/store.mjs +25 -0
  104. package/src/policy-profile.js +18 -3
  105. package/src/recall/anaphora.mjs +87 -0
  106. package/src/recall/cards.mjs +7 -0
  107. package/src/recall/embedding.mjs +8 -8
  108. package/src/recall/entities.mjs +112 -0
  109. package/src/recall/index.js +235 -19
  110. package/src/recall/inject-order.mjs +10 -8
  111. package/src/recall/privacy.mjs +105 -16
  112. package/src/recall/recall-core.mjs +116 -10
  113. package/src/recall/shared-visibility.js +302 -0
  114. package/src/recall/time-normalizer.mjs +196 -0
  115. package/src/role/activation.js +191 -0
  116. package/src/role/attention.js +318 -0
  117. package/src/role/base.js +36 -0
  118. package/src/role/channels.js +423 -0
  119. package/src/role/index.js +425 -0
  120. package/src/role/interfaces.js +115 -0
  121. package/src/role/personality.js +370 -0
  122. package/src/role/readers.js +151 -0
  123. package/src/role/relationships.js +477 -0
  124. package/src/role/store.js +202 -0
  125. package/src/role/writeprotect.js +373 -0
  126. package/src/router/aliases.json +176 -25
  127. package/src/router/background/agent-runner.mjs +1 -1
  128. package/src/router/background/anchor.mjs +1 -1
  129. package/src/router/background/audit.mjs +2 -2
  130. package/src/router/background/budget-ledger.mjs +2 -2
  131. package/src/router/background/host-sim.mjs +1 -1
  132. package/src/router/background/memory.mjs +4 -4
  133. package/src/router/background/queue.mjs +1 -1
  134. package/src/router/background/retry.mjs +1 -1
  135. package/src/router/background/router.mjs +1 -1
  136. package/src/router/background/stream.mjs +1 -1
  137. package/src/router/background/tool-policy.mjs +1 -1
  138. package/src/router/design-headings.json +5 -5
  139. package/src/router/face/config.mjs +1 -1
  140. package/src/router/face/identity.mjs +3 -3
  141. package/src/router/judge.mjs +8 -7
  142. package/src/router/policies/generic.json +21 -4
  143. package/src/router/route.js +109 -22
  144. package/src/router/router.mjs +10 -6
  145. package/src/router/routing-rules.json +260 -445
  146. package/src/router/run-sim.mjs +8 -8
  147. package/src/router/runtime.js +23 -12
  148. package/src/router/service/board.mjs +95 -15
  149. package/src/router/service/config.mjs +5 -5
  150. package/src/router/service/context.mjs +1 -1
  151. package/src/router/service/hint.mjs +14 -0
  152. package/src/router/service/queue.mjs +1 -1
  153. package/src/router/service/service.mjs +27 -11
  154. package/src/router/shadow.mjs +4 -3
  155. package/src/surgery-recovery.js +2 -2
  156. package/src/tool-output.js +2 -2
  157. package/src/tools.js +39 -7
  158. package/src/truncate-visible.js +2 -2
  159. package/src/ui/actions.js +1312 -0
  160. package/src/ui/panel-api.js +367 -0
  161. package/src/ui/panel-auth.js +251 -0
  162. package/src/ui/panel-integrity.js +243 -0
  163. package/src/ui/routes-readonly.js +695 -0
  164. package/src/ui/routes.js +3 -0
  165. package/docs/DEV.md +0 -279
  166. package/docs/PRESENCE-BUS-WIRING.md +0 -491
  167. package/docs/PRESENCE-BUS.md +0 -694
  168. package/docs/SURGERY.md +0 -186
  169. package/scripts/mark-core-cards.mjs +0 -289
  170. package/scripts/pack.mjs +0 -392
  171. package/scripts/router-proposals.mjs +0 -386
  172. package/src/router/fixtures/M1.json +0 -16
  173. package/src/router/fixtures/M2.json +0 -16
  174. package/src/router/fixtures/M3.json +0 -20
  175. package/src/router/fixtures/M4.json +0 -20
  176. package/src/router/fixtures/M5.json +0 -45
  177. package/src/router/fixtures/M6.json +0 -53
  178. package/src/router/fixtures/M7.json +0 -46
  179. package/src/router/fixtures/S1.json +0 -20
  180. package/src/router/fixtures/S2.json +0 -23
  181. package/src/router/fixtures/S3.json +0 -20
  182. package/src/router/fixtures/S4.json +0 -34
  183. package/src/router/fixtures/S5.json +0 -43
  184. package/src/router/fixtures/S6.json +0 -47
  185. package/src/router/fixtures/T1.json +0 -23
  186. package/src/router/fixtures/T2.json +0 -16
  187. package/src/router/fixtures/T3.json +0 -19
  188. package/src/router/fixtures/T4.json +0 -16
  189. package/src/router/fixtures/X1.json +0 -17
  190. package/src/router/fixtures/X2.json +0 -20
  191. package/src/router/gen-design-headings.mjs +0 -107
  192. package/src/router/policies/owner-rulings-2026-11-03.json +0 -62
  193. package/test/auto-recall.test.mjs +0 -474
  194. package/test/auto-truncate.test.mjs +0 -931
  195. package/test/autobind.test.mjs +0 -385
  196. package/test/bus.test.mjs +0 -1572
  197. package/test/cli-guard.test.mjs +0 -198
  198. package/test/conclusion.test.mjs +0 -408
  199. package/test/config-schema.test.mjs +0 -204
  200. package/test/d6-output.test.mjs +0 -194
  201. package/test/d6.test.mjs +0 -395
  202. package/test/deps.test.mjs +0 -342
  203. package/test/doctor-setup.test.mjs +0 -297
  204. package/test/embedding-recall.test.mjs +0 -347
  205. package/test/feature-flags.test.mjs +0 -568
  206. package/test/filter-shadow.test.mjs +0 -170
  207. package/test/fixtures/baseline-0.8.0-registration.json +0 -556
  208. package/test/fixtures/d6-header-tools.json +0 -1
  209. package/test/fixtures/lock-child.mjs +0 -67
  210. package/test/fixtures/pre-step-container.mjs +0 -100
  211. package/test/fixtures/privacy-memory/topics/pii-unmarked.md +0 -9
  212. package/test/fixtures/privacy-memory/topics/secret-local-only.md +0 -11
  213. package/test/fixtures/profile-session-longlive-config.json +0 -20
  214. package/test/fixtures/proposal-cas-child.mjs +0 -66
  215. package/test/host-engine-status.test.mjs +0 -307
  216. package/test/hot-truncate.test.mjs +0 -1349
  217. package/test/identity-card-marker.test.mjs +0 -307
  218. package/test/identity-router/run.mjs +0 -721
  219. package/test/integration-pre-step.test.mjs +0 -553
  220. package/test/live.test.mjs +0 -1020
  221. package/test/maintenance-schedule.test.mjs +0 -538
  222. package/test/memory.test.mjs +0 -2828
  223. package/test/metrics.test.mjs +0 -478
  224. package/test/orchestrator.test.mjs +0 -266
  225. package/test/p1_probe.mjs +0 -97
  226. package/test/packaging.test.mjs +0 -182
  227. package/test/portability.test.mjs +0 -343
  228. package/test/rc4-regressions.test.mjs +0 -562
  229. package/test/rc5-regressions.test.mjs +0 -197
  230. package/test/rc6-chinese.test.mjs +0 -394
  231. package/test/router-1100/apply-off.test.mjs +0 -175
  232. package/test/router-1100/budget-reconcile.test.mjs +0 -215
  233. package/test/router-1100/config-context.test.mjs +0 -211
  234. package/test/router-1100/helpers.mjs +0 -129
  235. package/test/router-1100/hint-envelope.test.mjs +0 -150
  236. package/test/router-1100/lock-crossproc.test.mjs +0 -206
  237. package/test/router-1100/proposal-cas-crossproc.test.mjs +0 -184
  238. package/test/router-1100/router-apply.test.mjs +0 -372
  239. package/test/router-1100/router-fixb.test.mjs +0 -602
  240. package/test/router-1100/router-proposals.test.mjs +0 -405
  241. package/test/router-1100/router-queue-lease-wal.test.mjs +0 -355
  242. package/test/router-1100/router-security-cr.test.mjs +0 -333
  243. package/test/router-1100/service-lifecycle.test.mjs +0 -220
  244. package/test/router-1100/tools-contract.test.mjs +0 -164
  245. package/test/router-1100/turn-reflect-tick.test.mjs +0 -89
  246. package/test/router-1100/v6-contract.test.mjs +0 -158
  247. package/test/router-1100/v6b-contract.test.mjs +0 -293
  248. package/test/router-1100/v6c-contract.test.mjs +0 -249
  249. package/test/router-1100/wal-rotation.test.mjs +0 -375
  250. package/test/router-1100/wp1-config.test.mjs +0 -170
  251. package/test/router-1100/wp1-default-off.test.mjs +0 -77
  252. package/test/router-1100/wp1-model-status.test.mjs +0 -90
  253. package/test/router-1100/wp1-status.test.mjs +0 -220
  254. package/test/router-1100/wp1-tools.test.mjs +0 -146
  255. package/test/router-1100/wp5-cli.test.mjs +0 -240
  256. package/test/router-1100/wp5-model-auto.test.mjs +0 -418
  257. package/test/router-1100/wp6-merge.test.mjs +0 -386
  258. package/test/router-1100/wp6-wake.test.mjs +0 -502
  259. package/test/router-background/audit.test.mjs +0 -37
  260. package/test/router-background/budget-ledger.test.mjs +0 -330
  261. package/test/router-background/cross-process.test.mjs +0 -222
  262. package/test/router-background/fixtures/cross-child.mjs +0 -85
  263. package/test/router-background/fixtures/real-shapes.mjs +0 -239
  264. package/test/router-background/router.test.mjs +0 -1044
  265. package/test/router-background/stream-retry.test.mjs +0 -358
  266. package/test/router-background/tool-agent.test.mjs +0 -384
  267. package/test/router-face/bridge.test.mjs +0 -169
  268. package/test/router-face/face.test.mjs +0 -316
  269. package/test/router-failclosed.test.mjs +0 -466
  270. package/test/router-identity.test.mjs +0 -384
  271. package/test/router-l1-l2.contract.test.mjs +0 -1075
  272. package/test/router-memory-tools.test.mjs +0 -262
  273. package/test/router-policy.test.mjs +0 -225
  274. package/test/router-runtime.test.mjs +0 -438
  275. package/test/router-shadow.test.mjs +0 -287
  276. package/test/router.test.mjs +0 -903
  277. package/test/run-rc.mjs +0 -299
  278. package/test/run.mjs +0 -2469
  279. package/test/safety-defaults.test.mjs +0 -179
  280. package/test/seeded-load.test.mjs +0 -436
  281. package/test/session-original.test.mjs +0 -338
  282. package/test/surgery-recovery.test.mjs +0 -936
  283. package/test/t7_drive.mjs +0 -151
  284. package/test/tool-output.test.mjs +0 -393
  285. package/test/tool-schema.test.mjs +0 -177
  286. package/test/tools_contract.test.mjs +0 -716
  287. package/test/truncate-visible.test.mjs +0 -1150
  288. package/test/ui-flags.test.mjs +0 -477
  289. package/test/v4-hardening.test.mjs +0 -427
@@ -11,6 +11,8 @@
11
11
 
12
12
  import { memoryError, memoryRootFromConfig } from './paths.js';
13
13
  import { scanCards } from './index.js';
14
+ import { readVerifiedAutoCards } from './auto-cards.js';
15
+ import { readVerifiedCardAnchors } from './card-anchors.js';
14
16
 
15
17
  export const RECALL_DEFAULT_TOPK = 3;
16
18
  export const RECALL_MAX_TOPK = 20;
@@ -176,6 +178,7 @@ export function memoryRecall(args, config = {}) {
176
178
  const root = memoryRootFromConfig(config);
177
179
 
178
180
  const scanned = scanCards(root);
181
+ const sidecar = readVerifiedCardAnchors({ memoryRoot: root }).links;
179
182
  const results = [];
180
183
  for (const card of scanned) {
181
184
  const status = card.fields?.status ?? 'active';
@@ -183,11 +186,19 @@ export function memoryRecall(args, config = {}) {
183
186
  if (room && !(card.rel === room || card.rel.startsWith(`${room}/`))) continue;
184
187
  const { score, hits } = scoreCard(query, card);
185
188
  if (score <= 0 || hits.length === 0) continue;
189
+ const link = sidecar.get(card.rel);
186
190
  results.push({
187
191
  path: card.rel,
188
192
  title: card.title,
189
193
  score,
190
194
  excerpt: excerptFor(query, card),
195
+ ...(link && Array.isArray(link.sourceEventIds) && link.sourceEventIds.length > 0
196
+ ? { sourceEventIds: link.sourceEventIds }
197
+ : {}),
198
+ ...(link && Array.isArray(link.anchors) && link.anchors.length > 0
199
+ ? { anchors: link.anchors }
200
+ : {}),
201
+ ...(link?.legacy === true ? { legacy: true } : {}),
191
202
  });
192
203
  }
193
204
 
@@ -195,7 +206,58 @@ export function memoryRecall(args, config = {}) {
195
206
  if (right.score !== left.score) return right.score - left.score;
196
207
  return left.path < right.path ? -1 : left.path > right.path ? 1 : 0;
197
208
  });
198
- const top = results.slice(0, topK);
209
+
210
+ // 1.8.0 two-layer recall: curated cards are weighted slightly higher and take
211
+ // at most 2 of topK; AutoCards supplement with at most 1. A curated hit
212
+ // suppresses an event card on the same topic.
213
+ const weighted = [...results].sort((left, right) => {
214
+ const l = left.score * 1.12;
215
+ const r = right.score * 1.12;
216
+ if (r !== l) return r - l;
217
+ return left.path < right.path ? -1 : left.path > right.path ? 1 : 0;
218
+ });
219
+ const manualCap = Math.min(2, topK);
220
+ const manualResults = weighted.slice(0, manualCap);
221
+ const autoCards = (room === null && statuses.includes('active'))
222
+ ? readVerifiedAutoCards({ memoryRoot: root })
223
+ : [];
224
+ const autoResults = [];
225
+ const autoCap = Math.min(1, Math.max(0, topK - manualResults.length));
226
+ if (autoCap > 0) {
227
+ for (const card of autoCards) {
228
+ const pseudo = {
229
+ title: card.title,
230
+ body: [card.title, Array.isArray(card.terms) ? card.terms.join(' ') : ''].filter(Boolean).join('\n'),
231
+ fields: { tags: [] },
232
+ };
233
+ const { score, hits } = scoreCard(query, pseudo);
234
+ if (score <= 0 || hits.length === 0) continue;
235
+ // Suppress the same-topic event card when a curated card already hit.
236
+ if (manualResults.length > 0) {
237
+ const eventText = `${card.title} ${(card.terms ?? []).join(' ')}`.toLowerCase();
238
+ const sameTopic = manualResults.some((item) => {
239
+ const title = String(item.title ?? '').toLowerCase();
240
+ const overlap = recallTokens(title).filter((token) => eventText.includes(token)).length;
241
+ return overlap >= Math.max(2, Math.floor(recallTokens(title).length * 0.5));
242
+ });
243
+ if (sameTopic) continue;
244
+ }
245
+ const anchor = card.anchor;
246
+ autoResults.push({
247
+ path: `auto:${card.cardId}`,
248
+ title: card.title,
249
+ score,
250
+ excerpt: collapse(card.title).slice(0, 120),
251
+ cardId: card.cardId,
252
+ eventId: card.eventId,
253
+ type: 'event',
254
+ anchor,
255
+ originalHint: `可读原文 session_original(turn=${anchor.turn})`,
256
+ });
257
+ if (autoResults.length >= autoCap) break;
258
+ }
259
+ }
260
+ const top = [...manualResults, ...autoResults].slice(0, topK);
199
261
 
200
262
  const base = {
201
263
  ok: true,
@@ -204,6 +266,7 @@ export function memoryRecall(args, config = {}) {
204
266
  room,
205
267
  statuses,
206
268
  scanned: scanned.length,
269
+ scannedEvents: autoCards.length,
207
270
  count: top.length,
208
271
  results: top,
209
272
  };
@@ -212,7 +275,7 @@ export function memoryRecall(args, config = {}) {
212
275
  ...base,
213
276
  status: 'empty',
214
277
  code: 'E_RECALL_NO_MATCH',
215
- message: `没有卡片命中「${query}」— 换更短/更具体的关键词,或放宽 room/status 过滤;本次扫描 ${scanned.length} 张卡`,
278
+ message: `没有卡片命中「${query}」— 换更短/更具体的关键词,或放宽 room/status 过滤;本次扫描 ${scanned.length} 张卡${autoCards.length > 0 ? `、${autoCards.length} 张事件卡` : ''}`,
216
279
  };
217
280
  }
218
281
  return { ...base, status: 'ok' };
@@ -0,0 +1,425 @@
1
+ // src/memory/records.js — M2-B 扩展 / v2.6.4 §14.1:records 数据治理最小集
2
+ //
3
+ // 每条机器断言(records.jsonl)在原有字段上增加:
4
+ // evidence[]{anchorId, sha256, excerptRef, verifiedAt} // 0..n,锚点引用
5
+ // sourceTier ∈ user|host|agent|plugin // 写入来源分层
6
+ // conflict/conflict_with[] // 同 subject 互斥断言标记
7
+ // state ∈ active|superseded|forgotten|archived|restored|unverified
8
+ //
9
+ // 铁律:
10
+ // * 旧 records 缺字段 → 读时默认 evidence=[] / sourceTier='agent' / state 由 status 推导,不崩;
11
+ // * 写时非法 sourceTier → 拒收 `E_SOURCE_TIER`(读时非法值降级 agent 并标记 tierDefaulted);
12
+ // * 同 subject 互斥(正反极性,或同 attribute 不同 value)→ 两条都标 conflict + conflict_with;
13
+ // 回忆候选返回 conflicts[],**不静默选一个**;
14
+ // * 读审计:每次 recall/evidence 调用写 `.longlive/recall/log.jsonl` 一行,含 recordId(行数=调用数)。
15
+ //
16
+ // 纯文件模块(不依赖 SQLite/宿主),可被 #17 只读路由与 M2-C 召回共用。
17
+
18
+ import crypto from 'node:crypto';
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+
22
+ import { isNegativeConclusion } from './cards.js';
23
+
24
+ export const RECORDS_SCHEMA = 'records/v1';
25
+ export const SOURCE_TIERS = Object.freeze(['user', 'host', 'agent', 'plugin']);
26
+ export const RECORD_STATES = Object.freeze(['active', 'superseded', 'forgotten', 'archived', 'restored', 'unverified']);
27
+ export const DEFAULT_SOURCE_TIER = 'agent';
28
+ export const AUDIT_SCHEMA = 'recall-log/v1';
29
+
30
+ export class RecordsError extends Error {
31
+ constructor(code, message) {
32
+ super(message);
33
+ this.name = 'RecordsError';
34
+ this.code = code;
35
+ }
36
+ }
37
+
38
+ export function recordsPathOf(memoryRoot) {
39
+ return path.join(path.resolve(String(memoryRoot ?? '')), '.longlive', 'records.jsonl');
40
+ }
41
+
42
+ export function recallLogPathOf(memoryRoot) {
43
+ return path.join(path.resolve(String(memoryRoot ?? '')), '.longlive', 'recall', 'log.jsonl');
44
+ }
45
+
46
+ function sha256Hex(text) {
47
+ return crypto.createHash('sha256').update(text, 'utf8').digest('hex');
48
+ }
49
+
50
+ /** R1 anchorId 规则(确定性、索引重建后不变)。 */
51
+ export function anchorIdOf(anchor) {
52
+ if (!anchor || typeof anchor !== 'object') return null;
53
+ if (anchor.kind === 'file_line') {
54
+ return `anc_${sha256Hex(`file|${anchor.rel}|${anchor.lineStart ?? 1}`).slice(0, 16)}`;
55
+ }
56
+ return `anc_${sha256Hex(`${anchor.sessionId ?? ''}|${anchor.turn ?? ''}|${anchor.seqStart ?? ''}`).slice(0, 16)}`;
57
+ }
58
+
59
+ /** Build evidence[] entries from anchors (0..n). */
60
+ export function evidenceFromAnchors(anchors, { verifiedAt = null } = {}) {
61
+ if (!Array.isArray(anchors)) return [];
62
+ return anchors
63
+ .filter((anchor) => anchor && typeof anchor === 'object')
64
+ .map((anchor) => ({
65
+ anchorId: anchor.anchorId ?? anchorIdOf(anchor),
66
+ sha256: anchor.digest ?? anchor.fileSha ?? null,
67
+ excerptRef: anchor.kind === 'file_line'
68
+ ? `file:${anchor.rel}:${anchor.lineStart ?? 1}-${anchor.lineEnd ?? anchor.lineStart ?? 1}`
69
+ : `session:${anchor.sessionId ?? ''}#turn${anchor.turn ?? ''}`,
70
+ verifiedAt,
71
+ }))
72
+ .filter((entry) => typeof entry.anchorId === 'string' && entry.anchorId !== '');
73
+ }
74
+
75
+ /** Write-path validation: invalid tier throws (no silent write). */
76
+ export function assertSourceTier(value, fallback = DEFAULT_SOURCE_TIER) {
77
+ if (value === undefined || value === null || value === '') return fallback;
78
+ if (!SOURCE_TIERS.includes(value)) throw new RecordsError('E_SOURCE_TIER', `sourceTier 非法:${String(value)}(允许 ${SOURCE_TIERS.join('|')})`);
79
+ return value;
80
+ }
81
+
82
+ /** Read-path normalization: never throws, defaults are explicit. */
83
+ export function normalizeSourceTier(value, fallback = DEFAULT_SOURCE_TIER) {
84
+ if (SOURCE_TIERS.includes(value)) return { sourceTier: value, tierDefaulted: false };
85
+ return { sourceTier: fallback, tierDefaulted: value !== undefined && value !== null && value !== '' };
86
+ }
87
+
88
+ function normalizeState(row) {
89
+ if (RECORD_STATES.includes(row?.state)) return row.state;
90
+ const status = row?.status;
91
+ if (status === 'superseded' || status === 'forgotten' || status === 'archived' || status === 'restored') return status;
92
+ return 'active';
93
+ }
94
+
95
+ /** Read-side normalization (backward compatible with 1.6.8 records). */
96
+ export const ANCHOR_ID_RE = /^anc_[0-9a-f]{16}$/;
97
+ export const SHA256_RE = /^[0-9a-f]{64}$/;
98
+
99
+ const MUTEX_VALUE_GROUPS = Object.freeze([
100
+ Object.freeze(['快速', '安全']),
101
+ Object.freeze(['allow', 'deny']),
102
+ Object.freeze(['允许', '禁止']),
103
+ Object.freeze(['启用', '禁用']),
104
+ ]);
105
+
106
+ /** NFKC normalize: full-width digits/quotes/space folded (EA-03). */
107
+ export function normalizeText(value) {
108
+ return String(value ?? '').normalize('NFKC').replace(/[\u3000\s]+/g, ' ').trim();
109
+ }
110
+
111
+ /** Structural validity of one evidence entry (EA-01: fake anchors are invalid). */
112
+ export function validateEvidenceEntry(entry) {
113
+ if (!entry || typeof entry !== 'object') return { valid: false, code: 'E_EVIDENCE_NOT_OBJECT' };
114
+ if (typeof entry.anchorId !== 'string' || !ANCHOR_ID_RE.test(entry.anchorId)) return { valid: false, code: 'E_ANCHOR_ID' };
115
+ if (typeof entry.sha256 !== 'string' || !SHA256_RE.test(entry.sha256)) return { valid: false, code: 'E_SHA256' };
116
+ if (typeof entry.excerptRef !== 'string' || entry.excerptRef.trim() === '') return { valid: false, code: 'E_EXCERPT_REF' };
117
+ return { valid: true, code: 'OK' };
118
+ }
119
+ export function normalizeRecord(row) {
120
+ const value = row && typeof row === 'object' ? row : {};
121
+ const tier = normalizeSourceTier(value.sourceTier);
122
+ const evidence = Array.isArray(value.evidence)
123
+ ? value.evidence.filter((entry) => entry && typeof entry === 'object').map((entry) => {
124
+ const validity = validateEvidenceEntry(entry);
125
+ return {
126
+ anchorId: typeof entry.anchorId === 'string' ? entry.anchorId : null,
127
+ sha256: typeof entry.sha256 === 'string' ? entry.sha256 : null,
128
+ excerptRef: typeof entry.excerptRef === 'string' ? entry.excerptRef : null,
129
+ verifiedAt: entry.verifiedAt ?? null,
130
+ valid: validity.valid,
131
+ invalidCode: validity.valid ? null : validity.code,
132
+ };
133
+ })
134
+ : [];
135
+ return {
136
+ id: typeof value.id === 'string' ? value.id : null,
137
+ kind: typeof value.kind === 'string' ? value.kind : 'fact',
138
+ content: typeof value.content === 'string' ? value.content : '',
139
+ subject: typeof value.subject === 'string' && value.subject.trim() !== '' ? value.subject : null,
140
+ attribute: typeof value.attribute === 'string' ? value.attribute : null,
141
+ value: value.value !== undefined ? value.value : null,
142
+ project: value.project ?? null,
143
+ salience: Number.isInteger(value.salience) ? value.salience : 3,
144
+ importance: typeof value.importance === 'number' ? value.importance : 0.5,
145
+ state: normalizeState(value),
146
+ createdAt: value.createdAt ?? null,
147
+ lastUsed: value.lastUsed ?? null,
148
+ useCount: Number.isInteger(value.useCount) ? value.useCount : 0,
149
+ anchors: Array.isArray(value.anchors) ? value.anchors : [],
150
+ evidence,
151
+ sourceTier: tier.sourceTier,
152
+ tierDefaulted: tier.tierDefaulted,
153
+ conflict: value.conflict === true,
154
+ conflict_with: Array.isArray(value.conflict_with) ? value.conflict_with : [],
155
+ supersedes: Array.isArray(value.supersedes) ? value.supersedes : [],
156
+ supersededBy: value.supersededBy ?? null,
157
+ audit: value.audit && typeof value.audit === 'object' ? value.audit : {},
158
+ // C3 扩展:无证据 / 证据结构非法(伪造 anchorId/sha256/excerptRef)=> unverified。
159
+ unverified: evidence.length === 0 || evidence.some((entry) => entry.valid !== true),
160
+ evidenceValid: evidence.length > 0 && evidence.every((entry) => entry.valid === true),
161
+ };
162
+ }
163
+
164
+ function subjectKey(record) {
165
+ const compact = (value) => normalizeText(value).toLowerCase().replace(/\s+/g, '');
166
+ const subject = record.subject;
167
+ if (subject && typeof subject === 'object') {
168
+ const entity = compact(subject.entity);
169
+ const attribute = compact(subject.attribute);
170
+ if (entity === '') return null;
171
+ return attribute === '' ? entity : entity + '::' + attribute;
172
+ }
173
+ const raw = subject ?? record.attribute ?? null;
174
+ const normalized = compact(raw);
175
+ return normalized === '' ? null : normalized;
176
+ }
177
+
178
+ function valueTokens(value) {
179
+ return String(value ?? '').match(/\d+(?:\.\d+)*|[A-Za-z]+/g) ?? [];
180
+ }
181
+
182
+ function attributeOf(entry) {
183
+ if (typeof entry?.attribute === 'string' && entry.attribute.trim() !== '') return entry.attribute;
184
+ if (entry?.subject && typeof entry.subject === 'object' && typeof entry.subject.attribute === 'string') return entry.subject.attribute;
185
+ return null;
186
+ }
187
+
188
+ function sameAttributeDifferentValue(a, b) {
189
+ const attrA = attributeOf(a);
190
+ const attrB = attributeOf(b);
191
+ if (typeof attrA !== 'string' || typeof attrB !== 'string') return false;
192
+ if (normalizeText(attrA).toLowerCase() !== normalizeText(attrB).toLowerCase()) return false;
193
+ const left = normalizeText(a.value ?? a.content).toLowerCase();
194
+ const right = normalizeText(b.value ?? b.content).toLowerCase();
195
+ return left !== right;
196
+ }
197
+
198
+ /** Mutex value groups (EA-03): same subject + one token from opposite members => conflict. */
199
+ function mutexValueConflict(a, b) {
200
+ const left = normalizeText(a.content + ' ' + (a.value ?? '')).toLowerCase();
201
+ const right = normalizeText(b.content + ' ' + (b.value ?? '')).toLowerCase();
202
+ for (const group of MUTEX_VALUE_GROUPS) {
203
+ const leftHas = group.filter((token) => left.includes(token.toLowerCase()));
204
+ const rightHas = group.filter((token) => right.includes(token.toLowerCase()));
205
+ if (leftHas.length === 0 || rightHas.length === 0) continue;
206
+ if (!leftHas.some((token) => rightHas.includes(token))) return true;
207
+ }
208
+ return false;
209
+ }
210
+
211
+ /**
212
+ * Detect mutually exclusive assertions (pure; no disk IO).
213
+ * Returns { conflicts:[{idA,idB,subject,why}], byId:Map<id,string[]> }.
214
+ */
215
+ export function detectRecordConflicts(records) {
216
+ const list = (Array.isArray(records) ? records : []).filter((record) => record && typeof record.id === 'string'
217
+ && record.state !== 'superseded' && record.state !== 'forgotten');
218
+ const bySubject = new Map();
219
+ for (const record of list) {
220
+ const key = subjectKey(record);
221
+ if (key === null) continue;
222
+ if (!bySubject.has(key)) bySubject.set(key, []);
223
+ bySubject.get(key).push(record);
224
+ }
225
+ const conflicts = [];
226
+ const byId = new Map();
227
+ for (const [subject, group] of bySubject) {
228
+ for (let i = 0; i < group.length; i += 1) {
229
+ for (let j = i + 1; j < group.length; j += 1) {
230
+ const a = group[i];
231
+ const b = group[j];
232
+ if (a.supersedes?.includes(b.id) || b.supersedes?.includes(a.id)) continue;
233
+ if (a.supersededBy === b.id || b.supersededBy === a.id) continue;
234
+ const opposite = isNegativeConclusion(normalizeText(a.content)) !== isNegativeConclusion(normalizeText(b.content));
235
+ const differentValue = sameAttributeDifferentValue(a, b);
236
+ const mutex = mutexValueConflict(a, b);
237
+ if (!opposite && !differentValue && !mutex) continue;
238
+ const why = opposite ? 'opposite-polarity' : (differentValue ? 'same-attribute-different-value' : 'mutex-value-group');
239
+ conflicts.push({ idA: a.id, idB: b.id, subject, why });
240
+ if (!byId.has(a.id)) byId.set(a.id, []);
241
+ if (!byId.has(b.id)) byId.set(b.id, []);
242
+ byId.get(a.id).push(b.id);
243
+ byId.get(b.id).push(a.id);
244
+ }
245
+ }
246
+ }
247
+ return { conflicts, byId };
248
+ }
249
+
250
+ /** Read all records (read-only) and compute conflicts/unverified/state. */
251
+ export function listRecords({ memoryRoot, limit = 200 } = {}) {
252
+ const file = recordsPathOf(memoryRoot);
253
+ const items = [];
254
+ const bad = [];
255
+ let present = false;
256
+ let text = '';
257
+ try {
258
+ text = fs.readFileSync(file, 'utf8');
259
+ present = true;
260
+ } catch {
261
+ text = '';
262
+ }
263
+ for (const line of text.split('\n')) {
264
+ if (line.trim() === '') continue;
265
+ let row = null;
266
+ try {
267
+ row = JSON.parse(line);
268
+ } catch {
269
+ bad.push(line.slice(0, 120));
270
+ continue;
271
+ }
272
+ items.push(normalizeRecord(row));
273
+ }
274
+ const { conflicts, byId } = detectRecordConflicts(items);
275
+ for (const item of items) {
276
+ const ids = byId.get(item.id) ?? [];
277
+ item.conflict = ids.length > 0;
278
+ item.conflict_with = ids;
279
+ }
280
+ const limited = Number.isInteger(limit) && limit > 0 ? items.slice(-limit) : items;
281
+ return {
282
+ schema: RECORDS_SCHEMA,
283
+ present,
284
+ items: limited,
285
+ total: items.length,
286
+ conflicts,
287
+ unverified: items.filter((item) => item.unverified).length,
288
+ badLines: bad,
289
+ file,
290
+ };
291
+ }
292
+
293
+ /** Append one audit line (one per recall/evidence call). */
294
+ export function appendRecallAudit({ memoryRoot, now = () => new Date(), query = '', recordId = null, recordIds = [], hit = false, conflicts = [], timings = null, source = 'records-query', extra = {} } = {}) {
295
+ const value = typeof now === 'function' ? now() : new Date();
296
+ const at = (value instanceof Date ? value : new Date(value)).toISOString();
297
+ const line = {
298
+ schema: AUDIT_SCHEMA,
299
+ at,
300
+ query: String(query ?? '').slice(0, 500),
301
+ hit: hit === true,
302
+ recordId: recordId ?? (recordIds.length > 0 ? recordIds[0] : null),
303
+ recordIds: Array.isArray(recordIds) ? recordIds.slice(0, 50) : [],
304
+ conflicts: Array.isArray(conflicts) ? conflicts.slice(0, 20) : [],
305
+ source,
306
+ timings: timings && typeof timings === 'object' ? timings : null,
307
+ ...extra,
308
+ };
309
+ fs.mkdirSync(path.dirname(recallLogPathOf(memoryRoot)), { recursive: true });
310
+ fs.appendFileSync(recallLogPathOf(memoryRoot), `${JSON.stringify(line)}\n`, 'utf8');
311
+ return { ok: true, line, file: recallLogPathOf(memoryRoot) };
312
+ }
313
+
314
+ function queryTokens(query) {
315
+ const text = String(query ?? '').toLowerCase();
316
+ const tokens = new Set(text.match(/[a-z0-9]+|[\u3400-\u4dbf\u4e00-\u9fff]{2,4}/g) ?? []);
317
+ for (const run of text.match(/[\u3400-\u4dbf\u4e00-\u9fff]+/g) ?? []) {
318
+ const chars = Array.from(run);
319
+ for (let i = 0; i + 1 < chars.length; i += 1) tokens.add(chars[i] + chars[i + 1]);
320
+ }
321
+ return [...tokens];
322
+ }
323
+
324
+ /**
325
+ * Minimal records recall (basis for M2-C memory_recall_v2):
326
+ * keyword/bigram match over content, returns candidates + conflicts[] + unverified flags,
327
+ * and writes exactly one audit line per call (hit or not).
328
+ */
329
+ export function queryRecords({ memoryRoot, query = '', limit = 10, now = () => new Date(), source = 'records-query' } = {}) {
330
+ const started = Date.now();
331
+ const listed = listRecords({ memoryRoot, limit: 500 });
332
+ const tokens = queryTokens(query);
333
+ const scored = listed.items
334
+ .filter((item) => item.state === 'active')
335
+ .map((item) => {
336
+ const haystack = `${item.content} ${item.subject ?? ''} ${item.kind}`.toLowerCase();
337
+ let score = 0;
338
+ for (const token of tokens) if (haystack.includes(token)) score += 1;
339
+ return { item, score };
340
+ })
341
+ .filter((entry) => tokens.length === 0 || entry.score > 0)
342
+ .sort((a, b) => b.score - a.score || String(b.item.createdAt ?? '').localeCompare(String(a.item.createdAt ?? '')));
343
+ const items = scored.slice(0, limit).map((entry) => entry.item);
344
+ const recordIds = items.map((item) => item.id).filter(Boolean);
345
+ const conflictIds = new Set([...items.flatMap((item) => item.conflict_with), ...items.map((item) => item.id)]);
346
+ const conflicts = listed.conflicts.filter((entry) => conflictIds.has(entry.idA) || conflictIds.has(entry.idB));
347
+ appendRecallAudit({
348
+ memoryRoot,
349
+ now,
350
+ query,
351
+ recordId: recordIds[0] ?? null,
352
+ recordIds,
353
+ hit: items.length > 0,
354
+ conflicts,
355
+ timings: { totalMs: Date.now() - started },
356
+ source,
357
+ });
358
+ return {
359
+ schema: RECORDS_SCHEMA,
360
+ query: String(query ?? ''),
361
+ items,
362
+ total: items.length,
363
+ conflicts,
364
+ unverified: items.filter((item) => item.unverified).length,
365
+ code: items.length > 0 ? 'OK' : 'E_RECORDS_NO_MATCH',
366
+ };
367
+ }
368
+
369
+ /** Recompute evidence liveness; returns a copy marked unverified when evidence is gone. */
370
+ export function verifyRecordEvidence(record, verifyOne) {
371
+ const base = normalizeRecord(record);
372
+ const results = base.evidence.map((entry) => {
373
+ if (entry.valid !== true) return { ...entry, state: 'invalid' };
374
+ let state = 'missing';
375
+ try {
376
+ state = typeof verifyOne === 'function' ? verifyOne(entry) : 'missing';
377
+ } catch {
378
+ state = 'missing';
379
+ }
380
+ return { ...entry, state };
381
+ });
382
+ const unverified = base.evidence.length === 0 || results.some((entry) => entry.state !== 'live');
383
+ return { ...base, evidenceVerified: results, unverified };
384
+ }
385
+
386
+ /** Derive excerptRef from an anchor object (session_turn / file_line). */
387
+ export function excerptRefOf(anchor) {
388
+ if (!anchor || typeof anchor !== 'object') return null;
389
+ if (anchor.kind === 'file_line') return 'file:' + anchor.rel + ':' + (anchor.lineStart ?? 1) + '-' + (anchor.lineEnd ?? anchor.lineStart ?? 1);
390
+ if (anchor.sessionId !== undefined && anchor.turn !== undefined) return 'session:' + anchor.sessionId + '#turn' + anchor.turn;
391
+ return null;
392
+ }
393
+
394
+ /** Write-path evidence evaluation (EA-01/EA-02): callers cannot self-declare verified. */
395
+ export function evaluateEvidenceForWrite(anchors, options = {}) {
396
+ const verifiedAt = options.verifiedAt ?? null;
397
+ const entries = (Array.isArray(anchors) ? anchors : []).map((anchor) => ({
398
+ anchorId: anchor?.anchorId ?? anchorIdOf(anchor),
399
+ sha256: anchor?.digest ?? anchor?.fileSha ?? null,
400
+ excerptRef: excerptRefOf(anchor),
401
+ verifiedAt,
402
+ }));
403
+ const checks = entries.map((entry) => {
404
+ const validity = validateEvidenceEntry(entry);
405
+ return { ...entry, valid: validity.valid, invalidCode: validity.valid ? null : validity.code };
406
+ });
407
+ const results = checks.map((entry) => {
408
+ if (entry.valid !== true) return { ...entry, state: 'invalid' };
409
+ let state = 'unverified';
410
+ try {
411
+ state = typeof options.verifyOne === 'function' ? options.verifyOne(entry) : 'unverified';
412
+ } catch {
413
+ state = 'missing';
414
+ }
415
+ return { ...entry, state };
416
+ });
417
+ return {
418
+ evidence: checks,
419
+ verified: checks.length > 0 && checks.every((entry) => entry.valid),
420
+ verifiedLive: results.length > 0 && results.every((entry) => entry.state === 'live'),
421
+ reasons: checks.filter((entry) => entry.valid !== true).map((entry) => entry.invalidCode),
422
+ };
423
+ }
424
+
425
+ export { sha256Hex };
@@ -0,0 +1,128 @@
1
+ // src/memory/retention.js — A6:保留/删除分层(四层不删除;protected fail-safe)
2
+ //
3
+ // 层级(MEMORY-RECALL-V2-PROPOSAL §4.1/§4.2):
4
+ // L0 raw events `.longlive/events/**`、session 日志复制品:通常不可变,
5
+ // 只能经截断/手术(§4.1.1 分级);任何常规遗忘路径不得触碰。
6
+ // L1 distill `.longlive/distill/**`:合并摘要,保留 gist,旧版 append 不删。
7
+ // L2 cards/index 卡片、索引、working-frame:可归档但**必须可恢复**;
8
+ // working-frame 属宿主私有,永久受保护(不参与 memory_forget)。
9
+ // L3 archive `.longlive/archive/**`、`epochs/**`、`tombstones.md`:仅纯噪声/重复,
10
+ // 可恢复可审计;重要事件永禁入。
11
+ //
12
+ // 保护谓词(C1 fail-safe,设计 §4.2.1):l2GatePass !== true → protected=true,
13
+ // 即只有显式 `l2GatePass:true` 才放行,false/undefined/缺字段一律保护。
14
+ // 缺 `.longlive/protected.json` 的 legacy 行为:非 L0 普通卡仍可遗忘(M2-D 蒸馏
15
+ // 侧的 fail-safe 闸门由 src/memory/l2-gate.js 独立判定“缺 manifest=保护”)。
16
+ // 删除只能经宿主/面板动作 + 审计;本模块只提供判定,不执行删除。
17
+
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+
21
+ export const RETENTION_SCHEMA = 'retention/v1';
22
+ export const RETENTION_LAYERS = Object.freeze(['L0', 'L1', 'L2', 'L3']);
23
+ export const PROTECTED_MANIFEST = '.longlive/protected.json';
24
+ export const FRAMES_DIR = '.longlive/working';
25
+
26
+ function normRel(value) {
27
+ return String(value ?? '').replace(/\\/g, '/').replace(/^\/+/, '');
28
+ }
29
+
30
+ /** Classify a path into a retention layer (pure, no IO). */
31
+ export function retentionLayerOf(relPath) {
32
+ const rel = normRel(relPath);
33
+ if (rel.startsWith('.longlive/working/')) return 'L2';
34
+ if (rel.startsWith('.longlive/distill')) return 'L1';
35
+ if (rel.startsWith('.longlive/archive/') || rel.startsWith('epochs/') || rel === 'tombstones.md') return 'L3';
36
+ if (rel.startsWith('.longlive/events') || rel.startsWith('.longlive/index/') || rel.startsWith('.longlive/audit/')) return 'L0';
37
+ return 'L2';
38
+ }
39
+
40
+ export function protectionManifestPathOf(memoryRoot) {
41
+ return path.join(path.resolve(String(memoryRoot ?? '')), PROTECTED_MANIFEST);
42
+ }
43
+
44
+ /**
45
+ * Read the protection manifest with fail-safe semantics.
46
+ * Missing file / unparsable / entry without l2GatePass => protected on the
47
+ * distill path; isProtectedPath keeps the legacy (pre-M2-D) missing-manifest
48
+ * behaviour for ordinary L2/L3 cards so existing forget flows stay intact.
49
+ */
50
+ export function readProtectionManifest(memoryRoot) {
51
+ const file = protectionManifestPathOf(memoryRoot);
52
+ let raw;
53
+ try {
54
+ raw = fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '');
55
+ } catch {
56
+ return { present: false, defaultProtected: false, entries: [], file };
57
+ }
58
+ let parsed;
59
+ try {
60
+ parsed = JSON.parse(raw);
61
+ } catch {
62
+ return { present: false, defaultProtected: false, entries: [], file };
63
+ }
64
+ const entries = Array.isArray(parsed?.entries)
65
+ ? parsed.entries.filter((entry) => entry && typeof entry.id === 'string').map((entry) => ({
66
+ id: normRel(entry.id),
67
+ kind: typeof entry.kind === 'string' ? entry.kind : 'card',
68
+ // C1 fail-safe (§4.2.1): only an explicit true releases protection;
69
+ // false stays false, undefined/missing stays undefined (both protect).
70
+ l2GatePass: entry.l2GatePass === true ? true : entry.l2GatePass === false ? false : undefined,
71
+ source: typeof entry.source === 'string' ? entry.source : null,
72
+ reason: typeof entry.reason === 'string' ? entry.reason : null,
73
+ }))
74
+ : [];
75
+ return {
76
+ present: true,
77
+ defaultProtected: parsed?.default !== 'allow',
78
+ entries,
79
+ file,
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Whether a card/path may be forgotten. Frames are always protected (A6: 纳入保留体系),
85
+ * protected entries are protected unless `l2GatePass === true` (fail-safe: false/undefined
86
+ * protect), and an unknown path under L0 is never deletable by the forget path.
87
+ */
88
+ export function isProtectedPath({ memoryRoot, relPath }) {
89
+ const rel = normRel(relPath);
90
+ if (rel === '') return true;
91
+ const layer = retentionLayerOf(rel);
92
+ if (layer === 'L0') return true;
93
+ if (rel.startsWith(FRAMES_DIR) || rel.endsWith('.hmac') || rel.endsWith('.lock')) return true;
94
+ const manifest = readProtectionManifest(memoryRoot);
95
+ const hit = manifest.entries.find((entry) => entry.id === rel);
96
+ if (hit) return hit.l2GatePass !== true;
97
+ // C1 fail-safe: absent manifest or protected-by-default means refuse.
98
+ return manifest.defaultProtected === true;
99
+ }
100
+
101
+ /** Throws E_PROTECTED when deletion would violate the retention/protection layering. */
102
+ export function assertDeletable({ memoryRoot, relPath }) {
103
+ if (!isProtectedPath({ memoryRoot, relPath })) return { ok: true, layer: retentionLayerOf(relPath) };
104
+ const error = new Error(`保留分层/保护名单拒绝删除:${normRel(relPath)}(A6)`);
105
+ error.code = 'E_PROTECTED';
106
+ error.status = 403;
107
+ throw error;
108
+ }
109
+
110
+ /** Executed deletions always leave an audit row (action=archive) in audit/records.jsonl. */
111
+ export function auditRetentionRows({ memoryRoot, moved = [], auditKey = null, questionId = 'unknown' } = {}) {
112
+ const rows = [];
113
+ for (const item of Array.isArray(moved) ? moved : []) {
114
+ rows.push({
115
+ schema: 'retention/v1',
116
+ action: 'archive',
117
+ recordId: item.path ?? item.id ?? null,
118
+ layer: retentionLayerOf(item.path ?? ''),
119
+ archived: item.archived ?? item.destRel ?? null,
120
+ backup: item.backupRel ?? item.backup ?? null,
121
+ sha256: item.sha256 ?? null,
122
+ auditKeyPresent: typeof auditKey === 'string' && auditKey !== '',
123
+ questionId,
124
+ at: typeof item.at === 'string' ? item.at : null,
125
+ });
126
+ }
127
+ return rows;
128
+ }