@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
@@ -0,0 +1,410 @@
1
+ // src/memory/forget-v2.js — M2-D:forget v2 门面(propose / execute / restore)
2
+ //
3
+ // 设计依据 §4.6(归档仅纯噪声/重复,重要事件永禁;沿用 forget.js 的备份→三问→
4
+ // rename→审计,不重写 forget.js):
5
+ // * propose = forget.js dry-run(只读,不建目录、不写审计);
6
+ // * execute = forget.js 执行(需 confirm:"FORGET";保护卡由 retention 拒绝);
7
+ // * restore = 从 tombstones.md 的 recoverable 目标或 epochs/ 把卡移回原路径,
8
+ // 只 rename、不删除,写 1 行恢复审计(生命周期上「忘/恢复」两条审计)。
9
+ //
10
+ // 本模块不注册工具(见 Lead 拍板:不 expose 新工具);入口在 admin 脚本。
11
+
12
+ import crypto from 'node:crypto';
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+
16
+ import { appendAuditRecord, resolveAuditKey, verifyAuditChain } from './audit-records.js';
17
+ import { memoryForget, DECAY_RULES } from './forget.js';
18
+ import { memoryError, memoryRootFromConfig, relativePath, resolveInside } from './paths.js';
19
+ import { isProtectedPath } from './retention.js';
20
+ import { readCardAnchors } from './card-anchors.js';
21
+ import {
22
+ appendAudit, ensureDir, epochsDir, sha256File, tombstonesPath, writeFileAtomic,
23
+ } from './state.js';
24
+
25
+ export const FORGET_V2_SCHEMA = 'forget-v2/v1';
26
+ export const PURE_NOISE_PATTERNS = Object.freeze([
27
+ /^\.longlive\/tool-output\//,
28
+ /^\.tmp-test\//,
29
+ /(^|\/)diag\//,
30
+ /(^|\/)tmp\//,
31
+ ]);
32
+
33
+ /** Pure-noise whitelist for the v2 archive path; anything else is never auto-archived. */
34
+ export function isPureNoisePath(relPath) {
35
+ const rel = String(relPath ?? '').replace(/\\/g, '/').replace(/^\/+/, '');
36
+ if (rel === '') return false;
37
+ return PURE_NOISE_PATTERNS.some((pattern) => pattern.test(rel));
38
+ }
39
+
40
+ /** Read-only proposal (delegates to forget.js dry-run). */
41
+ export function proposeForget(args = {}, config = {}) {
42
+ return memoryForget({ ...args, confirm: undefined }, config);
43
+ }
44
+
45
+ function sha256Hex(text) {
46
+ return crypto.createHash('sha256').update(String(text), 'utf8').digest('hex');
47
+ }
48
+
49
+ function hmacHex(key, text) {
50
+ return crypto.createHmac('sha256', key).update(String(text), 'utf8').digest('hex');
51
+ }
52
+
53
+ const TOMB_SIG_RE = /\s*\|\s*tombstonePrevHash:\s*(genesis|[0-9a-f]{64})\s*\|\s*tombstoneHash:\s*([0-9a-f]{64})\s*\|\s*tombstoneHmac:\s*([0-9a-f]{64})$/;
54
+
55
+ function signTombstoneLine(line, { key, previousHash }) {
56
+ if (typeof key !== 'string' || key === '') throw new Error('E_NO_KEY');
57
+ const hash = sha256Hex(`${previousHash}\n${line}`);
58
+ const hmac = hmacHex(key, hash);
59
+ return {
60
+ line: `${line} | tombstonePrevHash: ${previousHash} | tombstoneHash: ${hash} | tombstoneHmac: ${hmac}`,
61
+ hash,
62
+ };
63
+ }
64
+
65
+ /** Verify every tombstone line: signature required, chain must be continuous. */
66
+ export function verifyTombstoneLedger(root, key) {
67
+ const file = tombstonesPath(root);
68
+ let text = '';
69
+ try { text = fs.readFileSync(file, 'utf8'); } catch {
70
+ return { ok: false, code: 'E_TOMBSTONE_MISSING', lines: 0 };
71
+ }
72
+ let previous = 'genesis';
73
+ let lines = 0;
74
+ for (const raw of text.split(/\r?\n/)) {
75
+ if (!raw.startsWith('- ')) continue;
76
+ lines += 1;
77
+ const match = raw.match(TOMB_SIG_RE);
78
+ if (!match) return { ok: false, code: 'E_TOMBSTONE_UNSIGNED', line: lines };
79
+ const base = raw.slice(0, match.index);
80
+ const expectedHash = sha256Hex(`${previous}\n${base}`);
81
+ if (match[2] !== expectedHash) return { ok: false, code: 'E_TOMBSTONE_CHAIN_BROKEN', line: lines };
82
+ if (match[1] !== previous) return { ok: false, code: 'E_TOMBSTONE_CHAIN_BROKEN', line: lines };
83
+ const expectedHmac = hmacHex(key, expectedHash);
84
+ if (match[3] !== expectedHmac) return { ok: false, code: 'E_TOMBSTONE_HMAC_INVALID', line: lines };
85
+ previous = match[2];
86
+ }
87
+ return { ok: true, code: 'OK', lines, lastHash: previous };
88
+ }
89
+
90
+ function lastSignedTombstoneHash(text) {
91
+ let previous = 'genesis';
92
+ for (const raw of String(text ?? '').split(/\r?\n/)) {
93
+ if (!raw.startsWith('- ')) continue;
94
+ const match = raw.match(TOMB_SIG_RE);
95
+ if (match) previous = match[2];
96
+ }
97
+ return previous;
98
+ }
99
+
100
+ function auditDenied({ root, action, recordId, code, extra = {}, now, skipHead = false }) {
101
+ try {
102
+ appendAuditRecord({
103
+ memoryRoot: root,
104
+ action,
105
+ recordId: recordId ?? null,
106
+ readerTier: 'host',
107
+ questionId: 'unknown',
108
+ expectedTurns: null,
109
+ answerText: null,
110
+ at: now instanceof Date ? now.toISOString() : new Date(now).toISOString(),
111
+ extra: { code, ...extra },
112
+ skipHead,
113
+ });
114
+ } catch { /* denial audit best effort; fail closed regardless */ }
115
+ }
116
+
117
+ /** Production gate: forget/restore must notice a broken audit chain, not only admins. */
118
+ function requireAuditChain(root, now, action, recordId) {
119
+ const chain = verifyAuditChain({ memoryRoot: root });
120
+ if (chain.ok !== true) {
121
+ auditDenied({
122
+ root,
123
+ action,
124
+ recordId,
125
+ code: 'E_AUDIT_CHAIN_BROKEN',
126
+ extra: { chainViolation: true, auditChain: chain },
127
+ now,
128
+ skipHead: true,
129
+ });
130
+ throw memoryError('E_AUDIT_CHAIN_BROKEN', '审计哈希链/头锚损坏:拒绝遗忘/恢复(fail-closed)', { auditChain: chain });
131
+ }
132
+ return chain;
133
+ }
134
+
135
+ function requireKey(root, now, action, recordId) {
136
+ const resolved = resolveAuditKey(root);
137
+ if (resolved.key === null) {
138
+ auditDenied({ root, action, recordId, code: 'E_NO_KEY', extra: { hmacResidual: 'hostkey-unavailable' }, now });
139
+ throw memoryError('E_NO_KEY', '遗忘执行/恢复需要宿主 auditKey(env 或 .longlive/.hostkey);未提供 key,fail-closed');
140
+ }
141
+ return resolved.key;
142
+ }
143
+
144
+ function sourceAnchorFor(memoryRoot, rel) {
145
+ try {
146
+ const link = readCardAnchors(memoryRoot).get(String(rel ?? '').replace(/\\/g, '/'));
147
+ const anchor = Array.isArray(link?.anchors) ? link.anchors.find((item) => item
148
+ && (item.kind === 'session_turn' || item.kind === 'file_line')) : null;
149
+ if (anchor) return { anchor, sourceEventIds: Array.isArray(link.sourceEventIds) ? link.sourceEventIds : [] };
150
+ } catch { /* legacy sidecar unreadable: no fake anchor */ }
151
+ return {
152
+ anchor: { kind: 'legacy', status: 'missing', reason: 'no-explicit-source-anchor' },
153
+ sourceEventIds: [],
154
+ };
155
+ }
156
+
157
+ function richTombstoneLine({ prefix, moved, planned, source, rule, params }) {
158
+ const backup = { path: moved.backupRel, sha256: moved.sha256 };
159
+ return `${prefix} | ${moved.path} | ${moved.kind} | reason: ${planned.epitaph.reason}` +
160
+ ` | affects: ${planned.epitaph.affects} | stillUseful: ${planned.epitaph.stillUseful}` +
161
+ ` | recoverable: ${moved.destRel} | sha256: ${moved.sha256}` +
162
+ ` | sourceAnchor: ${JSON.stringify(source.anchor)}` +
163
+ ` | sourceEventIds: ${JSON.stringify(source.sourceEventIds)}` +
164
+ ` | rule: ${JSON.stringify(rule)} | params: ${JSON.stringify(params)}` +
165
+ ` | backup: ${JSON.stringify(backup)}`;
166
+ }
167
+
168
+ function enrichForgottenLedger({ root, result, anchorsByPath, args, now, key }) {
169
+ const tombFile = tombstonesPath(root);
170
+ if (!fs.existsSync(tombFile)) return { enriched: 0, missingLines: [] };
171
+ let text = fs.readFileSync(tombFile, 'utf8');
172
+ const lines = text.split(/\r?\n/);
173
+ let previousSigned = lastSignedTombstoneHash(text);
174
+ let enriched = 0;
175
+ const missingLines = [];
176
+ for (const moved of Array.isArray(result.archived) ? result.archived : []) {
177
+ const planned = (Array.isArray(result.proposals) ? result.proposals : []).find((item) => item.path === moved.path);
178
+ if (!planned) { missingLines.push(moved.path); continue; }
179
+ const marker = ` | ${moved.path} | `;
180
+ const index = lines.findIndex((line) => line.startsWith('- ') && line.includes(marker));
181
+ if (index < 0) { missingLines.push(moved.path); continue; }
182
+ const cut = lines[index].indexOf(marker);
183
+ const prefix = lines[index].slice(0, cut);
184
+ const source = anchorsByPath.get(moved.path) ?? sourceAnchorFor(root, moved.path);
185
+ const params = {
186
+ today: result.today ?? null,
187
+ confirm: 'FORGET',
188
+ by: typeof args?.by === 'string' && args.by !== '' ? args.by : 'admin',
189
+ at: now instanceof Date ? now.toISOString() : new Date(now).toISOString(),
190
+ };
191
+ const unsigned = richTombstoneLine({ prefix, moved, planned, source, rule: DECAY_RULES, params });
192
+ const signed = signTombstoneLine(unsigned, { key, previousHash: previousSigned });
193
+ lines[index] = signed.line;
194
+ previousSigned = signed.hash;
195
+ enriched += 1;
196
+ appendAuditRecord({
197
+ memoryRoot: root,
198
+ action: 'archive',
199
+ recordId: moved.path,
200
+ readerTier: 'host',
201
+ questionId: 'unknown',
202
+ expectedTurns: null,
203
+ answerText: null,
204
+ at: params.at,
205
+ extra: {
206
+ forgetV2: 'tombstone-enriched',
207
+ layer: 'L2',
208
+ archived: moved.destRel,
209
+ backup: { path: moved.backupRel, sha256: moved.sha256 },
210
+ sha256: moved.sha256,
211
+ sourceAnchor: source.anchor,
212
+ sourceEventIds: source.sourceEventIds,
213
+ rule: DECAY_RULES,
214
+ params,
215
+ },
216
+ });
217
+ }
218
+ if (enriched > 0) {
219
+ // Atomic rewrite + read-back verify after the append-only backup already exists.
220
+ writeFileAtomic(root, tombFile, `${lines.join('\n')}`, { backup: false, now });
221
+ }
222
+ return { enriched, missingLines };
223
+ }
224
+
225
+ /**
226
+ * Execute forgets (confirm gate stays in forget.js). P0: tombstone lines carry
227
+ * source anchor + backup sha + frozen rule/params; audit rows carry hash chain.
228
+ */
229
+ export function executeForget(args = {}, config = {}) {
230
+ const root = memoryRootFromConfig(config);
231
+ const now = config.now instanceof Date ? config.now : new Date();
232
+ const key = requireKey(root, now, 'forget-denied', null);
233
+ requireAuditChain(root, now, 'forget-denied', null);
234
+ const plan = proposeForget(args, config);
235
+ const anchorsByPath = new Map();
236
+ for (const item of Array.isArray(plan.proposals) ? plan.proposals : []) {
237
+ anchorsByPath.set(item.path, sourceAnchorFor(root, item.path));
238
+ }
239
+ const result = memoryForget({ ...args, confirm: 'FORGET' }, config);
240
+ if (result?.applied === true) {
241
+ const enriched = enrichForgottenLedger({ root, result, anchorsByPath, args, now, key });
242
+ return { ...result, tombstoneEnriched: enriched.enriched, tombstoneEnrichMissing: enriched.missingLines };
243
+ }
244
+ return result;
245
+ }
246
+
247
+ function parseJsonField(line, name) {
248
+ const marker = `${name}: `;
249
+ const start = line.indexOf(marker);
250
+ if (start < 0) return null;
251
+ const tail = line.slice(start + marker.length);
252
+ // Structured fields are compact JSON and end at the next " | " delimiter.
253
+ const end = tail.indexOf(' | ');
254
+ const raw = (end >= 0 ? tail.slice(0, end) : tail).trim();
255
+ try { return JSON.parse(raw); } catch { return null; }
256
+ }
257
+
258
+ function parseTombstoneFor(root, rel) {
259
+ const file = tombstonesPath(root);
260
+ let text;
261
+ try {
262
+ text = fs.readFileSync(file, 'utf8');
263
+ } catch (error) {
264
+ if (error?.code === 'ENOENT') return null;
265
+ throw error;
266
+ }
267
+ const normalized = String(rel).replace(/\\/g, '/');
268
+ for (const line of text.split(/\r?\n/)) {
269
+ if (!line.startsWith('- ')) continue;
270
+ if (!line.includes(`| ${normalized} |`)) continue;
271
+ const recoverable = line.match(/recoverable:\s*([^|\n]+)/);
272
+ const sha = line.match(/\bsha256:\s*([0-9a-f]{64})/);
273
+ return {
274
+ line,
275
+ recoverable: recoverable ? recoverable[1].trim() : null,
276
+ sha256: sha ? sha[1] : null,
277
+ sourceAnchor: parseJsonField(line, 'sourceAnchor'),
278
+ sourceEventIds: parseJsonField(line, 'sourceEventIds'),
279
+ backup: parseJsonField(line, 'backup'),
280
+ };
281
+ }
282
+ return null;
283
+ }
284
+
285
+ function auditRestore({ root, recordId, from, to, by, now }) {
286
+ try {
287
+ appendAuditRecord({
288
+ memoryRoot: root,
289
+ action: 'restore',
290
+ recordId,
291
+ readerTier: 'host',
292
+ questionId: 'unknown',
293
+ expectedTurns: null,
294
+ answerText: null,
295
+ at: now.toISOString(),
296
+ extra: { from, to, by },
297
+ });
298
+ } catch {
299
+ /* audit best-effort; the rename already happened and audit.log records it */
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Restore a forgotten card: tombstones.md 的 recoverable 目标优先,其次 epochs/。
305
+ * 只 rename(不删、不覆盖已有卡),写 1 行恢复审计 + 1 行 audit.log。
306
+ */
307
+ export function restoreForgotten({ root, path: relPath, by = 'admin', now = new Date() } = {}) {
308
+ const memoryRoot = String(root ?? '');
309
+ if (memoryRoot === '') throw memoryError('E_MEMORY_ROOT', 'restoreForgotten requires root');
310
+ const rel = String(relPath ?? '').replace(/\\/g, '/').replace(/^\/+/, '');
311
+ if (rel === '') throw memoryError('E_RESTORE_PATH', 'restore 需要原卡路径(topics/… 或 journal/…)');
312
+ const target = resolveInside(memoryRoot, rel);
313
+ if (fs.existsSync(target)) {
314
+ throw memoryError('E_RESTORE_TARGET', `恢复目标已存在(不覆盖):${rel}`);
315
+ }
316
+ const key = requireKey(memoryRoot, now, 'restore-denied', rel);
317
+ requireAuditChain(memoryRoot, now, 'restore-denied', rel);
318
+ const tomb = parseTombstoneFor(memoryRoot, rel);
319
+ if (!tomb) {
320
+ auditDenied({ root: memoryRoot, action: 'restore-denied', recordId: rel, code: 'E_TOMBSTONE_INCOMPLETE', extra: { reason: 'tombstone-missing' }, now });
321
+ throw memoryError('E_TOMBSTONE_INCOMPLETE', `恢复被拒绝:tombstones.md 找不到 ${rel} 的完整条目`);
322
+ }
323
+ if (!tomb.backup || typeof tomb.backup.path !== 'string' || tomb.backup.path === ''
324
+ || typeof tomb.backup.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(tomb.backup.sha256)) {
325
+ auditDenied({ root: memoryRoot, action: 'restore-denied', recordId: rel, code: 'E_TOMBSTONE_INCOMPLETE', extra: { reason: 'missing-backup-field', tombstone: tomb.line }, now });
326
+ throw memoryError('E_TOMBSTONE_INCOMPLETE', `恢复被拒绝:tombstone 缺 backup{path,sha256}:${rel}`);
327
+ }
328
+ const ledger = verifyTombstoneLedger(memoryRoot, key);
329
+ if (!ledger.ok) {
330
+ auditDenied({ root: memoryRoot, action: 'restore-denied', recordId: rel, code: ledger.code, extra: { reason: 'tombstone-signature-chain', ledger }, now });
331
+ throw memoryError(ledger.code, `恢复被拒绝:tombstone 签名/哈希链无效(${ledger.code}, line=${ledger.line ?? '-'})`);
332
+ }
333
+ let backupVerified = null;
334
+ {
335
+ const expected = tomb.backup.sha256;
336
+ let actual = null;
337
+ try { actual = sha256File(resolveInside(memoryRoot, tomb.backup.path)); } catch { actual = null; }
338
+ if (actual === null || actual !== expected) {
339
+ auditDenied({
340
+ root: memoryRoot,
341
+ action: 'restore-denied',
342
+ recordId: rel,
343
+ code: 'E_RESTORE_BACKUP_MISMATCH',
344
+ extra: { backup: tomb.backup, expectedSha256: expected, actualSha256: actual, sourceAnchor: tomb.sourceAnchor ?? null },
345
+ now,
346
+ });
347
+ throw memoryError('E_RESTORE_BACKUP_MISMATCH', `恢复前备份校验失败:${rel}(backup=${tomb.backup.path})`, {
348
+ expectedSha256: expected,
349
+ actualSha256: actual,
350
+ });
351
+ }
352
+ backupVerified = true;
353
+ }
354
+ let from = null;
355
+ let source = null;
356
+ if (tomb?.recoverable) {
357
+ const candidate = resolveInside(memoryRoot, tomb.recoverable);
358
+ if (fs.existsSync(candidate)) {
359
+ source = candidate;
360
+ from = tomb.recoverable;
361
+ }
362
+ }
363
+ if (!source) {
364
+ const epochCandidate = path.join(epochsDir(memoryRoot), rel);
365
+ if (fs.existsSync(epochCandidate)) {
366
+ source = epochCandidate;
367
+ from = relativePath(memoryRoot, epochCandidate);
368
+ }
369
+ }
370
+ if (!source) throw memoryError('E_RESTORE_NOT_FOUND', `找不到可恢复的副本:${rel}(tombstones recoverable / epochs 均未命中)`);
371
+ if (isProtectedPath({ memoryRoot, relPath: rel }) === true) {
372
+ // 恢复本身是“加回”而不是“删除”,保护卡也允许恢复(保留 fail-safe 只在删除方向)。
373
+ }
374
+ ensureDir(memoryRoot, path.dirname(target));
375
+ fs.renameSync(source, target);
376
+ appendAudit(memoryRoot, { action: 'memory_forget_restore', object: rel, result: `from=${from} by=${by}`, now });
377
+ auditRestore({ root: memoryRoot, recordId: rel, from, to: rel, by, now });
378
+ try {
379
+ appendAuditRecord({
380
+ memoryRoot,
381
+ action: 'restore',
382
+ recordId: rel,
383
+ readerTier: 'host',
384
+ questionId: 'unknown',
385
+ expectedTurns: null,
386
+ answerText: null,
387
+ at: now.toISOString(),
388
+ extra: {
389
+ forgetV2: 'restore-enriched',
390
+ from,
391
+ to: rel,
392
+ by,
393
+ backup: tomb?.backup ?? null,
394
+ backupSha256: tomb?.backup?.sha256 ?? tomb?.sha256 ?? null,
395
+ backupVerified,
396
+ sourceAnchor: tomb?.sourceAnchor ?? null,
397
+ sourceEventIds: tomb?.sourceEventIds ?? [],
398
+ },
399
+ });
400
+ } catch { /* best effort; rename + audit.log already recorded */ }
401
+ return { ok: true, schema: FORGET_V2_SCHEMA, status: 'restored', path: rel, from, to: rel, restored: true, backupVerified };
402
+ }
403
+
404
+ /** Facade: mode=propose|execute|restore. */
405
+ export function forgetV2({ mode = 'propose', path: relPath = null, by = 'admin', now = new Date(), ...rest } = {}, config = {}) {
406
+ if (mode === 'propose') return { schema: FORGET_V2_SCHEMA, mode, ...proposeForget(rest, config) };
407
+ if (mode === 'execute') return { schema: FORGET_V2_SCHEMA, mode, ...executeForget(rest, config) };
408
+ if (mode === 'restore') return { schema: FORGET_V2_SCHEMA, mode, ...restoreForgotten({ root: config.memoryRoot, path: relPath, by, now }) };
409
+ throw memoryError('E_FORGET_V2_MODE', `未知 mode=${mode}(propose|execute|restore)`);
410
+ }
@@ -20,6 +20,8 @@
20
20
  import fs from 'node:fs';
21
21
  import path from 'node:path';
22
22
 
23
+ import { appendAuditRecord, resolveAuditKey } from './audit-records.js';
24
+ import { isProtectedPath } from './retention.js';
23
25
  import { memoryRootFromConfig, resolveInside, relativePath, memoryError } from './paths.js';
24
26
  import {
25
27
  dayString, daysBetween, isoStamp, isDayString,
@@ -190,6 +192,20 @@ function proposalFor(card, todayDay) {
190
192
  return { skipped: false, kind: null };
191
193
  }
192
194
 
195
+ function readProtectedEntries(root) {
196
+ try {
197
+ const value = JSON.parse(fs.readFileSync(path.join(root, '.longlive', 'protected.json'), 'utf8'));
198
+ return Array.isArray(value?.entries) ? value.entries.filter((entry) => entry && typeof entry.id === 'string') : [];
199
+ } catch {
200
+ return [];
201
+ }
202
+ }
203
+
204
+ function protectedHit(root, rel) {
205
+ const normalized = String(rel).replace(/\\/g, '/');
206
+ return readProtectedEntries(root).find((entry) => String(entry.id).replace(/\\/g, '/') === normalized) ?? null;
207
+ }
208
+
193
209
  /**
194
210
  * memory_forget: dry-run unless `confirm === "FORGET"`. On execution each card
195
211
  * is backed up first, its tombstone line is rewritten before its move, then it
@@ -206,6 +222,27 @@ export function memoryForget(args = {}, config = {}) {
206
222
  `执行遗忘需要 confirm:"${CONFIRM_WORD}",收到 ${JSON.stringify(args.confirm)} — 未执行任何操作`,
207
223
  );
208
224
  }
225
+ // F1 (red-team P0): execution is host-key gated. Dry-run stays read-only and
226
+ // does not need a key; an actual move must not happen without an unlock key.
227
+ if (execute) {
228
+ const key = resolveAuditKey(root);
229
+ if (key.key === null) {
230
+ try {
231
+ appendAuditRecord({
232
+ memoryRoot: root,
233
+ action: 'forget-denied',
234
+ recordId: null,
235
+ readerTier: 'host',
236
+ questionId: 'unknown',
237
+ expectedTurns: null,
238
+ answerText: null,
239
+ at: now.toISOString(),
240
+ extra: { code: 'E_NO_KEY', hmacResidual: 'hostkey-unavailable' },
241
+ });
242
+ } catch { /* denial audit best effort; fail closed below */ }
243
+ throw memoryError('E_NO_KEY', '遗忘执行需要宿主 auditKey(env 或 .longlive/.hostkey);未提供 key,fail-closed');
244
+ }
245
+ }
209
246
 
