@remnic/core 9.69.45 → 9.69.46
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/dist/access-admin-ops-surface.d.ts +6 -6
- package/dist/access-admin-ops-surface.js +25 -25
- package/dist/access-audit.js +2 -2
- package/dist/access-authorization-probe.d.ts +6 -6
- package/dist/access-authorization-probe.js +26 -26
- package/dist/access-boundary.d.ts +6 -6
- package/dist/access-boundary.js +25 -25
- package/dist/access-cli.js +70 -69
- package/dist/access-cli.js.map +1 -1
- package/dist/access-extraction-force-flush.d.ts +6 -6
- package/dist/access-extraction-force-flush.js +25 -25
- package/dist/access-http-briefing.d.ts +6 -6
- package/dist/access-http-briefing.js +26 -26
- package/dist/access-http-lcm-compaction.d.ts +6 -6
- package/dist/access-http-lifecycle-flush.d.ts +6 -6
- package/dist/access-http-offline-stream.d.ts +6 -6
- package/dist/access-http-query.js +27 -27
- package/dist/access-http.d.ts +7 -7
- package/dist/access-http.js +41 -41
- package/dist/access-identity-continuity-surface.d.ts +4 -4
- package/dist/access-identity-continuity-surface.js +25 -25
- package/dist/access-lcm-surface.d.ts +6 -6
- package/dist/access-lcm-surface.js +25 -25
- package/dist/access-mcp-shared-context-tools.d.ts +6 -6
- package/dist/access-mcp.d.ts +6 -6
- package/dist/access-mcp.js +33 -33
- package/dist/access-namespace-preflight.js +25 -25
- package/dist/access-observe-transcript.d.ts +4 -4
- package/dist/access-observe-write-category.d.ts +2 -2
- package/dist/access-observe-write-category.js +4 -4
- package/dist/access-observe-write-surface.d.ts +6 -6
- package/dist/access-observe-write-surface.js +25 -25
- package/dist/access-offline-manifest.d.ts +4 -4
- package/dist/access-operations-batch.js +26 -26
- package/dist/access-operations.d.ts +6 -6
- package/dist/access-operations.js +32 -32
- package/dist/access-recall-concurrency.d.ts +6 -6
- package/dist/access-recall-concurrency.js +25 -25
- package/dist/access-recall-response.d.ts +6 -6
- package/dist/access-recall-response.js +25 -25
- package/dist/access-recall-surface.d.ts +6 -6
- package/dist/access-recall-surface.js +25 -25
- package/dist/access-schema.js +3 -3
- package/dist/{access-service-CDteK_Gt.d.ts → access-service-DGynYlbE.d.ts} +4 -4
- package/dist/access-service-helpers.d.ts +6 -6
- package/dist/access-service.d.ts +6 -6
- package/dist/access-service.js +25 -25
- package/dist/access-surface-catalog.d.ts +6 -6
- package/dist/access-surface-catalog.js +27 -27
- package/dist/access-wearables-meetings-surface.d.ts +2 -2
- package/dist/bootstrap.d.ts +4 -4
- package/dist/briefing-window.d.ts +1 -1
- package/dist/briefing.d.ts +1 -1
- package/dist/briefing.js +8 -8
- package/dist/buffer.d.ts +1 -1
- package/dist/causal-consolidation.js +9 -9
- package/dist/{chunk-R4HFCATE.js → chunk-3NYM6JA4.js} +2 -2
- package/dist/{chunk-WNMVUWI7.js → chunk-3UQIS2GC.js} +2 -2
- package/dist/{chunk-46A4RGBC.js → chunk-47C6DF43.js} +3 -3
- package/dist/{chunk-NRFUYVW7.js → chunk-4ECS33D4.js} +2 -2
- package/dist/{chunk-C3MCMFNV.js → chunk-5EHPB6RL.js} +2 -2
- package/dist/{chunk-SPN5YFXN.js → chunk-62NYUFNH.js} +7 -7
- package/dist/{chunk-TFS5FAAC.js → chunk-66QOUYNS.js} +66 -47
- package/dist/chunk-66QOUYNS.js.map +1 -0
- package/dist/{chunk-OOBUWMPY.js → chunk-7BNB7KHB.js} +4 -4
- package/dist/{chunk-Z5YNVWRD.js → chunk-AN2O4KZY.js} +2 -2
- package/dist/{chunk-JUOJGZPV.js → chunk-AYP2FZFA.js} +2 -2
- package/dist/{chunk-T2ZNUWUY.js → chunk-AYZHHMTK.js} +2 -2
- package/dist/{chunk-LQVXSRAJ.js → chunk-B4EGAHHS.js} +2 -2
- package/dist/{chunk-JPNUKKSL.js → chunk-CFIHVWYZ.js} +2 -2
- package/dist/{chunk-D3DJYGU2.js → chunk-CWMMKFO7.js} +3 -3
- package/dist/{chunk-OREGA7GY.js → chunk-D5DTZBTV.js} +6 -6
- package/dist/{chunk-BOWAP64Q.js → chunk-DDBLZJRV.js} +2 -2
- package/dist/{chunk-SHEHQBCG.js → chunk-DUJYRVUD.js} +638 -45
- package/dist/chunk-DUJYRVUD.js.map +1 -0
- package/dist/{chunk-DSHSQGSN.js → chunk-EAZ3YZKN.js} +2 -2
- package/dist/{chunk-WCQ7L54B.js → chunk-EEHQY7ER.js} +2 -2
- package/dist/{chunk-2WMXZYMS.js → chunk-F5V7KRQS.js} +2 -2
- package/dist/{chunk-HVGCQMTQ.js → chunk-FMEP2RB5.js} +2 -2
- package/dist/{chunk-NGAFYIHQ.js → chunk-FSH6KRKO.js} +2 -2
- package/dist/{chunk-E5VOKZQV.js → chunk-GGY7WKMB.js} +1 -1
- package/dist/{chunk-UJXNBZYJ.js → chunk-GYYLZOSO.js} +4 -4
- package/dist/{chunk-NKDQXDFL.js → chunk-HXDXR2FH.js} +17 -17
- package/dist/{chunk-OFBJD3V5.js → chunk-HYWPUYLO.js} +2 -2
- package/dist/{chunk-KQAFEZQX.js → chunk-IPLYGWQF.js} +5 -5
- package/dist/{chunk-ZQ533JDE.js → chunk-IXMWTNNY.js} +2 -2
- package/dist/{chunk-WMTYEQO5.js → chunk-J5OXWG66.js} +2 -2
- package/dist/{chunk-C3YEB7CR.js → chunk-JOP7W24J.js} +4 -4
- package/dist/{chunk-5TKHJ5RW.js → chunk-KF57IQFX.js} +12 -12
- package/dist/chunk-M4DB6ZNN.js +87 -0
- package/dist/chunk-M4DB6ZNN.js.map +1 -0
- package/dist/{chunk-5XTILJRI.js → chunk-N46SNYMC.js} +2 -2
- package/dist/{chunk-HBGSZ62Z.js → chunk-ORQ7AZMU.js} +5 -5
- package/dist/{chunk-ZLVURGAJ.js → chunk-OV7GJ4KW.js} +2 -2
- package/dist/{chunk-6II55MXS.js → chunk-P7S7BVBJ.js} +2 -2
- package/dist/{chunk-IAT5WFEU.js → chunk-QUFJ3NJE.js} +258 -794
- package/dist/chunk-QUFJ3NJE.js.map +1 -0
- package/dist/{chunk-LZAJONO5.js → chunk-S3SND7SO.js} +2 -2
- package/dist/{chunk-K75H7FSO.js → chunk-S7KDHFF2.js} +2 -2
- package/dist/{chunk-Q4MI2KCG.js → chunk-SRRZZHA5.js} +2 -2
- package/dist/{chunk-AOMUIF3R.js → chunk-SW42MMAH.js} +2 -2
- package/dist/{chunk-B55FYF6F.js → chunk-T5NI5K46.js} +2 -2
- package/dist/{chunk-J6NEOCQC.js → chunk-TE6B2UU6.js} +2 -2
- package/dist/{chunk-7K5Q6COX.js → chunk-TVVEYCNW.js} +4 -4
- package/dist/{chunk-T3F2HWIG.js → chunk-TZ44E732.js} +2 -2
- package/dist/{chunk-A2YN3FR4.js → chunk-U4ESGVZ2.js} +3 -3
- package/dist/{chunk-F6AYWLRN.js → chunk-UEJY6ZTQ.js} +2 -2
- package/dist/{chunk-7LKXP4QG.js → chunk-UGGOQKO6.js} +25 -25
- package/dist/{chunk-M7ECD3KN.js → chunk-UOREPQ3C.js} +2 -2
- package/dist/{chunk-IFULZYYQ.js → chunk-VGRVJ6Z6.js} +2 -2
- package/dist/{chunk-N6I257OM.js → chunk-WOBMSMUK.js} +3 -3
- package/dist/{chunk-VA5QRTZX.js → chunk-XLZZLUWL.js} +2 -2
- package/dist/{chunk-MDYZCPNQ.js → chunk-Y3XHIWVP.js} +2 -2
- package/dist/{chunk-2LRO73MN.js → chunk-YFVIA5A6.js} +2 -2
- package/dist/{chunk-YHSEUZV5.js → chunk-YUQPUK6H.js} +2 -2
- package/dist/{cli-CZDhIcxu.d.ts → cli-Cik3H7q1.d.ts} +3 -3
- package/dist/cli.d.ts +7 -7
- package/dist/cli.js +56 -56
- package/dist/coding/export-okf-codegraph.js +7 -7
- package/dist/compounding/engine.d.ts +1 -1
- package/dist/compounding/engine.js +8 -8
- package/dist/connectors/codex-materialize-runner.js +8 -8
- package/dist/connectors/index.js +8 -8
- package/dist/consolidation-provenance-check.d.ts +1 -1
- package/dist/consolidation-undo.d.ts +1 -1
- package/dist/contradiction/index.d.ts +2 -2
- package/dist/convergence-refresh.d.ts +1 -1
- package/dist/convergence-refresh.js +10 -10
- package/dist/corpus-watermark.js +10 -10
- package/dist/deep-recall-http-glue.d.ts +6 -6
- package/dist/deep-recall-http-glue.js +4 -4
- package/dist/deep-recall-mcp-tools.d.ts +6 -6
- package/dist/entity-retrieval.d.ts +1 -1
- package/dist/entity-retrieval.js +8 -8
- package/dist/explicit-capture.d.ts +4 -4
- package/dist/explicit-capture.js +2 -2
- package/dist/external-wiki-access.d.ts +6 -6
- package/dist/external-wiki-access.js +26 -26
- package/dist/external-wiki-mcp-tools.d.ts +6 -6
- package/dist/extraction.js +8 -8
- package/dist/harmonic-retrieval.js +1 -1
- package/dist/{index-TBPe6NvB.d.ts → index-Zb6SUV4R.d.ts} +1 -1
- package/dist/index.d.ts +14 -14
- package/dist/index.js +70 -69
- package/dist/lcm/index.js +3 -3
- package/dist/location/index.d.ts +3 -3
- package/dist/location/index.js +10 -10
- package/dist/maintenance/memory-governance.js +8 -8
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.d.ts +1 -1
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +8 -8
- package/dist/maintenance/rebuild-memory-projection.d.ts +1 -1
- package/dist/maintenance/rebuild-memory-projection.js +9 -9
- package/dist/{maintenance-B813YFWJ.d.ts → maintenance-Du_VRvVg.d.ts} +1 -1
- package/dist/mcp-memory-inspector-app.d.ts +6 -6
- package/dist/memory-age.d.ts +100 -0
- package/dist/memory-age.js +22 -0
- package/dist/memory-age.js.map +1 -0
- package/dist/memory-subject-cli.d.ts +6 -6
- package/dist/memory-subject.d.ts +6 -6
- package/dist/memory-worth-outcomes.d.ts +1 -1
- package/dist/namespaces/migrate.d.ts +1 -1
- package/dist/namespaces/migrate.js +9 -9
- package/dist/namespaces/search.js +3 -3
- package/dist/namespaces/storage.d.ts +1 -1
- package/dist/namespaces/storage.js +8 -8
- package/dist/operator-doctor-corpus.js +11 -11
- package/dist/operator-toolkit.d.ts +1 -1
- package/dist/operator-toolkit.js +16 -16
- package/dist/orchestration/compression-guideline-coordinator.d.ts +1 -1
- package/dist/orchestration/maintenance.d.ts +2 -2
- package/dist/orchestration/maintenance.js +16 -16
- package/dist/{orchestrator-BCvQavIS.d.ts → orchestrator-D9U3Zgjp.d.ts} +3 -3
- package/dist/orchestrator.d.ts +4 -4
- package/dist/orchestrator.js +70 -69
- package/dist/{public-http-NH72a6aa.d.ts → public-http-AabioFLh.d.ts} +1 -1
- package/dist/recall-navigation-http-glue.d.ts +6 -6
- package/dist/recall-navigation-mcp-tools.d.ts +6 -6
- package/dist/recall-navigation-operations.js +26 -26
- package/dist/recall-navigation.js +25 -25
- package/dist/search/factory.js +2 -2
- package/dist/search/index.js +4 -4
- package/dist/semantic-consolidation.js +9 -9
- package/dist/semantic-rule-promotion.js +8 -8
- package/dist/semantic-rule-verifier.js +8 -8
- package/dist/{service-Echc5Xso.d.ts → service-ByjbCuj7.d.ts} +1 -1
- package/dist/source-agent-qualifier.js +8 -8
- package/dist/standup/build.js +8 -8
- package/dist/{storage-ClkHCQSE.d.ts → storage-CbdF077z.d.ts} +16 -2
- package/dist/storage.d.ts +1 -1
- package/dist/storage.js +7 -7
- package/dist/support-passport/index.d.ts +9 -9
- package/dist/support-passport/index.js +28 -28
- package/dist/{tagging-ksktJ60G.d.ts → tagging-CF7DNV7_.d.ts} +1 -1
- package/dist/tier-migration.d.ts +1 -1
- package/dist/transfer/autodetect.js +1 -1
- package/dist/transfer/backup.js +1 -1
- package/dist/transfer/capsule-export.js +2 -2
- package/dist/transfer/export-okf.js +8 -8
- package/dist/verified-recall.js +8 -8
- package/dist/who-knows-cli.d.ts +6 -6
- package/dist/who-knows-cli.js +9 -9
- package/dist/who-knows.d.ts +6 -6
- package/dist/who-knows.js +8 -8
- package/package.json +2 -2
- package/src/dedup/merge.test.ts +185 -8
- package/src/memory-age.test.ts +106 -0
- package/src/memory-age.ts +196 -0
- package/src/orchestration/recall-search-pipeline.test.ts +207 -1
- package/src/orchestration/recall-search-pipeline.ts +13 -9
- package/src/orchestration/semantic-merge-commit-effects.ts +31 -9
- package/src/orchestration/semantic-merge-persist.ts +16 -5
- package/src/storage/cas-revision-host.ts +22 -0
- package/src/storage/cas-revision-store.test.ts +44 -1
- package/src/storage/cas-revision-store.ts +23 -0
- package/src/storage/tombstone-blocked-capture-sync.ts +26 -1
- package/src/storage-frontmatter-cas.test.ts +49 -1
- package/src/storage.ts +1 -1
- package/dist/chunk-IAT5WFEU.js.map +0 -1
- package/dist/chunk-SHEHQBCG.js.map +0 -1
- package/dist/chunk-TFS5FAAC.js.map +0 -1
- /package/dist/{chunk-R4HFCATE.js.map → chunk-3NYM6JA4.js.map} +0 -0
- /package/dist/{chunk-WNMVUWI7.js.map → chunk-3UQIS2GC.js.map} +0 -0
- /package/dist/{chunk-46A4RGBC.js.map → chunk-47C6DF43.js.map} +0 -0
- /package/dist/{chunk-NRFUYVW7.js.map → chunk-4ECS33D4.js.map} +0 -0
- /package/dist/{chunk-C3MCMFNV.js.map → chunk-5EHPB6RL.js.map} +0 -0
- /package/dist/{chunk-SPN5YFXN.js.map → chunk-62NYUFNH.js.map} +0 -0
- /package/dist/{chunk-OOBUWMPY.js.map → chunk-7BNB7KHB.js.map} +0 -0
- /package/dist/{chunk-Z5YNVWRD.js.map → chunk-AN2O4KZY.js.map} +0 -0
- /package/dist/{chunk-JUOJGZPV.js.map → chunk-AYP2FZFA.js.map} +0 -0
- /package/dist/{chunk-T2ZNUWUY.js.map → chunk-AYZHHMTK.js.map} +0 -0
- /package/dist/{chunk-LQVXSRAJ.js.map → chunk-B4EGAHHS.js.map} +0 -0
- /package/dist/{chunk-JPNUKKSL.js.map → chunk-CFIHVWYZ.js.map} +0 -0
- /package/dist/{chunk-D3DJYGU2.js.map → chunk-CWMMKFO7.js.map} +0 -0
- /package/dist/{chunk-OREGA7GY.js.map → chunk-D5DTZBTV.js.map} +0 -0
- /package/dist/{chunk-BOWAP64Q.js.map → chunk-DDBLZJRV.js.map} +0 -0
- /package/dist/{chunk-DSHSQGSN.js.map → chunk-EAZ3YZKN.js.map} +0 -0
- /package/dist/{chunk-WCQ7L54B.js.map → chunk-EEHQY7ER.js.map} +0 -0
- /package/dist/{chunk-2WMXZYMS.js.map → chunk-F5V7KRQS.js.map} +0 -0
- /package/dist/{chunk-HVGCQMTQ.js.map → chunk-FMEP2RB5.js.map} +0 -0
- /package/dist/{chunk-NGAFYIHQ.js.map → chunk-FSH6KRKO.js.map} +0 -0
- /package/dist/{chunk-E5VOKZQV.js.map → chunk-GGY7WKMB.js.map} +0 -0
- /package/dist/{chunk-UJXNBZYJ.js.map → chunk-GYYLZOSO.js.map} +0 -0
- /package/dist/{chunk-NKDQXDFL.js.map → chunk-HXDXR2FH.js.map} +0 -0
- /package/dist/{chunk-OFBJD3V5.js.map → chunk-HYWPUYLO.js.map} +0 -0
- /package/dist/{chunk-KQAFEZQX.js.map → chunk-IPLYGWQF.js.map} +0 -0
- /package/dist/{chunk-ZQ533JDE.js.map → chunk-IXMWTNNY.js.map} +0 -0
- /package/dist/{chunk-WMTYEQO5.js.map → chunk-J5OXWG66.js.map} +0 -0
- /package/dist/{chunk-C3YEB7CR.js.map → chunk-JOP7W24J.js.map} +0 -0
- /package/dist/{chunk-5TKHJ5RW.js.map → chunk-KF57IQFX.js.map} +0 -0
- /package/dist/{chunk-5XTILJRI.js.map → chunk-N46SNYMC.js.map} +0 -0
- /package/dist/{chunk-HBGSZ62Z.js.map → chunk-ORQ7AZMU.js.map} +0 -0
- /package/dist/{chunk-ZLVURGAJ.js.map → chunk-OV7GJ4KW.js.map} +0 -0
- /package/dist/{chunk-6II55MXS.js.map → chunk-P7S7BVBJ.js.map} +0 -0
- /package/dist/{chunk-LZAJONO5.js.map → chunk-S3SND7SO.js.map} +0 -0
- /package/dist/{chunk-K75H7FSO.js.map → chunk-S7KDHFF2.js.map} +0 -0
- /package/dist/{chunk-Q4MI2KCG.js.map → chunk-SRRZZHA5.js.map} +0 -0
- /package/dist/{chunk-AOMUIF3R.js.map → chunk-SW42MMAH.js.map} +0 -0
- /package/dist/{chunk-B55FYF6F.js.map → chunk-T5NI5K46.js.map} +0 -0
- /package/dist/{chunk-J6NEOCQC.js.map → chunk-TE6B2UU6.js.map} +0 -0
- /package/dist/{chunk-7K5Q6COX.js.map → chunk-TVVEYCNW.js.map} +0 -0
- /package/dist/{chunk-T3F2HWIG.js.map → chunk-TZ44E732.js.map} +0 -0
- /package/dist/{chunk-A2YN3FR4.js.map → chunk-U4ESGVZ2.js.map} +0 -0
- /package/dist/{chunk-F6AYWLRN.js.map → chunk-UEJY6ZTQ.js.map} +0 -0
- /package/dist/{chunk-7LKXP4QG.js.map → chunk-UGGOQKO6.js.map} +0 -0
- /package/dist/{chunk-M7ECD3KN.js.map → chunk-UOREPQ3C.js.map} +0 -0
- /package/dist/{chunk-IFULZYYQ.js.map → chunk-VGRVJ6Z6.js.map} +0 -0
- /package/dist/{chunk-N6I257OM.js.map → chunk-WOBMSMUK.js.map} +0 -0
- /package/dist/{chunk-VA5QRTZX.js.map → chunk-XLZZLUWL.js.map} +0 -0
- /package/dist/{chunk-MDYZCPNQ.js.map → chunk-Y3XHIWVP.js.map} +0 -0
- /package/dist/{chunk-2LRO73MN.js.map → chunk-YFVIA5A6.js.map} +0 -0
- /package/dist/{chunk-YHSEUZV5.js.map → chunk-YUQPUK6H.js.map} +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import { mkdir, mkdtemp, rm, utimes, writeFile } from "node:fs/promises";
|
|
5
5
|
import test from "node:test";
|
|
6
6
|
|
|
7
7
|
import { parseConfig } from "../config.js";
|
|
@@ -565,3 +565,209 @@ test("QMD stops after an underfilled archive-only page", async () => {
|
|
|
565
565
|
assert.deepEqual(out, []);
|
|
566
566
|
assert.equal(searchCalls, 1, "an underfilled raw page must not trigger widening");
|
|
567
567
|
});
|
|
568
|
+
|
|
569
|
+
// ─── #2976: memory age from content dates; bulk-touch mtime distrust ─────────
|
|
570
|
+
|
|
571
|
+
const AGE_DAY_MS = 86_400_000;
|
|
572
|
+
|
|
573
|
+
function scoredResult(namespace: string, p: string, score: number): QmdSearchResult {
|
|
574
|
+
return { docid: `${namespace}:${p}`, namespace, path: p, score, snippet: p };
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function ageMemory(absPath: string, frontmatter: Record<string, unknown>): MemoryFile {
|
|
578
|
+
return {
|
|
579
|
+
path: absPath,
|
|
580
|
+
content: absPath,
|
|
581
|
+
frontmatter: { status: "active", memoryKind: "fact", ...frontmatter } as unknown as MemoryFile["frontmatter"],
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function boostCoordinator(
|
|
586
|
+
memoryDir: string,
|
|
587
|
+
recencyWeight: number,
|
|
588
|
+
): RecallSearchPipelineCoordinator {
|
|
589
|
+
const config = parseConfig({
|
|
590
|
+
openaiApiKey: "sk-test",
|
|
591
|
+
memoryDir,
|
|
592
|
+
workspaceDir: path.join(memoryDir, "workspace"),
|
|
593
|
+
});
|
|
594
|
+
const deps = {
|
|
595
|
+
config,
|
|
596
|
+
effectiveRecencyWeight: () => recencyWeight,
|
|
597
|
+
filterSearchResultsForRecall: async (
|
|
598
|
+
results: QmdSearchResult[],
|
|
599
|
+
preloaded?: Map<string, MemoryFile>,
|
|
600
|
+
) => ({ results, memoryByPath: preloaded ?? new Map() }),
|
|
601
|
+
} as unknown as RecallSearchPipelineDeps;
|
|
602
|
+
return new RecallSearchPipelineCoordinator(deps);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
async function writeDatedFile(filePath: string, mtimeMs: number): Promise<void> {
|
|
606
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
607
|
+
await writeFile(filePath, "fixture\n");
|
|
608
|
+
await utimes(filePath, mtimeMs / 1000, mtimeMs / 1000);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function isoAgo(now: number, days: number): string {
|
|
612
|
+
return new Date(now - days * AGE_DAY_MS).toISOString();
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
test("#2976 content dates age a memory even when its mtime is fresh (updated backs a missing created)", async () => {
|
|
616
|
+
const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-age-content-"));
|
|
617
|
+
try {
|
|
618
|
+
const now = Date.now();
|
|
619
|
+
const fileA = path.join(memoryDir, "facts", "a.md");
|
|
620
|
+
const fileB = path.join(memoryDir, "facts", "b.md");
|
|
621
|
+
// Both files carry today's mtime — the bulk import / cp -r shape. A has
|
|
622
|
+
// no created, only a 40-day-old updated; B carries a 40-day-old created.
|
|
623
|
+
await writeDatedFile(fileA, now);
|
|
624
|
+
await writeDatedFile(fileB, now);
|
|
625
|
+
const memoryByPath = new Map<string, MemoryFile>([
|
|
626
|
+
["default\0facts/a.md", ageMemory(fileA, { updated: isoAgo(now, 40) })],
|
|
627
|
+
["default\0facts/b.md", ageMemory(fileB, { created: isoAgo(now, 40), updated: isoAgo(now, 40) })],
|
|
628
|
+
]);
|
|
629
|
+
|
|
630
|
+
const boosted = await boostCoordinator(memoryDir, 0.5).boostSearchResults(
|
|
631
|
+
[scoredResult("default", "facts/a.md", 1), scoredResult("default", "facts/b.md", 1)],
|
|
632
|
+
["default"],
|
|
633
|
+
undefined,
|
|
634
|
+
memoryByPath,
|
|
635
|
+
);
|
|
636
|
+
|
|
637
|
+
const [a, b] = boosted;
|
|
638
|
+
const expected = 1 * 0.5 + Math.pow(0.5, 40 / 7) * 0.5;
|
|
639
|
+
assert.ok(a && Number.isFinite(a.score), "a memory without created must not score NaN");
|
|
640
|
+
assert.ok(b && Number.isFinite(b.score));
|
|
641
|
+
assert.ok(Math.abs(a.score - expected) < 1e-9, `ages by the 40-day-old content date (${a?.score}), not the fresh mtime (1.0)`);
|
|
642
|
+
assert.equal(a.score, b.score, "updated and created of the same age produce the same score");
|
|
643
|
+
} finally {
|
|
644
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
test("#2976 a bulk-touch mtime cluster never inflates freshness (ranking unchanged vs pre-touch base order)", async () => {
|
|
649
|
+
const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-age-bulk-"));
|
|
650
|
+
try {
|
|
651
|
+
const now = Date.now();
|
|
652
|
+
const files = ["facts/x.md", "facts/y.md", "facts/z.md"];
|
|
653
|
+
const baseScores = [0.9, 0.8, 0.7];
|
|
654
|
+
const mtimes = [now - 10 * AGE_DAY_MS, now - 30 * AGE_DAY_MS, now - 90 * AGE_DAY_MS];
|
|
655
|
+
// Undated memories: only mtime can say how old they are.
|
|
656
|
+
const buildMap = () =>
|
|
657
|
+
new Map<string, MemoryFile>(
|
|
658
|
+
files.map((rel) => ["default\0" + rel, ageMemory(path.join(memoryDir, rel), {})]),
|
|
659
|
+
);
|
|
660
|
+
for (let i = 0; i < files.length; i += 1) {
|
|
661
|
+
await writeDatedFile(path.join(memoryDir, files[i]!), mtimes[i]!);
|
|
662
|
+
}
|
|
663
|
+
const results = () => files.map((rel, i) => scoredResult("default", rel, baseScores[i]!));
|
|
664
|
+
|
|
665
|
+
// Pre-touch: mtimes are scattered, so each trusted mtime ages its memory.
|
|
666
|
+
const preTouch = await boostCoordinator(memoryDir, 0.5).boostSearchResults(
|
|
667
|
+
results(),
|
|
668
|
+
["default"],
|
|
669
|
+
undefined,
|
|
670
|
+
buildMap(),
|
|
671
|
+
);
|
|
672
|
+
for (const r of preTouch) {
|
|
673
|
+
assert.ok(Number.isFinite(r.score), "an undated memory with a trusted mtime must not score NaN");
|
|
674
|
+
}
|
|
675
|
+
assert.deepEqual(
|
|
676
|
+
preTouch.map((r) => r.path),
|
|
677
|
+
["facts/x.md", "facts/y.md", "facts/z.md"],
|
|
678
|
+
"pre-touch, the scattered trusted mtimes preserve the base ranking",
|
|
679
|
+
);
|
|
680
|
+
|
|
681
|
+
// Bulk touch: every mtime reset to one calendar day (cp -r / restore).
|
|
682
|
+
const bulkDay = now - 1 * AGE_DAY_MS;
|
|
683
|
+
for (const rel of files) {
|
|
684
|
+
await writeDatedFile(path.join(memoryDir, rel), bulkDay);
|
|
685
|
+
}
|
|
686
|
+
const postTouch = await boostCoordinator(memoryDir, 0.5).boostSearchResults(
|
|
687
|
+
results(),
|
|
688
|
+
["default"],
|
|
689
|
+
undefined,
|
|
690
|
+
buildMap(),
|
|
691
|
+
);
|
|
692
|
+
assert.deepEqual(
|
|
693
|
+
postTouch.map((r) => r.score),
|
|
694
|
+
baseScores,
|
|
695
|
+
"a same-day mtime cluster is distrusted: no recency boost, scores stay at base",
|
|
696
|
+
);
|
|
697
|
+
assert.deepEqual(
|
|
698
|
+
postTouch.map((r) => r.path),
|
|
699
|
+
["facts/x.md", "facts/y.md", "facts/z.md"],
|
|
700
|
+
"ranking is unchanged versus the pre-touch base order",
|
|
701
|
+
);
|
|
702
|
+
} finally {
|
|
703
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
704
|
+
}
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
test("#2976 an undated memory falls back to a trusted scattered mtime; a dated sibling keeps its content age", async () => {
|
|
708
|
+
const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-age-fallback-"));
|
|
709
|
+
try {
|
|
710
|
+
const now = Date.now();
|
|
711
|
+
const fileA = path.join(memoryDir, "facts", "undated.md");
|
|
712
|
+
const fileB = path.join(memoryDir, "facts", "dated.md");
|
|
713
|
+
// A: no dates, mtime 2 days ago. B: created 2 days ago, mtime 5 days ago
|
|
714
|
+
// (different days, one file each — no cluster, mtime stays trusted).
|
|
715
|
+
await writeDatedFile(fileA, now - 2 * AGE_DAY_MS);
|
|
716
|
+
await writeDatedFile(fileB, now - 5 * AGE_DAY_MS);
|
|
717
|
+
const memoryByPath = new Map<string, MemoryFile>([
|
|
718
|
+
["default\0facts/undated.md", ageMemory(fileA, {})],
|
|
719
|
+
["default\0facts/dated.md", ageMemory(fileB, { created: isoAgo(now, 2), updated: isoAgo(now, 2) })],
|
|
720
|
+
]);
|
|
721
|
+
|
|
722
|
+
const boosted = await boostCoordinator(memoryDir, 0.5).boostSearchResults(
|
|
723
|
+
[scoredResult("default", "facts/undated.md", 1), scoredResult("default", "facts/dated.md", 1)],
|
|
724
|
+
["default"],
|
|
725
|
+
undefined,
|
|
726
|
+
memoryByPath,
|
|
727
|
+
);
|
|
728
|
+
|
|
729
|
+
const [undated, dated] = boosted;
|
|
730
|
+
// 1e-6 (not 1e-9): the pipeline stamps its own Date.now() after this
|
|
731
|
+
// test's fixture writes, so a few ms of clock drift shifts the 2-day
|
|
732
|
+
// blend in the 8th decimal.
|
|
733
|
+
const expected = 1 * 0.5 + Math.pow(0.5, 2 / 7) * 0.5;
|
|
734
|
+
assert.ok(undated && Number.isFinite(undated.score), "the undated memory must not score NaN");
|
|
735
|
+
assert.ok(
|
|
736
|
+
undated && Math.abs(undated.score - expected) < 1e-6,
|
|
737
|
+
`an undated memory ages by its trusted mtime (2 days): got ${undated?.score}, want ~${expected}`,
|
|
738
|
+
);
|
|
739
|
+
assert.ok(
|
|
740
|
+
undated && dated && Math.abs(undated.score - dated.score) < 1e-6,
|
|
741
|
+
"mtime fallback and a same-aged content date score identically",
|
|
742
|
+
);
|
|
743
|
+
} finally {
|
|
744
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
745
|
+
}
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
test("#2976 guard: a memory whose content date is older than its fresh mtime ages by the content date", async () => {
|
|
749
|
+
const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-age-guard-"));
|
|
750
|
+
try {
|
|
751
|
+
const now = Date.now();
|
|
752
|
+
const filePath = path.join(memoryDir, "facts", "old.md");
|
|
753
|
+
await writeDatedFile(filePath, now); // fresh mtime, 90-day-old content
|
|
754
|
+
const memoryByPath = new Map<string, MemoryFile>([
|
|
755
|
+
["default\0facts/old.md", ageMemory(filePath, { created: isoAgo(now, 90), updated: isoAgo(now, 90) })],
|
|
756
|
+
]);
|
|
757
|
+
|
|
758
|
+
const boosted = await boostCoordinator(memoryDir, 0.5).boostSearchResults(
|
|
759
|
+
[scoredResult("default", "facts/old.md", 1)],
|
|
760
|
+
["default"],
|
|
761
|
+
undefined,
|
|
762
|
+
memoryByPath,
|
|
763
|
+
);
|
|
764
|
+
|
|
765
|
+
const expected = 1 * 0.5 + Math.pow(0.5, 90 / 7) * 0.5;
|
|
766
|
+
assert.ok(
|
|
767
|
+
boosted[0] && Math.abs(boosted[0].score - expected) < 1e-9,
|
|
768
|
+
"ages by the 90-day-old created date, not the fresh mtime",
|
|
769
|
+
);
|
|
770
|
+
} finally {
|
|
771
|
+
await rm(memoryDir, { recursive: true, force: true });
|
|
772
|
+
}
|
|
773
|
+
});
|
|
@@ -34,6 +34,7 @@ import { RerankCache, rerankLocalOrNoop, reorderByRankedKeys } from "../rerank.j
|
|
|
34
34
|
import type { SearchBackend, SearchDegradation, SearchExecutionOptions, SearchQueryOptions } from "../search/port.js";
|
|
35
35
|
import { SecureStoreLockedError } from "../secure-store/index.js";
|
|
36
36
|
import { isPathInsideStorageRoot } from "../storage-paths.js";
|
|
37
|
+
import { applyRecencyBoost, collectMtimeFallbackContext } from "../memory-age.js";
|
|
37
38
|
import type { CorpusReadOptions } from "../corpus-read-cancellation.js";
|
|
38
39
|
import { extractTagsFromPrompt, isTemporalQuery, queryByDateRangeAsync, queryByTagsAsync, readIndexSnapshotAsync, recencyWindowFromPrompt } from "../temporal-index.js";
|
|
39
40
|
import {
|
|
@@ -1558,20 +1559,23 @@ export class RecallSearchPipelineCoordinator {
|
|
|
1558
1559
|
|
|
1559
1560
|
const boosted: QmdSearchResult[] = [];
|
|
1560
1561
|
const recencyWeight = this.deps.effectiveRecencyWeight();
|
|
1562
|
+
// #2976: mtime backs age only for memories without content dates, and a
|
|
1563
|
+
// same-day mtime cluster (bulk import / restore / cp -r) is distrusted.
|
|
1564
|
+
const mtimeFallback =
|
|
1565
|
+
recencyWeight > 0
|
|
1566
|
+
? await collectMtimeFallbackContext(
|
|
1567
|
+
safeResults.flatMap((r) => { const m = memoryForResult(memoryByPath, r); return m ? [m] : []; }),
|
|
1568
|
+
)
|
|
1569
|
+
: null;
|
|
1561
1570
|
for (const r of safeResults) {
|
|
1562
1571
|
const memory = memoryForResult(memoryByPath, r);
|
|
1563
1572
|
let score = r.score;
|
|
1564
1573
|
|
|
1565
1574
|
if (memory) {
|
|
1566
|
-
// Recency boost: exponential decay over
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
const ageDays = ageMs / (1000 * 60 * 60 * 24);
|
|
1571
|
-
const halfLifeDays = 7;
|
|
1572
|
-
const recencyScore = Math.pow(0.5, ageDays / halfLifeDays);
|
|
1573
|
-
score = score * (1 - recencyWeight) + recencyScore * recencyWeight;
|
|
1574
|
-
}
|
|
1575
|
+
// Recency boost: 7-day exponential decay over the shared age resolver
|
|
1576
|
+
// (#2976): content dates first, trusted mtime as fallback, unknown
|
|
1577
|
+
// age never fresh. See memory-age.ts.
|
|
1578
|
+
score = applyRecencyBoost(score, memory, recencyWeight, now, mtimeFallback);
|
|
1575
1579
|
|
|
1576
1580
|
// Access count boost: log scale, capped
|
|
1577
1581
|
if (this.deps.config.boostAccessCount && memory.frontmatter.accessCount) {
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { GraphEdgeAppendError } from "../graph-append-error.js";
|
|
16
|
+
import type { CasRevisionReadStatus } from "../storage/cas-revision-store.js";
|
|
16
17
|
import type { GraphEdge, GraphType } from "../graph.js";
|
|
17
18
|
import { readFile } from "node:fs/promises";
|
|
18
19
|
import { removeNodeEdgesForRewrite, rollbackNodeEdgeRewrite } from "../graph-jsonl.js";
|
|
@@ -259,8 +260,8 @@ export async function persistMergedTargetThreadEpisode(
|
|
|
259
260
|
}
|
|
260
261
|
|
|
261
262
|
/**
|
|
262
|
-
* Round N+12 (C) + N+14: replace a merged target's generated graph
|
|
263
|
-
* with the rebuild's rows, revision-guarded end to end. The caller's
|
|
263
|
+
* Round N+12 (C) + N+14 + #2837: replace a merged target's generated graph
|
|
264
|
+
* edges with the rebuild's rows, revision-guarded end to end. The caller's
|
|
264
265
|
* committed-body check can pass, writer B can then commit a NEWER merge and
|
|
265
266
|
* finish rebuilding edges for it, and writer A would resume to remove B's
|
|
266
267
|
* edges and install edges derived from A's older body. The revision observed
|
|
@@ -271,6 +272,15 @@ export async function persistMergedTargetThreadEpisode(
|
|
|
271
272
|
* chain only then). A build failure rolls back the same way (round N+10 C)
|
|
272
273
|
* and rethrows — fail-open stays the caller's decision.
|
|
273
274
|
*
|
|
275
|
+
* #2837: the post-build guard reads the truthful receipt STATUS, never the
|
|
276
|
+
* fail-open token. The old guard collapsed an unreadable sidecar into
|
|
277
|
+
* undefined on both reads, so an unavailable-vs-unavailable rewrite compared
|
|
278
|
+
* undefined against undefined, passed, and installed replacement edges no
|
|
279
|
+
* receipt ever verified. An `unavailable` post-build status now aborts the
|
|
280
|
+
* install (rollback, false) so the next merge retries; only a PROVEN match —
|
|
281
|
+
* present-and-equal, or absent-against-absent for a pre-sidecar target —
|
|
282
|
+
* may install.
|
|
283
|
+
*
|
|
274
284
|
* Round N+14 makes the ROLLBACK surgical: `build` returns the exact rows it
|
|
275
285
|
* appended (by identity), and the rollback removes only those — never a
|
|
276
286
|
* node-wide sweep — so rows a newer writer appended after this writer's
|
|
@@ -283,13 +293,14 @@ export async function rewriteMergedTargetGraphEdges(
|
|
|
283
293
|
storage: {
|
|
284
294
|
dir: string;
|
|
285
295
|
getMemoryByIdIncludingArchived: (id: string) => Promise<MemoryFile | null>;
|
|
286
|
-
|
|
296
|
+
readCasRevisionStatus: (filePath: string) => Promise<CasRevisionReadStatus>;
|
|
287
297
|
},
|
|
288
298
|
input: {
|
|
289
299
|
targetId: string;
|
|
290
300
|
memoryRelPath: string;
|
|
291
301
|
mergedContent: string;
|
|
292
|
-
/** The CAS revision token the committed-body check observed
|
|
302
|
+
/** The CAS revision token the committed-body check observed (undefined
|
|
303
|
+
* only when the check PROVED the receipt absent — #2837). */
|
|
293
304
|
revisionChecked: string | undefined;
|
|
294
305
|
rewriteTypes: readonly GraphType[];
|
|
295
306
|
build: () => Promise<GraphEdge[] | void>;
|
|
@@ -306,15 +317,26 @@ export async function rewriteMergedTargetGraphEdges(
|
|
|
306
317
|
try {
|
|
307
318
|
appended = (await input.build()) ?? undefined;
|
|
308
319
|
const committedNow = await storage.getMemoryByIdIncludingArchived(input.targetId);
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
}
|
|
320
|
+
let standing: CasRevisionReadStatus;
|
|
321
|
+
if (committedNow === null) {
|
|
322
|
+
standing = { status: "absent" };
|
|
323
|
+
} else {
|
|
324
|
+
standing = await storage.readCasRevisionStatus(committedNow.path).catch((err: unknown) => ({
|
|
325
|
+
status: "unavailable" as const,
|
|
326
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
327
|
+
}));
|
|
328
|
+
}
|
|
313
329
|
if (
|
|
314
330
|
!committedNow ||
|
|
315
331
|
committedNow.content !== input.mergedContent ||
|
|
316
|
-
|
|
332
|
+
standing.status === "unavailable" ||
|
|
333
|
+
(standing.status === "present" ? standing.revision : undefined) !== input.revisionChecked
|
|
317
334
|
) {
|
|
335
|
+
if (standing.status === "unavailable") {
|
|
336
|
+
log.warn(
|
|
337
|
+
`rewriteMergedTargetGraphEdges: CAS revision status for ${input.memoryRelPath} is unavailable (${standing.reason}); aborting the install — an unverified replacement must not install (#2837)`,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
318
340
|
await rollbackNodeEdgeRewrite(storage.dir, input.memoryRelPath, input.rewriteTypes, removedEdges, input.targetId, appended);
|
|
319
341
|
return false;
|
|
320
342
|
}
|
|
@@ -1144,15 +1144,26 @@ export async function runMergedTargetPostEffects(
|
|
|
1144
1144
|
if (input.graphCaps.entityGraph) rewriteTypes.push("entity");
|
|
1145
1145
|
if (input.graphCaps.timeGraph) rewriteTypes.push("time");
|
|
1146
1146
|
if (input.graphCaps.causalGraph) rewriteTypes.push("causal");
|
|
1147
|
-
// Round N+12 (C): the remove-and-rebuild is revision-guarded
|
|
1148
|
-
// (see rewriteMergedTargetGraphEdges) — a writer committing
|
|
1149
|
-
// mid-rebuild aborts this install instead of clobbering
|
|
1147
|
+
// Round N+12 (C) + #2837: the remove-and-rebuild is revision-guarded
|
|
1148
|
+
// end to end (see rewriteMergedTargetGraphEdges) — a writer committing
|
|
1149
|
+
// a newer body mid-rebuild aborts this install instead of clobbering
|
|
1150
|
+
// its edges. The guard compares the truthful receipt STATUS (#2807:
|
|
1151
|
+
// the dedicated CAS revision token, never public `updated`), and an
|
|
1152
|
+
// UNAVAILABLE sidecar SKIPS the effect entirely: passing a fail-open
|
|
1153
|
+
// undefined into the install is what let an unchecked replacement
|
|
1154
|
+
// install when both sidecar reads failed.
|
|
1155
|
+
const receiptStatus = await storage.readCasRevisionStatus(committed.path);
|
|
1156
|
+
if (receiptStatus.status === "unavailable") {
|
|
1157
|
+
log.warn(
|
|
1158
|
+
`runMergedTargetPostEffects: CAS revision status for ${committed.path} is unavailable (${receiptStatus.reason}); skipping the graph rewrite — the next merge retries it (#2837)`,
|
|
1159
|
+
);
|
|
1160
|
+
return signals;
|
|
1161
|
+
}
|
|
1150
1162
|
rewriteInstalled = await rewriteMergedTargetGraphEdges(storage, {
|
|
1151
1163
|
targetId: merge.targetId,
|
|
1152
1164
|
memoryRelPath,
|
|
1153
1165
|
mergedContent: merge.mergedContent,
|
|
1154
|
-
|
|
1155
|
-
revisionChecked: await storage.readCasRevision(committed.path),
|
|
1166
|
+
revisionChecked: receiptStatus.status === "present" ? receiptStatus.revision : undefined,
|
|
1156
1167
|
rewriteTypes,
|
|
1157
1168
|
build: () =>
|
|
1158
1169
|
deps.buildGraphEdge(
|
|
@@ -72,4 +72,26 @@ export class CasRevisionHost extends CasRevisionStore {
|
|
|
72
72
|
await this.recoverPendingRevision(pathname);
|
|
73
73
|
return await this.beginRevisionTransaction(pathname, expectedContent);
|
|
74
74
|
}
|
|
75
|
+
|
|
76
|
+
/** #2837: best-effort post-deletion sweep of the target's receipt shard.
|
|
77
|
+
* The durable memory file is already gone, so a failed sweep must never
|
|
78
|
+
* fail the deletion itself — it logs instead. A PENDING shard (crash
|
|
79
|
+
* recovery evidence) and a foreign shard (another target's receipt) are
|
|
80
|
+
* kept, with a warning naming the recovery path. */
|
|
81
|
+
async sweepShardAfterDeletion(filePath: string): Promise<void> {
|
|
82
|
+
try {
|
|
83
|
+
const outcome = await this.removeRevisionShard(filePath);
|
|
84
|
+
if (outcome === "pending") {
|
|
85
|
+
log.warn(
|
|
86
|
+
`CAS revision shard for ${filePath} kept after memory deletion: a pending reservation stands — run recovery before the path is reused`,
|
|
87
|
+
);
|
|
88
|
+
} else if (outcome === "foreign") {
|
|
89
|
+
log.warn(
|
|
90
|
+
`CAS revision shard for ${filePath} kept after memory deletion: the shard names another target`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
} catch (err) {
|
|
94
|
+
log.warn(`failed to sweep CAS revision shard for deleted memory ${filePath}: ${err}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
75
97
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
-
import { mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { mkdir, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from "node:fs/promises";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import test from "node:test";
|
|
@@ -461,3 +461,46 @@ test("legacy landed pending whose file no longer matches expectedDigest is ambig
|
|
|
461
461
|
assert.equal((await store.readRevisionStatus(target)).status, "unavailable");
|
|
462
462
|
});
|
|
463
463
|
});
|
|
464
|
+
|
|
465
|
+
test("removeRevisionShard sweeps a committed shard, keeps pending evidence, never touches a foreign shard (#2837)", async () => {
|
|
466
|
+
await withStore(async (store, root) => {
|
|
467
|
+
const target = path.join(root, "memories", "fact-deleted.md");
|
|
468
|
+
const shard = shardPathFor(root, target);
|
|
469
|
+
const shardExists = async () =>
|
|
470
|
+
stat(shard).then(
|
|
471
|
+
() => true,
|
|
472
|
+
() => false,
|
|
473
|
+
);
|
|
474
|
+
|
|
475
|
+
// No receipt was ever minted: the sweep reports absence, not an error.
|
|
476
|
+
assert.equal(await store.removeRevisionShard(target), "absent");
|
|
477
|
+
|
|
478
|
+
const a = await store.beginRevisionTransaction(target);
|
|
479
|
+
await a.commit();
|
|
480
|
+
assert.equal(await shardExists(), true);
|
|
481
|
+
assert.equal(await store.removeRevisionShard(target), "removed");
|
|
482
|
+
assert.equal(await shardExists(), false, "the committed shard is gone once its memory is deleted");
|
|
483
|
+
assert.equal((await store.readRevisionStatus(target)).status, "absent");
|
|
484
|
+
|
|
485
|
+
// A PENDING marker is crash-recovery evidence: the sweep must keep it.
|
|
486
|
+
const b = await store.beginRevisionTransaction(target);
|
|
487
|
+
assert.equal(await store.removeRevisionShard(target), "pending");
|
|
488
|
+
assert.equal(await shardExists(), true, "the pending marker survives the deletion sweep");
|
|
489
|
+
await b.abort();
|
|
490
|
+
|
|
491
|
+
// A shard naming another target is never destroyed by this target's sweep.
|
|
492
|
+
await mkdir(path.dirname(shard), { recursive: true });
|
|
493
|
+
await writeFile(
|
|
494
|
+
shard,
|
|
495
|
+
`${JSON.stringify({
|
|
496
|
+
version: 1,
|
|
497
|
+
path: "memories/someone-else.md",
|
|
498
|
+
revision: "2026-08-01T00:00:00.000Z",
|
|
499
|
+
state: "committed",
|
|
500
|
+
})}\n`,
|
|
501
|
+
"utf8",
|
|
502
|
+
);
|
|
503
|
+
assert.equal(await store.removeRevisionShard(target), "foreign");
|
|
504
|
+
assert.equal(await shardExists(), true, "a foreign shard is kept — it names another target");
|
|
505
|
+
});
|
|
506
|
+
});
|
|
@@ -641,6 +641,29 @@ export class CasRevisionStore {
|
|
|
641
641
|
});
|
|
642
642
|
}
|
|
643
643
|
|
|
644
|
+
/** #2837: remove the target's receipt shard once its durable memory file
|
|
645
|
+
* has been deleted, so orphan shards cannot accumulate. Under the shard
|
|
646
|
+
* lock: a PENDING marker is crash-recovery evidence for
|
|
647
|
+
* {@link recoverPendingRevision} and is KEPT ("pending"); a shard naming
|
|
648
|
+
* another target is KEPT ("foreign") — never destroy a receipt you cannot
|
|
649
|
+
* attribute. Never touches the durable memory file or the legacy global
|
|
650
|
+
* metadata: a stale legacy entry keeps per-path monotonicity if the path
|
|
651
|
+
* is ever reused. */
|
|
652
|
+
async removeRevisionShard(
|
|
653
|
+
filePath: string,
|
|
654
|
+
): Promise<"removed" | "absent" | "pending" | "foreign"> {
|
|
655
|
+
const relativePath = await this.resolveRelativePath(filePath);
|
|
656
|
+
return await this.withShardLock(relativePath, async () => {
|
|
657
|
+
const { shardPath } = await this.getSafeShardInfo(relativePath);
|
|
658
|
+
const view = await this.readShardView(shardPath, relativePath);
|
|
659
|
+
if (view.kind === "missing") return "absent";
|
|
660
|
+
if (view.kind === "pending") return view.foreign ? "foreign" : "pending";
|
|
661
|
+
if (view.kind === "foreign" || view.foreign) return "foreign";
|
|
662
|
+
await unlink(shardPath);
|
|
663
|
+
return "removed";
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
|
|
644
667
|
/** #2813 (P1 C): recovery for a reservation left PENDING by a crash or a
|
|
645
668
|
* failed publication. The caller supplies the KNOWN outcome of the
|
|
646
669
|
* durable memory write: `fileWriteLanded` publishes the pending token as
|
|
@@ -10,6 +10,7 @@ import { withRawEntityPageMutation } from "./entity-canonical-id-lock.js";
|
|
|
10
10
|
import { readMaybeEncryptedFileFromChunks, writeMaybeEncryptedFileFromChunks } from "../secure-store/secure-fs.js";
|
|
11
11
|
import * as archiveMutation from "../archive-mutation-version.js";
|
|
12
12
|
import { invalidationCommitFingerprint, isSemanticFrontmatterChange } from "./deletion-revision-store.js";
|
|
13
|
+
import { CasRevisionHost } from "./cas-revision-host.js";
|
|
13
14
|
import type { CasRevisionTransaction } from "./cas-revision-store.js";
|
|
14
15
|
import {
|
|
15
16
|
buildExplicitCaptureDedupKey,
|
|
@@ -208,7 +209,11 @@ export abstract class TombstoneBlockedCaptureIndexHost {
|
|
|
208
209
|
protected abstract bumpMemoryStatusVersion(): void;
|
|
209
210
|
protected abstract bumpMemoryCorpusVersion(): void;
|
|
210
211
|
protected abstract markFactHashIndexNotAuthoritative(): void;
|
|
211
|
-
|
|
212
|
+
/** #2837: durable unlink plus deletion-revision record, WITHOUT the CAS
|
|
213
|
+
* shard sweep — the concrete {@link deleteManagedStorageFile} composes the
|
|
214
|
+
* two so every deletion funnel (invalidation, maintenance purge, archive
|
|
215
|
+
* move, offline sync) sweeps. */
|
|
216
|
+
protected abstract deleteManagedStorageFileDurable(filePath: string, deletionMtimeMs?: number | null): Promise<boolean>;
|
|
212
217
|
protected abstract writeManagedStorageFile(filePath: string, write: () => Promise<void>): Promise<void>;
|
|
213
218
|
|
|
214
219
|
/** #2813 (P1 C, #2807): reserve the target's next durable CAS revision
|
|
@@ -221,6 +226,26 @@ export abstract class TombstoneBlockedCaptureIndexHost {
|
|
|
221
226
|
return undefined;
|
|
222
227
|
}
|
|
223
228
|
|
|
229
|
+
/** #2837 (shard cleanup): after a managed memory file is durably deleted,
|
|
230
|
+
* sweep its per-target CAS receipt shard so orphan shards cannot grow
|
|
231
|
+
* forever. The sweep runs under the shard's own lock and is best-effort —
|
|
232
|
+
* the memory file is already gone, so a sweep failure logs and never
|
|
233
|
+
* fails the delete. The sweeper is a second CasRevisionHost over the same
|
|
234
|
+
* memory dir; the host is stateless and every mutation serializes through
|
|
235
|
+
* the sidecar's file locks, so it shares the same shard files
|
|
236
|
+
* StorageManager's own host operates on. */
|
|
237
|
+
private deletedMemoryShardSweeper: CasRevisionHost | null = null;
|
|
238
|
+
|
|
239
|
+
protected async deleteManagedStorageFile(filePath: string, deletionMtimeMs?: number | null): Promise<boolean> {
|
|
240
|
+
const deleted = await this.deleteManagedStorageFileDurable(filePath, deletionMtimeMs);
|
|
241
|
+
if (!deleted) return false;
|
|
242
|
+
if (this.deletedMemoryShardSweeper === null) {
|
|
243
|
+
this.deletedMemoryShardSweeper = new CasRevisionHost(this.tombstoneBlockedCaptureIndexOptions().memoryDir);
|
|
244
|
+
}
|
|
245
|
+
await this.deletedMemoryShardSweeper.sweepShardAfterDeletion(filePath);
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
|
|
224
249
|
private async writeStorageSecureFileChunks(filePath: string, chunks: AsyncIterable<Buffer>): Promise<void> {
|
|
225
250
|
const options = this.tombstoneBlockedCaptureIndexOptions();
|
|
226
251
|
await writeMaybeEncryptedFileFromChunks(
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { mkdir, mkdtemp, readdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { mkdir, mkdtemp, readdir, readFile, rm, stat, symlink, writeFile } from "node:fs/promises";
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
@@ -838,3 +838,51 @@ test("crash after memory write before writeLanded recovers from host-forwarded e
|
|
|
838
838
|
);
|
|
839
839
|
});
|
|
840
840
|
});
|
|
841
|
+
|
|
842
|
+
test("deleting a memory sweeps its CAS revision shard (#2837)", async () => {
|
|
843
|
+
await withStorageDir(async (dir) => {
|
|
844
|
+
const storage = new StorageManager(dir);
|
|
845
|
+
const created = await storage.writeMemory("fact", "Deletion shard sweep content.", { source: "test" });
|
|
846
|
+
const memory = await storage.getMemoryById(created.id);
|
|
847
|
+
assert.ok(memory);
|
|
848
|
+
|
|
849
|
+
// Mint a standing receipt, then delete the memory through the public
|
|
850
|
+
// invalidation funnel (the same funnel maintenance purge and archive
|
|
851
|
+
// moves use underneath).
|
|
852
|
+
assert.equal(await storage.writeMemoryFrontmatter(memory, { tags: ["sweep"] }), true);
|
|
853
|
+
const shard = casShardPath(dir, memory.path);
|
|
854
|
+
assert.equal((await storage.readCasRevisionStatus(memory.path)).status, "present");
|
|
855
|
+
await stat(shard);
|
|
856
|
+
|
|
857
|
+
assert.equal(await storage.invalidateMemory(created.id), true);
|
|
858
|
+
assert.equal(await stat(shard).then(() => true, () => false), false, "the deleted memory's shard is swept");
|
|
859
|
+
assert.equal(
|
|
860
|
+
(await storage.readCasRevisionStatus(memory.path)).status,
|
|
861
|
+
"absent",
|
|
862
|
+
"no standing receipt survives the memory",
|
|
863
|
+
);
|
|
864
|
+
});
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
test("a pending shard survives memory deletion as recovery evidence, and the delete still succeeds (#2837)", async () => {
|
|
868
|
+
await withStorageDir(async (dir) => {
|
|
869
|
+
const storage = new StorageManager(dir);
|
|
870
|
+
const created = await storage.writeMemory("fact", "Pending shard kept on delete.", { source: "test" });
|
|
871
|
+
const memory = await storage.getMemoryById(created.id);
|
|
872
|
+
assert.ok(memory);
|
|
873
|
+
|
|
874
|
+
// Crash simulation: a reservation left PENDING for this target.
|
|
875
|
+
const seams = storage as unknown as { casRevisions: CasRevisionStore };
|
|
876
|
+
await seams.casRevisions.beginRevisionTransaction(memory.path);
|
|
877
|
+
const shard = casShardPath(dir, memory.path);
|
|
878
|
+
await stat(shard);
|
|
879
|
+
|
|
880
|
+
assert.equal(await storage.invalidateMemory(created.id), true, "the deletion itself never fails on the sweep");
|
|
881
|
+
assert.equal(
|
|
882
|
+
await stat(shard).then(() => true, () => false),
|
|
883
|
+
true,
|
|
884
|
+
"a PENDING marker is crash-recovery evidence and is kept",
|
|
885
|
+
);
|
|
886
|
+
assert.equal((await storage.readCasRevisionStatus(memory.path)).status, "unavailable");
|
|
887
|
+
});
|
|
888
|
+
});
|
package/src/storage.ts
CHANGED
|
@@ -2732,7 +2732,7 @@ export class StorageManager extends TombstoneBlockedCaptureIndexHost {
|
|
|
2732
2732
|
protected async writeManagedStorageFile(filePath: string, write: () => Promise<void>): Promise<void> {
|
|
2733
2733
|
return this.deletionRevisionStore.writeManagedStorageFile(filePath, write);
|
|
2734
2734
|
}
|
|
2735
|
-
protected async
|
|
2735
|
+
protected async deleteManagedStorageFileDurable(filePath: string, deletionMtimeMs?: number | null): Promise<boolean> {
|
|
2736
2736
|
return this.deletionRevisionStore.deleteManagedStorageFile(filePath, deletionMtimeMs);
|
|
2737
2737
|
}
|
|
2738
2738
|
|