@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
package/src/memory/recall.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|