210
247
  const cards = scanCards(root);
211
248
  const proposals = [];
@@ -286,6 +323,22 @@ export function memoryForget(args = {}, config = {}) {
286
323
  const card = cards.find((entry) => entry.rel === proposal.path);
287
324
  return planProposal(card, proposal, root, now);
288
325
  });
326
+ // GOV-07: protected entries are never forgotten by the tool path; refusal is audited
327
+ // in .longlive/audit/records.jsonl (single source of truth) BEFORE any scaffold write.
328
+ for (const item of planned) {
329
+ // A6: protected manifest + retention layering; frames/L0 are never forgettable here.
330
+ if (!isProtectedPath({ memoryRoot: root, relPath: item.path })) continue;
331
+ try {
332
+ const auditDir = path.join(root, '.longlive', 'audit');
333
+ fs.mkdirSync(auditDir, { recursive: true });
334
+ const row = { schema: 'audit-records/v1', questionId: 'unknown', expectedTurns: null, answerText: null, recordId: item.path, readerTier: 'host', action: 'denied', at: now.toISOString(), code: 'E_PROTECTED', protectedId: item.path };
335
+ fs.appendFileSync(path.join(auditDir, 'records.jsonl'), `${JSON.stringify(row)}\n`, 'utf8');
336
+ appendAuditRecord({ memoryRoot: root, action: 'denied', recordId: item.path, readerTier: 'host', questionId: 'unknown', expectedTurns: null, answerText: null, extra: { code: 'E_PROTECTED', protectedId: hit.id } });
337
+ } catch {
338
+ /* audit write failure must not downgrade the protected refusal */
339
+ }
340
+ throw memoryError('E_PROTECTED', `卡片在保护名单中,memory_forget 拒绝移动/删除:${item.path}(如需处理请经面板/人判入口)`);
341
+ }
289
342
  const created = createScaffold(root);
290
343
 
291
344
  const tombFile = tombstonesPath(root);
@@ -333,6 +386,24 @@ export function memoryForget(args = {}, config = {}) {
333
386
  });
334
387
  }
335
388
 
389
+ // A6: executed retention moves leave one unified audit row each (write/archive).
390
+ for (const item of moved) {
391
+ try {
392
+ appendAuditRecord({
393
+ memoryRoot: root,
394
+ action: 'archive',
395
+ recordId: item.path,
396
+ readerTier: 'host',
397
+ questionId: 'unknown',
398
+ expectedTurns: null,
399
+ answerText: null,
400
+ at: now.toISOString(),
401
+ extra: { layer: 'L2', archived: item.archived, backup: item.backupRel, sha256: item.sha256, tombstone: true },
402
+ });
403
+ } catch {
404
+ /* audit is best effort; executed move already recorded in audit.log */
405
+ }
406
+ }
336
407
  const tombstoneLines = countTombstoneLines(tombFile);
337
408
  appendAudit(root, {
338
409
  action: 'memory_forget',