@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.
- package/CHANGELOG.md +70 -1508
- package/LICENSE +1 -1
- package/QUICKSTART.md +34 -13
- package/README.en.md +18 -7
- package/README.md +34 -9
- package/SPEC.md +5 -5
- package/cordis.patch.yml +23 -9
- package/docs/ANSWER-GUARD.md +92 -0
- package/docs/ARCHITECTURE.md +4 -3
- package/docs/AUTO-RECALL-WIRING.md +1 -1
- package/docs/AUTO-RECALL.md +15 -9
- package/docs/CONFIG.md +168 -35
- package/docs/ERRORS.md +17 -5
- package/docs/FAQ.md +7 -7
- package/docs/FEATURES.md +96 -28
- package/docs/MIGRATION-1.6.0.md +2 -2
- package/docs/OPERATIONS.md +6 -6
- package/docs/PANEL-INTEGRATION.md +6 -0
- package/docs/PRIVACY.md +88 -0
- package/docs/QUICKSTART.md +5 -5
- package/docs/README.en.md +4 -2
- package/docs/RESIDENT-WATCHER.md +1 -1
- package/docs/SESSION-WATCH.md +6 -6
- package/docs/TROUBLESHOOTING.md +4 -4
- package/package.json +47 -117
- package/runbook.md +2 -2
- package/scripts/backfill-auto-cards.mjs +98 -0
- package/scripts/install-surgery-task.ps1 +5 -2
- package/scripts/lib/session-watch-fixture.mjs +1 -1
- package/scripts/longlive-doctor.mjs +6 -0
- package/scripts/migrate-180.mjs +163 -0
- package/scripts/recall-index-build.mjs +128 -0
- package/scripts/session-watch-drill.mjs +1 -1
- package/scripts/surgery-run.cmd +1 -1
- package/scripts/unattended-surgery.ps1 +5 -2
- package/scripts/verify-card-anchors.mjs +138 -0
- package/src/actions/audit.js +125 -0
- package/src/actions/evidence.js +122 -0
- package/src/actions/fs.js +162 -0
- package/src/activity/index.js +760 -0
- package/src/activity/model.js +351 -0
- package/src/activity/store.js +644 -0
- package/src/activity/thinking.js +176 -0
- package/src/adapters/constants.js +11 -0
- package/src/adapters/engram/data/aliases.json +51 -0
- package/src/adapters/engram/data/policy.json +8 -0
- package/src/adapters/engram/data/routing-rules.json +546 -0
- package/src/adapters/engram/index.js +235 -0
- package/src/adapters/registry.js +315 -0
- package/src/answer/audit.js +193 -0
- package/src/answer/evidence.js +30 -0
- package/src/answer/guard.js +1370 -0
- package/src/answer/index.js +1067 -0
- package/src/answer/query.js +111 -0
- package/src/answer/second-hop.mjs +101 -0
- package/src/auto-truncate.js +1 -1
- package/src/config.js +171 -47
- package/src/deps.js +6 -4
- package/src/features.js +182 -6
- package/src/heartbeat.js +156 -23
- package/src/identity/handover.js +52 -1
- package/src/identity/index.js +13 -5
- package/src/identity/inject-position.js +2 -2
- package/src/index.js +97 -7
- package/src/lifecycle/gap-clocks.js +328 -0
- package/src/lifecycle/index.js +269 -0
- package/src/live.js +22 -2
- package/src/memory/activity-capture.js +229 -0
- package/src/memory/anchors.js +194 -0
- package/src/memory/audit-records.js +222 -0
- package/src/memory/auto-cards.js +401 -0
- package/src/memory/capture.js +442 -0
- package/src/memory/card-anchors.js +203 -0
- package/src/memory/conflicts.js +106 -0
- package/src/memory/decay.js +311 -0
- package/src/memory/distill.js +471 -0
- package/src/memory/event-index.js +917 -0
- package/src/memory/evidence-chain.js +295 -0
- package/src/memory/evidence.js +145 -0
- package/src/memory/filter-runner.js +1 -1
- package/src/memory/filter.js +5 -1
- package/src/memory/forget-v2.js +410 -0
- package/src/memory/forget.js +71 -0
- package/src/memory/frames-activation.js +247 -0
- package/src/memory/fts-query.js +174 -0
- package/src/memory/governance.js +604 -0
- package/src/memory/index.js +1 -0
- package/src/memory/l2-gate.js +169 -0
- package/src/memory/lock.js +121 -0
- package/src/memory/migrate.js +139 -0
- package/src/memory/recall-v2.js +223 -0
- package/src/memory/recall.js +65 -2
- package/src/memory/records.js +425 -0
- package/src/memory/retention.js +128 -0
- package/src/memory/review.js +124 -0
- package/src/memory/trust.js +88 -0
- package/src/memory/version-chain.js +264 -0
- package/src/memory/wal.js +137 -0
- package/src/memory/working-frame.js +516 -0
- package/src/metrics/classify.mjs +5 -3
- package/src/metrics/index.mjs +21 -1
- package/src/metrics/prestep-latency.mjs +300 -0
- package/src/metrics/store.mjs +25 -0
- package/src/policy-profile.js +18 -3
- package/src/recall/anaphora.mjs +87 -0
- package/src/recall/cards.mjs +7 -0
- package/src/recall/embedding.mjs +8 -8
- package/src/recall/entities.mjs +112 -0
- package/src/recall/index.js +235 -19
- package/src/recall/inject-order.mjs +10 -8
- package/src/recall/privacy.mjs +105 -16
- package/src/recall/recall-core.mjs +116 -10
- package/src/recall/shared-visibility.js +302 -0
- package/src/recall/time-normalizer.mjs +196 -0
- package/src/role/activation.js +191 -0
- package/src/role/attention.js +318 -0
- package/src/role/base.js +36 -0
- package/src/role/channels.js +423 -0
- package/src/role/index.js +425 -0
- package/src/role/interfaces.js +115 -0
- package/src/role/personality.js +370 -0
- package/src/role/readers.js +151 -0
- package/src/role/relationships.js +477 -0
- package/src/role/store.js +202 -0
- package/src/role/writeprotect.js +373 -0
- package/src/router/aliases.json +176 -25
- package/src/router/background/agent-runner.mjs +1 -1
- package/src/router/background/anchor.mjs +1 -1
- package/src/router/background/audit.mjs +2 -2
- package/src/router/background/budget-ledger.mjs +2 -2
- package/src/router/background/host-sim.mjs +1 -1
- package/src/router/background/memory.mjs +4 -4
- package/src/router/background/queue.mjs +1 -1
- package/src/router/background/retry.mjs +1 -1
- package/src/router/background/router.mjs +1 -1
- package/src/router/background/stream.mjs +1 -1
- package/src/router/background/tool-policy.mjs +1 -1
- package/src/router/design-headings.json +5 -5
- package/src/router/face/config.mjs +1 -1
- package/src/router/face/identity.mjs +3 -3
- package/src/router/judge.mjs +8 -7
- package/src/router/policies/generic.json +21 -4
- package/src/router/route.js +109 -22
- package/src/router/router.mjs +10 -6
- package/src/router/routing-rules.json +260 -445
- package/src/router/run-sim.mjs +8 -8
- package/src/router/runtime.js +23 -12
- package/src/router/service/board.mjs +95 -15
- package/src/router/service/config.mjs +5 -5
- package/src/router/service/context.mjs +1 -1
- package/src/router/service/hint.mjs +14 -0
- package/src/router/service/queue.mjs +1 -1
- package/src/router/service/service.mjs +27 -11
- package/src/router/shadow.mjs +4 -3
- package/src/surgery-recovery.js +2 -2
- package/src/tool-output.js +2 -2
- package/src/tools.js +39 -7
- package/src/truncate-visible.js +2 -2
- package/src/ui/actions.js +1312 -0
- package/src/ui/panel-api.js +367 -0
- package/src/ui/panel-auth.js +251 -0
- package/src/ui/panel-integrity.js +243 -0
- package/src/ui/routes-readonly.js +695 -0
- package/src/ui/routes.js +3 -0
- package/docs/DEV.md +0 -279
- package/docs/PRESENCE-BUS-WIRING.md +0 -491
- package/docs/PRESENCE-BUS.md +0 -694
- package/docs/SURGERY.md +0 -186
- package/scripts/mark-core-cards.mjs +0 -289
- package/scripts/pack.mjs +0 -392
- package/scripts/router-proposals.mjs +0 -386
- package/src/router/fixtures/M1.json +0 -16
- package/src/router/fixtures/M2.json +0 -16
- package/src/router/fixtures/M3.json +0 -20
- package/src/router/fixtures/M4.json +0 -20
- package/src/router/fixtures/M5.json +0 -45
- package/src/router/fixtures/M6.json +0 -53
- package/src/router/fixtures/M7.json +0 -46
- package/src/router/fixtures/S1.json +0 -20
- package/src/router/fixtures/S2.json +0 -23
- package/src/router/fixtures/S3.json +0 -20
- package/src/router/fixtures/S4.json +0 -34
- package/src/router/fixtures/S5.json +0 -43
- package/src/router/fixtures/S6.json +0 -47
- package/src/router/fixtures/T1.json +0 -23
- package/src/router/fixtures/T2.json +0 -16
- package/src/router/fixtures/T3.json +0 -19
- package/src/router/fixtures/T4.json +0 -16
- package/src/router/fixtures/X1.json +0 -17
- package/src/router/fixtures/X2.json +0 -20
- package/src/router/gen-design-headings.mjs +0 -107
- package/src/router/policies/owner-rulings-2026-11-03.json +0 -62
- package/test/auto-recall.test.mjs +0 -474
- package/test/auto-truncate.test.mjs +0 -931
- package/test/autobind.test.mjs +0 -385
- package/test/bus.test.mjs +0 -1572
- package/test/cli-guard.test.mjs +0 -198
- package/test/conclusion.test.mjs +0 -408
- package/test/config-schema.test.mjs +0 -204
- package/test/d6-output.test.mjs +0 -194
- package/test/d6.test.mjs +0 -395
- package/test/deps.test.mjs +0 -342
- package/test/doctor-setup.test.mjs +0 -297
- package/test/embedding-recall.test.mjs +0 -347
- package/test/feature-flags.test.mjs +0 -568
- package/test/filter-shadow.test.mjs +0 -170
- package/test/fixtures/baseline-0.8.0-registration.json +0 -556
- package/test/fixtures/d6-header-tools.json +0 -1
- package/test/fixtures/lock-child.mjs +0 -67
- package/test/fixtures/pre-step-container.mjs +0 -100
- package/test/fixtures/privacy-memory/topics/pii-unmarked.md +0 -9
- package/test/fixtures/privacy-memory/topics/secret-local-only.md +0 -11
- package/test/fixtures/profile-session-longlive-config.json +0 -20
- package/test/fixtures/proposal-cas-child.mjs +0 -66
- package/test/host-engine-status.test.mjs +0 -307
- package/test/hot-truncate.test.mjs +0 -1349
- package/test/identity-card-marker.test.mjs +0 -307
- package/test/identity-router/run.mjs +0 -721
- package/test/integration-pre-step.test.mjs +0 -553
- package/test/live.test.mjs +0 -1020
- package/test/maintenance-schedule.test.mjs +0 -538
- package/test/memory.test.mjs +0 -2828
- package/test/metrics.test.mjs +0 -478
- package/test/orchestrator.test.mjs +0 -266
- package/test/p1_probe.mjs +0 -97
- package/test/packaging.test.mjs +0 -182
- package/test/portability.test.mjs +0 -343
- package/test/rc4-regressions.test.mjs +0 -562
- package/test/rc5-regressions.test.mjs +0 -197
- package/test/rc6-chinese.test.mjs +0 -394
- package/test/router-1100/apply-off.test.mjs +0 -175
- package/test/router-1100/budget-reconcile.test.mjs +0 -215
- package/test/router-1100/config-context.test.mjs +0 -211
- package/test/router-1100/helpers.mjs +0 -129
- package/test/router-1100/hint-envelope.test.mjs +0 -150
- package/test/router-1100/lock-crossproc.test.mjs +0 -206
- package/test/router-1100/proposal-cas-crossproc.test.mjs +0 -184
- package/test/router-1100/router-apply.test.mjs +0 -372
- package/test/router-1100/router-fixb.test.mjs +0 -602
- package/test/router-1100/router-proposals.test.mjs +0 -405
- package/test/router-1100/router-queue-lease-wal.test.mjs +0 -355
- package/test/router-1100/router-security-cr.test.mjs +0 -333
- package/test/router-1100/service-lifecycle.test.mjs +0 -220
- package/test/router-1100/tools-contract.test.mjs +0 -164
- package/test/router-1100/turn-reflect-tick.test.mjs +0 -89
- package/test/router-1100/v6-contract.test.mjs +0 -158
- package/test/router-1100/v6b-contract.test.mjs +0 -293
- package/test/router-1100/v6c-contract.test.mjs +0 -249
- package/test/router-1100/wal-rotation.test.mjs +0 -375
- package/test/router-1100/wp1-config.test.mjs +0 -170
- package/test/router-1100/wp1-default-off.test.mjs +0 -77
- package/test/router-1100/wp1-model-status.test.mjs +0 -90
- package/test/router-1100/wp1-status.test.mjs +0 -220
- package/test/router-1100/wp1-tools.test.mjs +0 -146
- package/test/router-1100/wp5-cli.test.mjs +0 -240
- package/test/router-1100/wp5-model-auto.test.mjs +0 -418
- package/test/router-1100/wp6-merge.test.mjs +0 -386
- package/test/router-1100/wp6-wake.test.mjs +0 -502
- package/test/router-background/audit.test.mjs +0 -37
- package/test/router-background/budget-ledger.test.mjs +0 -330
- package/test/router-background/cross-process.test.mjs +0 -222
- package/test/router-background/fixtures/cross-child.mjs +0 -85
- package/test/router-background/fixtures/real-shapes.mjs +0 -239
- package/test/router-background/router.test.mjs +0 -1044
- package/test/router-background/stream-retry.test.mjs +0 -358
- package/test/router-background/tool-agent.test.mjs +0 -384
- package/test/router-face/bridge.test.mjs +0 -169
- package/test/router-face/face.test.mjs +0 -316
- package/test/router-failclosed.test.mjs +0 -466
- package/test/router-identity.test.mjs +0 -384
- package/test/router-l1-l2.contract.test.mjs +0 -1075
- package/test/router-memory-tools.test.mjs +0 -262
- package/test/router-policy.test.mjs +0 -225
- package/test/router-runtime.test.mjs +0 -438
- package/test/router-shadow.test.mjs +0 -287
- package/test/router.test.mjs +0 -903
- package/test/run-rc.mjs +0 -299
- package/test/run.mjs +0 -2469
- package/test/safety-defaults.test.mjs +0 -179
- package/test/seeded-load.test.mjs +0 -436
- package/test/session-original.test.mjs +0 -338
- package/test/surgery-recovery.test.mjs +0 -936
- package/test/t7_drive.mjs +0 -151
- package/test/tool-output.test.mjs +0 -393
- package/test/tool-schema.test.mjs +0 -177
- package/test/tools_contract.test.mjs +0 -716
- package/test/truncate-visible.test.mjs +0 -1150
- package/test/ui-flags.test.mjs +0 -477
- 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
|
+
}
|
package/src/memory/forget.js
CHANGED
|
@@ -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',
|