@jjh32456/dsh-longlive 1.6.8 → 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 (288) hide show
  1. package/CHANGELOG.md +69 -1539
  2. package/LICENSE +1 -1
  3. package/QUICKSTART.md +33 -12
  4. package/README.en.md +17 -7
  5. package/README.md +29 -10
  6. package/SPEC.md +5 -5
  7. package/cordis.patch.yml +17 -12
  8. package/docs/ANSWER-GUARD.md +92 -0
  9. package/docs/ARCHITECTURE.md +3 -3
  10. package/docs/AUTO-RECALL-WIRING.md +1 -1
  11. package/docs/AUTO-RECALL.md +15 -9
  12. package/docs/CONFIG.md +143 -37
  13. package/docs/ERRORS.md +17 -5
  14. package/docs/FAQ.md +7 -7
  15. package/docs/FEATURES.md +66 -29
  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 +3 -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 +42 -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 +129 -18
  41. package/src/activity/model.js +11 -1
  42. package/src/activity/store.js +22 -3
  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 +145 -48
  58. package/src/deps.js +6 -4
  59. package/src/features.js +158 -8
  60. package/src/identity/index.js +13 -5
  61. package/src/identity/inject-position.js +2 -2
  62. package/src/index.js +97 -7
  63. package/src/lifecycle/gap-clocks.js +328 -0
  64. package/src/lifecycle/index.js +269 -0
  65. package/src/memory/activity-capture.js +229 -0
  66. package/src/memory/anchors.js +194 -0
  67. package/src/memory/audit-records.js +222 -0
  68. package/src/memory/auto-cards.js +401 -0
  69. package/src/memory/capture.js +442 -0
  70. package/src/memory/card-anchors.js +203 -0
  71. package/src/memory/conflicts.js +106 -0
  72. package/src/memory/decay.js +311 -0
  73. package/src/memory/distill.js +471 -0
  74. package/src/memory/event-index.js +917 -0
  75. package/src/memory/evidence-chain.js +295 -0
  76. package/src/memory/evidence.js +145 -0
  77. package/src/memory/filter-runner.js +1 -1
  78. package/src/memory/filter.js +5 -1
  79. package/src/memory/forget-v2.js +410 -0
  80. package/src/memory/forget.js +71 -0
  81. package/src/memory/frames-activation.js +247 -0
  82. package/src/memory/fts-query.js +174 -0
  83. package/src/memory/governance.js +604 -0
  84. package/src/memory/index.js +1 -0
  85. package/src/memory/l2-gate.js +169 -0
  86. package/src/memory/lock.js +121 -0
  87. package/src/memory/migrate.js +139 -0
  88. package/src/memory/recall-v2.js +223 -0
  89. package/src/memory/recall.js +65 -2
  90. package/src/memory/records.js +425 -0
  91. package/src/memory/retention.js +128 -0
  92. package/src/memory/review.js +124 -0
  93. package/src/memory/trust.js +88 -0
  94. package/src/memory/version-chain.js +264 -0
  95. package/src/memory/wal.js +137 -0
  96. package/src/memory/working-frame.js +516 -0
  97. package/src/metrics/classify.mjs +5 -3
  98. package/src/metrics/index.mjs +21 -1
  99. package/src/metrics/prestep-latency.mjs +300 -0
  100. package/src/metrics/store.mjs +25 -0
  101. package/src/policy-profile.js +18 -3
  102. package/src/recall/anaphora.mjs +87 -0
  103. package/src/recall/cards.mjs +7 -0
  104. package/src/recall/embedding.mjs +8 -8
  105. package/src/recall/entities.mjs +112 -0
  106. package/src/recall/index.js +235 -19
  107. package/src/recall/inject-order.mjs +10 -8
  108. package/src/recall/privacy.mjs +1 -1
  109. package/src/recall/recall-core.mjs +116 -10
  110. package/src/recall/shared-visibility.js +302 -0
  111. package/src/recall/time-normalizer.mjs +196 -0
  112. package/src/role/activation.js +191 -0
  113. package/src/role/attention.js +318 -0
  114. package/src/role/base.js +36 -0
  115. package/src/role/channels.js +423 -0
  116. package/src/role/index.js +425 -0
  117. package/src/role/interfaces.js +115 -0
  118. package/src/role/personality.js +370 -0
  119. package/src/role/readers.js +151 -0
  120. package/src/role/relationships.js +477 -0
  121. package/src/role/store.js +202 -0
  122. package/src/role/writeprotect.js +373 -0
  123. package/src/router/aliases.json +176 -25
  124. package/src/router/background/agent-runner.mjs +1 -1
  125. package/src/router/background/anchor.mjs +1 -1
  126. package/src/router/background/audit.mjs +2 -2
  127. package/src/router/background/budget-ledger.mjs +2 -2
  128. package/src/router/background/host-sim.mjs +1 -1
  129. package/src/router/background/memory.mjs +4 -4
  130. package/src/router/background/queue.mjs +1 -1
  131. package/src/router/background/retry.mjs +1 -1
  132. package/src/router/background/router.mjs +1 -1
  133. package/src/router/background/stream.mjs +1 -1
  134. package/src/router/background/tool-policy.mjs +1 -1
  135. package/src/router/design-headings.json +5 -5
  136. package/src/router/face/config.mjs +1 -1
  137. package/src/router/face/identity.mjs +3 -3
  138. package/src/router/judge.mjs +8 -7
  139. package/src/router/policies/generic.json +21 -4
  140. package/src/router/route.js +109 -22
  141. package/src/router/router.mjs +10 -6
  142. package/src/router/routing-rules.json +260 -445
  143. package/src/router/run-sim.mjs +8 -8
  144. package/src/router/runtime.js +23 -12
  145. package/src/router/service/config.mjs +5 -5
  146. package/src/router/service/context.mjs +1 -1
  147. package/src/router/service/queue.mjs +1 -1
  148. package/src/router/shadow.mjs +4 -3
  149. package/src/surgery-recovery.js +2 -2
  150. package/src/tool-output.js +2 -2
  151. package/src/tools.js +39 -7
  152. package/src/truncate-visible.js +2 -2
  153. package/src/ui/actions.js +1312 -0
  154. package/src/ui/panel-api.js +367 -0
  155. package/src/ui/panel-auth.js +251 -0
  156. package/src/ui/panel-integrity.js +243 -0
  157. package/src/ui/routes-readonly.js +695 -0
  158. package/src/ui/routes.js +3 -0
  159. package/docs/DEV.md +0 -279
  160. package/docs/PRESENCE-BUS-WIRING.md +0 -491
  161. package/docs/PRESENCE-BUS.md +0 -694
  162. package/docs/SURGERY.md +0 -186
  163. package/scripts/mark-core-cards.mjs +0 -289
  164. package/scripts/pack.mjs +0 -392
  165. package/scripts/router-proposals.mjs +0 -386
  166. package/src/router/fixtures/M1.json +0 -16
  167. package/src/router/fixtures/M2.json +0 -16
  168. package/src/router/fixtures/M3.json +0 -20
  169. package/src/router/fixtures/M4.json +0 -20
  170. package/src/router/fixtures/M5.json +0 -45
  171. package/src/router/fixtures/M6.json +0 -53
  172. package/src/router/fixtures/M7.json +0 -46
  173. package/src/router/fixtures/S1.json +0 -20
  174. package/src/router/fixtures/S2.json +0 -23
  175. package/src/router/fixtures/S3.json +0 -20
  176. package/src/router/fixtures/S4.json +0 -34
  177. package/src/router/fixtures/S5.json +0 -43
  178. package/src/router/fixtures/S6.json +0 -47
  179. package/src/router/fixtures/T1.json +0 -23
  180. package/src/router/fixtures/T2.json +0 -16
  181. package/src/router/fixtures/T3.json +0 -19
  182. package/src/router/fixtures/T4.json +0 -16
  183. package/src/router/fixtures/X1.json +0 -17
  184. package/src/router/fixtures/X2.json +0 -20
  185. package/src/router/gen-design-headings.mjs +0 -107
  186. package/src/router/policies/owner-rulings-2026-11-03.json +0 -62
  187. package/test/activity-realtime.test.mjs +0 -959
  188. package/test/activity-stream.test.mjs +0 -512
  189. package/test/auto-recall.test.mjs +0 -474
  190. package/test/auto-truncate.test.mjs +0 -931
  191. package/test/autobind.test.mjs +0 -399
  192. package/test/bus.test.mjs +0 -1572
  193. package/test/cli-guard.test.mjs +0 -198
  194. package/test/conclusion.test.mjs +0 -408
  195. package/test/config-schema.test.mjs +0 -210
  196. package/test/d6-output.test.mjs +0 -194
  197. package/test/d6.test.mjs +0 -395
  198. package/test/deps.test.mjs +0 -342
  199. package/test/doctor-setup.test.mjs +0 -297
  200. package/test/embedding-recall.test.mjs +0 -347
  201. package/test/feature-flags.test.mjs +0 -585
  202. package/test/filter-shadow.test.mjs +0 -170
  203. package/test/fixtures/activity-append-child.mjs +0 -29
  204. package/test/fixtures/baseline-0.8.0-registration.json +0 -556
  205. package/test/fixtures/d6-header-tools.json +0 -1
  206. package/test/fixtures/lock-child.mjs +0 -67
  207. package/test/fixtures/pre-step-container.mjs +0 -100
  208. package/test/fixtures/privacy-memory/topics/pii-unmarked.md +0 -9
  209. package/test/fixtures/privacy-memory/topics/secret-local-only.md +0 -11
  210. package/test/fixtures/profile-session-longlive-config.json +0 -20
  211. package/test/fixtures/proposal-cas-child.mjs +0 -66
  212. package/test/host-engine-status.test.mjs +0 -307
  213. package/test/hot-truncate.test.mjs +0 -1349
  214. package/test/identity-card-marker.test.mjs +0 -307
  215. package/test/identity-router/run.mjs +0 -721
  216. package/test/integration-pre-step.test.mjs +0 -553
  217. package/test/live.test.mjs +0 -1023
  218. package/test/maintenance-schedule.test.mjs +0 -538
  219. package/test/memory.test.mjs +0 -2828
  220. package/test/metrics.test.mjs +0 -478
  221. package/test/orchestrator.test.mjs +0 -266
  222. package/test/p1_probe.mjs +0 -97
  223. package/test/packaging.test.mjs +0 -184
  224. package/test/portability.test.mjs +0 -343
  225. package/test/rc4-regressions.test.mjs +0 -562
  226. package/test/rc5-regressions.test.mjs +0 -197
  227. package/test/rc6-chinese.test.mjs +0 -394
  228. package/test/router-1100/apply-off.test.mjs +0 -175
  229. package/test/router-1100/budget-reconcile.test.mjs +0 -215
  230. package/test/router-1100/config-context.test.mjs +0 -211
  231. package/test/router-1100/helpers.mjs +0 -129
  232. package/test/router-1100/hint-envelope.test.mjs +0 -150
  233. package/test/router-1100/lock-crossproc.test.mjs +0 -206
  234. package/test/router-1100/proposal-cas-crossproc.test.mjs +0 -184
  235. package/test/router-1100/router-apply.test.mjs +0 -372
  236. package/test/router-1100/router-fixb.test.mjs +0 -602
  237. package/test/router-1100/router-proposals.test.mjs +0 -405
  238. package/test/router-1100/router-queue-lease-wal.test.mjs +0 -355
  239. package/test/router-1100/router-security-cr.test.mjs +0 -333
  240. package/test/router-1100/service-lifecycle.test.mjs +0 -220
  241. package/test/router-1100/tools-contract.test.mjs +0 -164
  242. package/test/router-1100/turn-reflect-tick.test.mjs +0 -89
  243. package/test/router-1100/v6-contract.test.mjs +0 -158
  244. package/test/router-1100/v6b-contract.test.mjs +0 -293
  245. package/test/router-1100/v6c-contract.test.mjs +0 -249
  246. package/test/router-1100/wal-rotation.test.mjs +0 -375
  247. package/test/router-1100/wp1-config.test.mjs +0 -170
  248. package/test/router-1100/wp1-default-off.test.mjs +0 -77
  249. package/test/router-1100/wp1-model-status.test.mjs +0 -90
  250. package/test/router-1100/wp1-status.test.mjs +0 -220
  251. package/test/router-1100/wp1-tools.test.mjs +0 -146
  252. package/test/router-1100/wp5-cli.test.mjs +0 -240
  253. package/test/router-1100/wp5-model-auto.test.mjs +0 -418
  254. package/test/router-1100/wp6-merge.test.mjs +0 -386
  255. package/test/router-1100/wp6-wake.test.mjs +0 -502
  256. package/test/router-background/audit.test.mjs +0 -37
  257. package/test/router-background/budget-ledger.test.mjs +0 -330
  258. package/test/router-background/cross-process.test.mjs +0 -222
  259. package/test/router-background/fixtures/cross-child.mjs +0 -85
  260. package/test/router-background/fixtures/real-shapes.mjs +0 -239
  261. package/test/router-background/router.test.mjs +0 -1076
  262. package/test/router-background/stream-retry.test.mjs +0 -358
  263. package/test/router-background/tool-agent.test.mjs +0 -384
  264. package/test/router-face/bridge.test.mjs +0 -169
  265. package/test/router-face/face.test.mjs +0 -316
  266. package/test/router-failclosed.test.mjs +0 -466
  267. package/test/router-identity.test.mjs +0 -384
  268. package/test/router-l1-l2.contract.test.mjs +0 -1075
  269. package/test/router-memory-tools.test.mjs +0 -262
  270. package/test/router-policy.test.mjs +0 -225
  271. package/test/router-runtime.test.mjs +0 -438
  272. package/test/router-shadow.test.mjs +0 -287
  273. package/test/router.test.mjs +0 -903
  274. package/test/run-rc.mjs +0 -315
  275. package/test/run.mjs +0 -2469
  276. package/test/safety-defaults.test.mjs +0 -181
  277. package/test/seeded-load.test.mjs +0 -436
  278. package/test/session-original.test.mjs +0 -338
  279. package/test/state-truth.test.mjs +0 -937
  280. package/test/surgery-recovery.test.mjs +0 -936
  281. package/test/t7_drive.mjs +0 -151
  282. package/test/tool-output.test.mjs +0 -393
  283. package/test/tool-schema.test.mjs +0 -177
  284. package/test/tools_contract.test.mjs +0 -716
  285. package/test/truncate-visible.test.mjs +0 -1150
  286. package/test/ui-flags.test.mjs +0 -477
  287. package/test/v4-hardening.test.mjs +0 -427
  288. package/test/version-consistency.test.mjs +0 -286
@@ -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
+ }