@remnic/core 9.3.751 → 9.3.752
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-boundary.d.ts +5 -5
- package/dist/access-boundary.js +29 -29
- package/dist/access-cli.js +88 -88
- package/dist/access-http.d.ts +5 -5
- package/dist/access-http.js +35 -35
- package/dist/access-mcp.d.ts +5 -5
- package/dist/access-mcp.js +34 -34
- package/dist/access-operations-batch.js +31 -31
- package/dist/access-operations.d.ts +5 -5
- package/dist/access-operations.js +33 -33
- package/dist/access-recall-surface.d.ts +363 -0
- package/dist/access-recall-surface.js +128 -0
- package/dist/access-schema.js +7 -7
- package/dist/{access-service-DmAEOJdJ.d.ts → access-service-C2cOHQvg.d.ts} +7 -26
- package/dist/access-service.d.ts +5 -5
- package/dist/access-service.js +28 -28
- package/dist/access-surface-catalog.d.ts +5 -5
- package/dist/action-confidence.d.ts +1 -1
- package/dist/active-memory-bridge.d.ts +1 -1
- package/dist/active-recall.d.ts +1 -1
- package/dist/active-recall.js +1 -1
- package/dist/behavior-learner.d.ts +1 -1
- package/dist/behavior-signals.d.ts +1 -1
- package/dist/bootstrap.d.ts +4 -4
- package/dist/briefing.d.ts +1 -1
- package/dist/briefing.js +4 -4
- package/dist/buffer-surprise-report.d.ts +1 -1
- package/dist/buffer.d.ts +1 -1
- package/dist/buffer.js +2 -2
- package/dist/calibration.d.ts +1 -1
- package/dist/capabilities.d.ts +1 -1
- package/dist/{capsule-crypto-7FJQINUR.js → capsule-crypto-CZJSLEFG.js} +3 -3
- package/dist/{catalog-BpOcwHdE.d.ts → catalog-D_FvzTM0.d.ts} +1 -1
- package/dist/causal-behavior.d.ts +1 -1
- package/dist/causal-behavior.js +2 -2
- package/dist/causal-chain.js +2 -2
- package/dist/causal-consolidation.d.ts +1 -1
- package/dist/causal-consolidation.js +9 -9
- package/dist/causal-retrieval.js +2 -2
- package/dist/causal-trajectory-graph.d.ts +1 -1
- package/dist/causal-trajectory.js +1 -1
- package/dist/{chunk-2NLLXCJG.js → chunk-2BFLDH5F.js} +2 -2
- package/dist/{chunk-UU32EAIY.js → chunk-2DPCAW2T.js} +5 -5
- package/dist/{chunk-5PZCDUJ6.js → chunk-2U4E34Y3.js} +4 -4
- package/dist/{chunk-JRZV3767.js → chunk-33NX5BLR.js} +4 -4
- package/dist/{chunk-X7Y7WX73.js → chunk-437S3G37.js} +2 -2
- package/dist/{chunk-HAZVVOYT.js → chunk-5DXUE7GB.js} +2 -2
- package/dist/{chunk-4DLFJJOQ.js → chunk-5JAT4ZJD.js} +4 -4
- package/dist/{chunk-RS25QOKZ.js → chunk-64SE4X5G.js} +2 -2
- package/dist/{chunk-QRRBXD24.js → chunk-67JRXV6Z.js} +7 -7
- package/dist/{chunk-WA7FECSG.js → chunk-6ROCH5JV.js} +7 -7
- package/dist/{chunk-5QANTTY2.js → chunk-BIAMJUUJ.js} +4 -4
- package/dist/{chunk-IKHKHADN.js → chunk-C4LNKQ3M.js} +5 -5
- package/dist/{chunk-B35L45HZ.js → chunk-DC5OBZS3.js} +2 -2
- package/dist/{chunk-ORAK3LNZ.js → chunk-ER3CCWK2.js} +2 -2
- package/dist/{chunk-SD34EK4Z.js → chunk-FEELYTRT.js} +5 -5
- package/dist/{chunk-KQAFEZQX.js → chunk-FLVL4L3L.js} +2 -2
- package/dist/{chunk-WIWPSQYU.js → chunk-FXRFFYWD.js} +4 -4
- package/dist/{chunk-W3M34XT4.js → chunk-GECEETQ3.js} +4 -4
- package/dist/{chunk-WWSXVOGY.js → chunk-GFGYHLON.js} +2 -2
- package/dist/{chunk-BJMBJZ2Y.js → chunk-GWTFLF2P.js} +59 -59
- package/dist/chunk-GWTFLF2P.js.map +1 -0
- package/dist/{chunk-WOX2UW4K.js → chunk-IBPEVGP5.js} +2 -2
- package/dist/{chunk-JZ7P26GF.js → chunk-IQWFWSCP.js} +4 -4
- package/dist/{chunk-6SXNQFHB.js → chunk-J2JB6BHT.js} +8 -8
- package/dist/{chunk-K2JM4DZZ.js → chunk-JHZ3LEM7.js} +2 -2
- package/dist/{chunk-YGGXUNS4.js → chunk-JNKSFIAY.js} +45 -45
- package/dist/{chunk-CGZWGVQF.js → chunk-K5CWCJKJ.js} +6 -6
- package/dist/{chunk-TPLE6SNY.js → chunk-KFNG72W7.js} +7 -7
- package/dist/{chunk-4NWIGAIC.js → chunk-KT2VQLP7.js} +8 -8
- package/dist/{chunk-6FNZ3NMS.js → chunk-L3VSTJ2V.js} +2 -2
- package/dist/{chunk-ARV3AUOM.js → chunk-M5QKGHCR.js} +2 -2
- package/dist/{chunk-E4PFDD3N.js → chunk-MEPWWSTD.js} +4 -4
- package/dist/{chunk-V5RVMULT.js → chunk-MLDAD27I.js} +7 -7
- package/dist/{chunk-K2R3DEEH.js → chunk-MTN2G4DT.js} +2 -2
- package/dist/{chunk-DBSMLRTO.js → chunk-MW7UUXTA.js} +5 -5
- package/dist/{chunk-OSMBC3JU.js → chunk-OKOF7ITI.js} +6 -6
- package/dist/{chunk-DBRVU5PR.js → chunk-OVMLS7TN.js} +3 -3
- package/dist/{chunk-NNL4LJAD.js → chunk-QMMCMV2L.js} +5 -5
- package/dist/{chunk-RNWWAS5V.js → chunk-QRM3K3XT.js} +5 -5
- package/dist/{chunk-HM6UJPPO.js → chunk-R7TGDLV3.js} +4 -4
- package/dist/{chunk-EEKDRAWX.js → chunk-RBYV3WSB.js} +4 -4
- package/dist/{chunk-LOPZ2DWR.js → chunk-RRRZ3CLN.js} +4 -4
- package/dist/{chunk-7TWA7DKP.js → chunk-S5UYWK3O.js} +6 -6
- package/dist/{chunk-QOGXUNWL.js → chunk-SC5AEZLU.js} +9 -9
- package/dist/{chunk-SNEXVX3U.js → chunk-UFNQKZV7.js} +15 -15
- package/dist/{chunk-W3BFT5OJ.js → chunk-V4ZPLQLC.js} +2 -2
- package/dist/{chunk-SQNED75R.js → chunk-V5UV7BOA.js} +1 -1
- package/dist/{chunk-RWZB6MAY.js → chunk-VQL22SHO.js} +891 -1002
- package/dist/chunk-VQL22SHO.js.map +1 -0
- package/dist/{chunk-UETBEHBA.js → chunk-VX2RYM2K.js} +2232 -1942
- package/dist/chunk-VX2RYM2K.js.map +1 -0
- package/dist/{chunk-K5LLP4CY.js → chunk-XKAOT4ZB.js} +4 -4
- package/dist/{chunk-ZBVQ4ZM4.js → chunk-YGNQG4Q6.js} +8 -8
- package/dist/{chunk-EZ25VE3G.js → chunk-YNDLCWXS.js} +4 -4
- package/dist/{chunk-HYI2GP3F.js → chunk-ZGGI3COG.js} +9 -9
- package/dist/{cli-lmf5RGyQ.d.ts → cli-DFUU9WYu.d.ts} +3 -3
- package/dist/cli.d.ts +6 -6
- package/dist/cli.js +60 -60
- package/dist/compounding/engine.d.ts +1 -1
- package/dist/compounding/engine.js +6 -6
- package/dist/compounding/preference-consolidator.d.ts +1 -1
- package/dist/compression-optimizer.d.ts +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.js +1 -1
- package/dist/connectors/codex-materialize-runner.d.ts +1 -1
- package/dist/connectors/codex-materialize-runner.js +5 -5
- package/dist/connectors/codex-materialize.d.ts +1 -1
- package/dist/connectors/index.d.ts +1 -1
- package/dist/connectors/index.js +7 -7
- package/dist/consolidation-provenance-check.d.ts +1 -1
- package/dist/consolidation-undo.d.ts +1 -1
- package/dist/contradiction/index.d.ts +1 -1
- package/dist/contradiction/index.js +2 -2
- package/dist/{contradiction-scan-3FJYWS3G.js → contradiction-scan-KO4WWCS4.js} +3 -3
- package/dist/contradiction-scan-KO4WWCS4.js.map +1 -0
- package/dist/conversation-index/backend.d.ts +1 -1
- package/dist/conversation-index/chunker.d.ts +1 -1
- package/dist/conversation-index/faiss-adapter.d.ts +1 -1
- package/dist/conversation-index/indexer.d.ts +1 -1
- package/dist/conversation-index/search.d.ts +1 -1
- package/dist/dashboard-runtime.js +2 -2
- package/dist/day-summary.d.ts +1 -1
- package/dist/day-summary.js +2 -2
- package/dist/delinearize.d.ts +1 -1
- package/dist/direct-answer-wiring.d.ts +1 -1
- package/dist/direct-answer.d.ts +1 -1
- package/dist/embedding-fallback.d.ts +1 -1
- package/dist/embedding-fallback.js +2 -2
- package/dist/enrichment/index.d.ts +1 -1
- package/dist/entity-retrieval.d.ts +1 -1
- package/dist/entity-retrieval.js +5 -5
- package/dist/entity-schema.d.ts +1 -1
- package/dist/explicit-capture.d.ts +4 -4
- package/dist/explicit-capture.js +2 -2
- package/dist/extraction-faithfulness.d.ts +1 -1
- package/dist/extraction-judge-telemetry.d.ts +1 -1
- package/dist/extraction-judge-training.d.ts +1 -1
- package/dist/extraction-judge.d.ts +1 -1
- package/dist/extraction.d.ts +1 -1
- package/dist/extraction.js +4 -4
- package/dist/fallback-llm.d.ts +1 -1
- package/dist/graph-dashboard-diff.d.ts +1 -1
- package/dist/graph-dashboard-key.d.ts +1 -1
- package/dist/graph-dashboard-parser.d.ts +1 -1
- package/dist/graph-edge-reinforcement.d.ts +1 -1
- package/dist/graph-snapshot.d.ts +1 -1
- package/dist/graph.d.ts +1 -1
- package/dist/identity-continuity.d.ts +1 -1
- package/dist/importance.d.ts +1 -1
- package/dist/index.d.ts +9 -9
- package/dist/index.js +142 -142
- package/dist/intent.d.ts +1 -1
- package/dist/lcm/engine.d.ts +1 -1
- package/dist/lcm/engine.js +3 -3
- package/dist/lcm/index.d.ts +1 -1
- package/dist/lcm/index.js +8 -8
- package/dist/lcm/tools.d.ts +1 -1
- package/dist/lifecycle.d.ts +1 -1
- package/dist/live-connectors-runner.d.ts +1 -1
- package/dist/local-llm.d.ts +1 -1
- package/dist/local-llm.js +2 -2
- package/dist/local-model-endpoint.d.ts +1 -1
- package/dist/maintenance/memory-governance.d.ts +1 -1
- package/dist/maintenance/memory-governance.js +4 -4
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +4 -4
- package/dist/maintenance/rebuild-memory-projection.js +5 -5
- package/dist/mcp-memory-inspector-app.d.ts +5 -5
- package/dist/memory-action-policy.d.ts +1 -1
- package/dist/memory-cache.d.ts +1 -1
- package/dist/memory-lifecycle-ledger-utils.d.ts +1 -1
- package/dist/memory-projection-store.d.ts +1 -1
- package/dist/memory-provenance.d.ts +1 -1
- package/dist/memory-worth-outcomes.d.ts +1 -1
- package/dist/models-json.d.ts +1 -1
- package/dist/namespaces/migrate.d.ts +2 -2
- package/dist/namespaces/migrate.js +10 -10
- package/dist/namespaces/principal.d.ts +1 -1
- package/dist/namespaces/search.d.ts +1 -1
- package/dist/namespaces/search.js +4 -4
- package/dist/namespaces/storage.d.ts +2 -2
- package/dist/namespaces/storage.js +6 -6
- package/dist/native-knowledge.d.ts +1 -1
- package/dist/operator-toolkit.d.ts +1 -1
- package/dist/operator-toolkit.js +17 -17
- package/dist/orchestration/compression-guideline-coordinator.d.ts +1 -1
- package/dist/orchestration/compression-guideline-coordinator.js +2 -2
- package/dist/orchestration/maintenance.d.ts +2 -2
- package/dist/orchestration/maintenance.js +10 -10
- package/dist/{orchestrator-D7bb8ZGp.d.ts → orchestrator-BhxMxtQJ.d.ts} +27 -106
- package/dist/orchestrator.d.ts +4 -4
- package/dist/orchestrator.js +86 -86
- package/dist/patterns-cli.d.ts +1 -1
- package/dist/policy-runtime.d.ts +1 -1
- package/dist/provenance.d.ts +1 -1
- package/dist/qmd-recall-cache.d.ts +1 -1
- package/dist/qmd.d.ts +1 -1
- package/dist/recall-disclosure-escalation.d.ts +1 -1
- package/dist/recall-explain-renderer.d.ts +1 -1
- package/dist/recall-planner-llm.d.ts +1 -1
- package/dist/recall-state.d.ts +1 -1
- package/dist/recall-tag-filter.d.ts +1 -1
- package/dist/recall-xray-cli.d.ts +1 -1
- package/dist/recall-xray-renderer.d.ts +1 -1
- package/dist/recall-xray.d.ts +1 -1
- package/dist/resolve-auth-token.d.ts +1 -1
- package/dist/resume-bundles.js +1 -1
- package/dist/retrieval-agents.d.ts +1 -1
- package/dist/retrieval-tiers.d.ts +1 -1
- package/dist/routing/engine.d.ts +1 -1
- package/dist/routing/store.d.ts +1 -1
- package/dist/routing/store.js +2 -2
- package/dist/schemas.d.ts +22 -22
- package/dist/search/embed-helper.d.ts +1 -1
- package/dist/search/embed-helper.js +2 -2
- package/dist/search/factory.d.ts +1 -1
- package/dist/search/factory.js +3 -3
- package/dist/search/index.d.ts +1 -1
- package/dist/search/index.js +3 -3
- package/dist/search/lancedb-backend.d.ts +1 -1
- package/dist/search/meilisearch-backend.d.ts +1 -1
- package/dist/search/noop-backend.d.ts +1 -1
- package/dist/search/orama-backend.d.ts +1 -1
- package/dist/search/port.d.ts +1 -1
- package/dist/search/remote-backend.d.ts +1 -1
- package/dist/secure-store/index.js +2 -2
- package/dist/{semantic-consolidation-CTfbVwCA.d.ts → semantic-consolidation-BrsoFeMP.d.ts} +1 -1
- package/dist/semantic-consolidation.d.ts +2 -2
- package/dist/semantic-consolidation.js +7 -7
- package/dist/semantic-rule-promotion.js +4 -4
- package/dist/semantic-rule-verifier.d.ts +1 -1
- package/dist/semantic-rule-verifier.js +4 -4
- package/dist/session-observer-bands.d.ts +1 -1
- package/dist/session-observer-state.d.ts +1 -1
- package/dist/shared-context/manager.d.ts +1 -1
- package/dist/shared-context/manager.js +2 -2
- package/dist/signal.d.ts +1 -1
- package/dist/storage.d.ts +1 -1
- package/dist/storage.js +3 -3
- package/dist/summarizer.d.ts +1 -1
- package/dist/summarizer.js +3 -3
- package/dist/summary-snapshot.d.ts +1 -1
- package/dist/temporal-supersession.d.ts +1 -1
- package/dist/temporal-validity.d.ts +1 -1
- package/dist/threading.d.ts +1 -1
- package/dist/tier-migration.d.ts +1 -1
- package/dist/tier-routing.d.ts +1 -1
- package/dist/topics.d.ts +1 -1
- package/dist/transcript.d.ts +1 -1
- package/dist/transfer/backup.js +3 -3
- package/dist/transfer/capsule-export.js +3 -3
- package/dist/transfer/capsule-import.js +3 -3
- package/dist/transfer/types.d.ts +12 -12
- package/dist/trust-score-stage.d.ts +1 -1
- package/dist/trust-score.d.ts +1 -1
- package/dist/{types-D4NYDtXI.d.ts → types-BfI4uJeb.d.ts} +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/utility-runtime.d.ts +1 -1
- package/dist/verified-recall.js +4 -4
- package/package.json +2 -2
- package/src/access-recall-surface.ts +1552 -0
- package/src/access-service.ts +29 -1219
- package/src/orchestration/recall-entry.ts +290 -0
- package/src/orchestration/session-context.ts +407 -0
- package/src/orchestrator.ts +125 -462
- package/dist/chunk-BJMBJZ2Y.js.map +0 -1
- package/dist/chunk-RWZB6MAY.js.map +0 -1
- package/dist/chunk-UETBEHBA.js.map +0 -1
- /package/dist/{capsule-crypto-7FJQINUR.js.map → access-recall-surface.js.map} +0 -0
- /package/dist/{contradiction-scan-3FJYWS3G.js.map → capsule-crypto-CZJSLEFG.js.map} +0 -0
- /package/dist/{chunk-2NLLXCJG.js.map → chunk-2BFLDH5F.js.map} +0 -0
- /package/dist/{chunk-UU32EAIY.js.map → chunk-2DPCAW2T.js.map} +0 -0
- /package/dist/{chunk-5PZCDUJ6.js.map → chunk-2U4E34Y3.js.map} +0 -0
- /package/dist/{chunk-JRZV3767.js.map → chunk-33NX5BLR.js.map} +0 -0
- /package/dist/{chunk-X7Y7WX73.js.map → chunk-437S3G37.js.map} +0 -0
- /package/dist/{chunk-HAZVVOYT.js.map → chunk-5DXUE7GB.js.map} +0 -0
- /package/dist/{chunk-4DLFJJOQ.js.map → chunk-5JAT4ZJD.js.map} +0 -0
- /package/dist/{chunk-RS25QOKZ.js.map → chunk-64SE4X5G.js.map} +0 -0
- /package/dist/{chunk-QRRBXD24.js.map → chunk-67JRXV6Z.js.map} +0 -0
- /package/dist/{chunk-WA7FECSG.js.map → chunk-6ROCH5JV.js.map} +0 -0
- /package/dist/{chunk-5QANTTY2.js.map → chunk-BIAMJUUJ.js.map} +0 -0
- /package/dist/{chunk-IKHKHADN.js.map → chunk-C4LNKQ3M.js.map} +0 -0
- /package/dist/{chunk-B35L45HZ.js.map → chunk-DC5OBZS3.js.map} +0 -0
- /package/dist/{chunk-ORAK3LNZ.js.map → chunk-ER3CCWK2.js.map} +0 -0
- /package/dist/{chunk-SD34EK4Z.js.map → chunk-FEELYTRT.js.map} +0 -0
- /package/dist/{chunk-KQAFEZQX.js.map → chunk-FLVL4L3L.js.map} +0 -0
- /package/dist/{chunk-WIWPSQYU.js.map → chunk-FXRFFYWD.js.map} +0 -0
- /package/dist/{chunk-W3M34XT4.js.map → chunk-GECEETQ3.js.map} +0 -0
- /package/dist/{chunk-WWSXVOGY.js.map → chunk-GFGYHLON.js.map} +0 -0
- /package/dist/{chunk-WOX2UW4K.js.map → chunk-IBPEVGP5.js.map} +0 -0
- /package/dist/{chunk-JZ7P26GF.js.map → chunk-IQWFWSCP.js.map} +0 -0
- /package/dist/{chunk-6SXNQFHB.js.map → chunk-J2JB6BHT.js.map} +0 -0
- /package/dist/{chunk-K2JM4DZZ.js.map → chunk-JHZ3LEM7.js.map} +0 -0
- /package/dist/{chunk-YGGXUNS4.js.map → chunk-JNKSFIAY.js.map} +0 -0
- /package/dist/{chunk-CGZWGVQF.js.map → chunk-K5CWCJKJ.js.map} +0 -0
- /package/dist/{chunk-TPLE6SNY.js.map → chunk-KFNG72W7.js.map} +0 -0
- /package/dist/{chunk-4NWIGAIC.js.map → chunk-KT2VQLP7.js.map} +0 -0
- /package/dist/{chunk-6FNZ3NMS.js.map → chunk-L3VSTJ2V.js.map} +0 -0
- /package/dist/{chunk-ARV3AUOM.js.map → chunk-M5QKGHCR.js.map} +0 -0
- /package/dist/{chunk-E4PFDD3N.js.map → chunk-MEPWWSTD.js.map} +0 -0
- /package/dist/{chunk-V5RVMULT.js.map → chunk-MLDAD27I.js.map} +0 -0
- /package/dist/{chunk-K2R3DEEH.js.map → chunk-MTN2G4DT.js.map} +0 -0
- /package/dist/{chunk-DBSMLRTO.js.map → chunk-MW7UUXTA.js.map} +0 -0
- /package/dist/{chunk-OSMBC3JU.js.map → chunk-OKOF7ITI.js.map} +0 -0
- /package/dist/{chunk-DBRVU5PR.js.map → chunk-OVMLS7TN.js.map} +0 -0
- /package/dist/{chunk-NNL4LJAD.js.map → chunk-QMMCMV2L.js.map} +0 -0
- /package/dist/{chunk-RNWWAS5V.js.map → chunk-QRM3K3XT.js.map} +0 -0
- /package/dist/{chunk-HM6UJPPO.js.map → chunk-R7TGDLV3.js.map} +0 -0
- /package/dist/{chunk-EEKDRAWX.js.map → chunk-RBYV3WSB.js.map} +0 -0
- /package/dist/{chunk-LOPZ2DWR.js.map → chunk-RRRZ3CLN.js.map} +0 -0
- /package/dist/{chunk-7TWA7DKP.js.map → chunk-S5UYWK3O.js.map} +0 -0
- /package/dist/{chunk-QOGXUNWL.js.map → chunk-SC5AEZLU.js.map} +0 -0
- /package/dist/{chunk-SNEXVX3U.js.map → chunk-UFNQKZV7.js.map} +0 -0
- /package/dist/{chunk-W3BFT5OJ.js.map → chunk-V4ZPLQLC.js.map} +0 -0
- /package/dist/{chunk-SQNED75R.js.map → chunk-V5UV7BOA.js.map} +0 -0
- /package/dist/{chunk-K5LLP4CY.js.map → chunk-XKAOT4ZB.js.map} +0 -0
- /package/dist/{chunk-ZBVQ4ZM4.js.map → chunk-YGNQG4Q6.js.map} +0 -0
- /package/dist/{chunk-EZ25VE3G.js.map → chunk-YNDLCWXS.js.map} +0 -0
- /package/dist/{chunk-HYI2GP3F.js.map → chunk-ZGGI3COG.js.map} +0 -0
|
@@ -0,0 +1,1552 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Access recall read-surface (extracted from access-service.ts; god-file
|
|
3
|
+
* decomposition follow-through of #1526, same playbook: verbatim move +
|
|
4
|
+
* live selfDeps wiring so Object.create(prototype) fakes and instance
|
|
5
|
+
* stubs keep working).
|
|
6
|
+
*
|
|
7
|
+
* Owns the recall read pipeline of the access layer:
|
|
8
|
+
* - executeRecall (budget/audit-gated recall + serialization)
|
|
9
|
+
* - recallXray (X-ray capture surface, #570)
|
|
10
|
+
* - serializeRecallResults / buildRecallResponseFromXraySnapshot
|
|
11
|
+
* - fetchRawExcerpts (raw-excerpt read gate)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import * as nodePath from "node:path";
|
|
16
|
+
import { AccessAuditAdapter, type AccessAuditResult } from "./access-audit.js";
|
|
17
|
+
import { resolveNamespaceCapabilities } from "./capabilities.js";
|
|
18
|
+
import { resolveCodingNamespaceOverlay } from "./coding/coding-namespace.js";
|
|
19
|
+
import { type BudgetDecision, CrossNamespaceBudget } from "./cross-namespace-budget.js";
|
|
20
|
+
import { normalizeProjectionTags } from "./memory-projection-format.js";
|
|
21
|
+
import { namespaceIdentityFromToken } from "./namespaces/identity.js";
|
|
22
|
+
import { canReadNamespace, defaultNamespaceForPrincipal, recallNamespacesForPrincipal, resolvePrincipal } from "./namespaces/principal.js";
|
|
23
|
+
import { expandScopeProfileReadNamespaces, resolveScopeProfilePlan } from "./namespaces/scope-profiles.js";
|
|
24
|
+
import type { Orchestrator, RecallInvocationOptions } from "./orchestrator.js";
|
|
25
|
+
import { decideDisclosureEscalation } from "./recall-disclosure-escalation.js";
|
|
26
|
+
import type { LastRecallSnapshot } from "./recall-state.js";
|
|
27
|
+
import { type TagMatchMode, applyTagFilter, normalizeTags, parseTagMatch } from "./recall-tag-filter.js";
|
|
28
|
+
import { type RecallXraySnapshot, estimateRecallTokens } from "./recall-xray.js";
|
|
29
|
+
import { SecureStoreLockedError } from "./secure-store/index.js";
|
|
30
|
+
import { StorageManager } from "./index.js";
|
|
31
|
+
import { DEFAULT_RECALL_DISCLOSURE, type MemoryFile, type RecallDisclosure, type RecallPlanMode, isRecallDisclosure } from "./types.js";
|
|
32
|
+
import {
|
|
33
|
+
EngramAccessInputError,
|
|
34
|
+
shapeMemorySummary,
|
|
35
|
+
type EngramAccessMemorySummary,
|
|
36
|
+
type EngramAccessRecallRequest,
|
|
37
|
+
type EngramAccessRecallResponse,
|
|
38
|
+
type EngramAccessSetCodingContextRequest,
|
|
39
|
+
} from "./access-service.js";
|
|
40
|
+
// Canonical copies (access-service.ts still carries private duplicates for
|
|
41
|
+
// its remaining callers; dedupe tracked separately).
|
|
42
|
+
import { qmdCollectionPathParts, qmdResultPathCandidates } from "./orchestration/qmd-result-resolver.js";
|
|
43
|
+
|
|
44
|
+
export interface AccessRecallSurfaceDeps {
|
|
45
|
+
readonly auditAdapter: AccessAuditAdapter | null;
|
|
46
|
+
readonly budget: CrossNamespaceBudget;
|
|
47
|
+
buildRecallDebug(
|
|
48
|
+
snapshot: LastRecallSnapshot | null,
|
|
49
|
+
namespace: string,
|
|
50
|
+
includeDebug: boolean,
|
|
51
|
+
sessionKey?: string,
|
|
52
|
+
): Promise<EngramAccessRecallResponse["debug"] | undefined>;
|
|
53
|
+
buildRecallResponseFromXraySnapshot(options: {
|
|
54
|
+
query: string;
|
|
55
|
+
sessionKey?: string;
|
|
56
|
+
snapshot: RecallXraySnapshot;
|
|
57
|
+
disclosure: RecallDisclosure;
|
|
58
|
+
startedAt: number;
|
|
59
|
+
requestedMode?: RecallPlanMode | "auto";
|
|
60
|
+
normalizedMode?: RecallPlanMode;
|
|
61
|
+
/**
|
|
62
|
+
* Read-authorization-gated namespace for the raw-excerpt LCM lookup (#1505
|
|
63
|
+
* thread 2f7). Threaded through to `serializeRecallResults` so the
|
|
64
|
+
* `includeRecall` x-ray path honours the SAME read gate as normal recall and
|
|
65
|
+
* never attaches overlay transcript rows the gate excludes.
|
|
66
|
+
*/
|
|
67
|
+
rawExcerptNamespace?: string;
|
|
68
|
+
/**
|
|
69
|
+
* Ordered, read-authorized LCM read session_id SET (#1505 fallback
|
|
70
|
+
* unification). Threaded through to `serializeRecallResults` so the x-ray raw
|
|
71
|
+
* disclosure path also finds excerpts archived at the coding read fallbacks.
|
|
72
|
+
*/
|
|
73
|
+
rawExcerptSessionIds?: string[];
|
|
74
|
+
/**
|
|
75
|
+
* Force NO raw excerpts (#1505 thread NBHWz). Set when the IMPLICIT
|
|
76
|
+
* raw-excerpt read gate found NO readable LCM namespace, so the x-ray
|
|
77
|
+
* includeRecall path degrades to empty excerpts rather than falling back to
|
|
78
|
+
* the write/overlay namespace the read gate excludes.
|
|
79
|
+
*/
|
|
80
|
+
rawExcerptsSuppressed?: boolean;
|
|
81
|
+
}): Promise<EngramAccessRecallResponse>;
|
|
82
|
+
fetchRawExcerpts(
|
|
83
|
+
disclosure: RecallDisclosure,
|
|
84
|
+
context: {
|
|
85
|
+
query: string;
|
|
86
|
+
sessionKey?: string;
|
|
87
|
+
namespace?: string;
|
|
88
|
+
/**
|
|
89
|
+
* Pre-resolved, ordered, read-authorized LCM read session_id SET (#1505
|
|
90
|
+
* fallback unification). When supplied, raw disclosure queries each key in
|
|
91
|
+
* order (primary coding overlay → read fallbacks) and merges rows, exactly
|
|
92
|
+
* as the orchestrator recall path and `lcmSearch` do, so a branch-scoped
|
|
93
|
+
* session finds excerpts archived at project/root scope. Already
|
|
94
|
+
* read-gated by `resolveLcmReadSessionIds`, so an unauthorized
|
|
95
|
+
* `<principal>-project-*` key is never present. Falls back to the legacy
|
|
96
|
+
* single `namespace`-prefixed key when absent (sessionless / legacy
|
|
97
|
+
* callers).
|
|
98
|
+
*/
|
|
99
|
+
lcmSessionIds?: string[];
|
|
100
|
+
} | null,
|
|
101
|
+
): Promise<EngramAccessMemorySummary["rawExcerpts"] | null>;
|
|
102
|
+
maybeAttachCodingContext(
|
|
103
|
+
sessionKey: string | undefined,
|
|
104
|
+
options: { cwd?: string; projectTag?: string },
|
|
105
|
+
): Promise<void>;
|
|
106
|
+
normalizeRecallMode(mode?: RecallPlanMode | "auto"): RecallPlanMode | undefined;
|
|
107
|
+
readonly orchestrator: Orchestrator;
|
|
108
|
+
resolveLcmReadSessionIds(
|
|
109
|
+
explicitNamespace: string | undefined,
|
|
110
|
+
resolvedNamespace: string,
|
|
111
|
+
sessionKey: string,
|
|
112
|
+
authenticatedPrincipal: string | undefined,
|
|
113
|
+
): string[];
|
|
114
|
+
resolveNamespace(namespace?: string): string;
|
|
115
|
+
resolveRawExcerptReadNamespace(
|
|
116
|
+
explicitNamespace: string | undefined,
|
|
117
|
+
sessionKey: string | undefined,
|
|
118
|
+
authenticatedPrincipal: string | undefined,
|
|
119
|
+
): string | undefined;
|
|
120
|
+
resolveRecallNamespace(
|
|
121
|
+
namespace: string | undefined,
|
|
122
|
+
sessionKey: string | undefined,
|
|
123
|
+
authenticatedPrincipal?: string,
|
|
124
|
+
): string | undefined;
|
|
125
|
+
resolveRequestPrincipal(sessionKey: string | undefined, authenticatedPrincipal?: string): string | undefined;
|
|
126
|
+
serializeMemorySummary(
|
|
127
|
+
memory: MemoryFile,
|
|
128
|
+
baseDir: string,
|
|
129
|
+
disclosure?: RecallDisclosure,
|
|
130
|
+
rawExcerpts?: EngramAccessMemorySummary["rawExcerpts"],
|
|
131
|
+
): EngramAccessMemorySummary;
|
|
132
|
+
serializeRecallResults(
|
|
133
|
+
snapshot: LastRecallSnapshot | null,
|
|
134
|
+
disclosure: RecallDisclosure,
|
|
135
|
+
rawContext?:
|
|
136
|
+
| {
|
|
137
|
+
query: string;
|
|
138
|
+
sessionKey?: string;
|
|
139
|
+
/**
|
|
140
|
+
* Read-authorization-gated namespace for the raw-excerpt LCM lookup
|
|
141
|
+
* (#1505 thread 2f7). When the caller supplies it, the raw lookup uses
|
|
142
|
+
* THIS namespace prefix instead of `snapshot.namespace` (the
|
|
143
|
+
* write/overlay namespace), so raw disclosure honours the SAME read
|
|
144
|
+
* gate as normal recall + `lcmSearch`. Omitted ⇒ falls back to the
|
|
145
|
+
* snapshot namespace (single-store / sessionless callers, unchanged).
|
|
146
|
+
*/
|
|
147
|
+
rawExcerptNamespace?: string;
|
|
148
|
+
/**
|
|
149
|
+
* Ordered, read-authorized LCM read session_id SET (#1505 fallback
|
|
150
|
+
* unification). When supplied, raw disclosure queries each key (primary
|
|
151
|
+
* coding overlay → read fallbacks) and merges rows so a branch-scoped
|
|
152
|
+
* session finds excerpts archived at project/root scope. Already
|
|
153
|
+
* read-gated, so no unauthorized overlay key is present. Omitted ⇒ the
|
|
154
|
+
* legacy single `rawExcerptNamespace`-prefixed key (unchanged).
|
|
155
|
+
*/
|
|
156
|
+
rawExcerptSessionIds?: string[];
|
|
157
|
+
/**
|
|
158
|
+
* Force NO raw excerpts even when `disclosure === "raw"` (#1505 thread
|
|
159
|
+
* NBHWz). Set by callers when the IMPLICIT raw-excerpt read gate found
|
|
160
|
+
* NO readable LCM namespace (a restrictive `default` READ policy with
|
|
161
|
+
* no readable overlay/self namespace). The lookup must NOT fall back to
|
|
162
|
+
* `snapshot.namespace` (the write/overlay namespace the read gate
|
|
163
|
+
* excludes) — it returns empty excerpts so raw recall degrades
|
|
164
|
+
* gracefully instead of leaking unreadable rows or throwing.
|
|
165
|
+
*/
|
|
166
|
+
rawExcerptsSuppressed?: boolean;
|
|
167
|
+
}
|
|
168
|
+
| null,
|
|
169
|
+
): Promise<EngramAccessMemorySummary[]>;
|
|
170
|
+
setCodingContext(request: EngramAccessSetCodingContextRequest): void;
|
|
171
|
+
storageForAbsoluteRecallPath(
|
|
172
|
+
memoryPath: string,
|
|
173
|
+
primaryNamespace: string,
|
|
174
|
+
recallNamespaces?: readonly string[],
|
|
175
|
+
): Promise<{ storage: StorageManager; dir: string } | null>;
|
|
176
|
+
xrayQueue: Promise<void>;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export class AccessRecallSurface {
|
|
180
|
+
constructor(
|
|
181
|
+
private readonly deps: AccessRecallSurfaceDeps,
|
|
182
|
+
) {}
|
|
183
|
+
|
|
184
|
+
async buildRecallResponseFromXraySnapshot(options: {
|
|
185
|
+
query: string;
|
|
186
|
+
sessionKey?: string;
|
|
187
|
+
snapshot: RecallXraySnapshot;
|
|
188
|
+
disclosure: RecallDisclosure;
|
|
189
|
+
startedAt: number;
|
|
190
|
+
requestedMode?: RecallPlanMode | "auto";
|
|
191
|
+
normalizedMode?: RecallPlanMode;
|
|
192
|
+
/**
|
|
193
|
+
* Read-authorization-gated namespace for the raw-excerpt LCM lookup (#1505
|
|
194
|
+
* thread 2f7). Threaded through to `serializeRecallResults` so the
|
|
195
|
+
* `includeRecall` x-ray path honours the SAME read gate as normal recall and
|
|
196
|
+
* never attaches overlay transcript rows the gate excludes.
|
|
197
|
+
*/
|
|
198
|
+
rawExcerptNamespace?: string;
|
|
199
|
+
/**
|
|
200
|
+
* Ordered, read-authorized LCM read session_id SET (#1505 fallback
|
|
201
|
+
* unification). Threaded through to `serializeRecallResults` so the x-ray raw
|
|
202
|
+
* disclosure path also finds excerpts archived at the coding read fallbacks.
|
|
203
|
+
*/
|
|
204
|
+
rawExcerptSessionIds?: string[];
|
|
205
|
+
/**
|
|
206
|
+
* Force NO raw excerpts (#1505 thread NBHWz). Set when the IMPLICIT
|
|
207
|
+
* raw-excerpt read gate found NO readable LCM namespace, so the x-ray
|
|
208
|
+
* includeRecall path degrades to empty excerpts rather than falling back to
|
|
209
|
+
* the write/overlay namespace the read gate excludes.
|
|
210
|
+
*/
|
|
211
|
+
rawExcerptsSuppressed?: boolean;
|
|
212
|
+
}): Promise<EngramAccessRecallResponse> {
|
|
213
|
+
const memoryIds = options.snapshot.results.map((result) => result.memoryId);
|
|
214
|
+
const resultPaths = options.snapshot.results.map((result) => result.path);
|
|
215
|
+
const namespace = options.snapshot.namespace
|
|
216
|
+
? this.deps.resolveNamespace(options.snapshot.namespace)
|
|
217
|
+
: this.deps.orchestrator.config.defaultNamespace;
|
|
218
|
+
const sourcesUsed = Array.from(
|
|
219
|
+
new Set(options.snapshot.results.map((result) => result.servedBy)),
|
|
220
|
+
);
|
|
221
|
+
const snapshotForSerialization: LastRecallSnapshot = {
|
|
222
|
+
sessionKey: options.sessionKey ?? "",
|
|
223
|
+
recordedAt: new Date(options.snapshot.capturedAt).toISOString(),
|
|
224
|
+
queryHash: createHash("sha256").update(options.query).digest("hex"),
|
|
225
|
+
queryLen: options.query.length,
|
|
226
|
+
memoryIds,
|
|
227
|
+
namespace,
|
|
228
|
+
recallNamespaces: [namespace],
|
|
229
|
+
traceId: options.snapshot.traceId,
|
|
230
|
+
plannerMode: options.normalizedMode,
|
|
231
|
+
requestedMode:
|
|
232
|
+
options.requestedMode && options.requestedMode !== "auto"
|
|
233
|
+
? options.requestedMode
|
|
234
|
+
: undefined,
|
|
235
|
+
sourcesUsed,
|
|
236
|
+
budgetsApplied: {
|
|
237
|
+
appliedTopK: memoryIds.length,
|
|
238
|
+
recallBudgetChars: options.snapshot.budget.chars,
|
|
239
|
+
maxMemoryTokens: this.deps.orchestrator.config.maxMemoryTokens,
|
|
240
|
+
finalContextChars: options.snapshot.budget.used,
|
|
241
|
+
},
|
|
242
|
+
latencyMs: Date.now() - options.startedAt,
|
|
243
|
+
resultPaths,
|
|
244
|
+
};
|
|
245
|
+
const results = await this.deps.serializeRecallResults(
|
|
246
|
+
snapshotForSerialization,
|
|
247
|
+
options.disclosure,
|
|
248
|
+
{
|
|
249
|
+
query: options.query,
|
|
250
|
+
...(options.sessionKey ? { sessionKey: options.sessionKey } : {}),
|
|
251
|
+
...(options.rawExcerptNamespace
|
|
252
|
+
? { rawExcerptNamespace: options.rawExcerptNamespace }
|
|
253
|
+
: {}),
|
|
254
|
+
...(options.rawExcerptSessionIds !== undefined
|
|
255
|
+
? { rawExcerptSessionIds: options.rawExcerptSessionIds }
|
|
256
|
+
: {}),
|
|
257
|
+
...(options.rawExcerptsSuppressed
|
|
258
|
+
? { rawExcerptsSuppressed: options.rawExcerptsSuppressed }
|
|
259
|
+
: {}),
|
|
260
|
+
},
|
|
261
|
+
);
|
|
262
|
+
const context = results
|
|
263
|
+
.map((result) => {
|
|
264
|
+
const content =
|
|
265
|
+
typeof result.content === "string" && result.content.length > 0
|
|
266
|
+
? result.content
|
|
267
|
+
: "";
|
|
268
|
+
return content || result.preview;
|
|
269
|
+
})
|
|
270
|
+
.filter((text) => text.length > 0)
|
|
271
|
+
.join("\n\n");
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
query: options.query,
|
|
275
|
+
...(options.sessionKey ? { sessionKey: options.sessionKey } : {}),
|
|
276
|
+
namespace,
|
|
277
|
+
context,
|
|
278
|
+
count: memoryIds.length,
|
|
279
|
+
memoryIds,
|
|
280
|
+
results,
|
|
281
|
+
recordedAt: snapshotForSerialization.recordedAt,
|
|
282
|
+
traceId: options.snapshot.traceId,
|
|
283
|
+
plannerMode: options.normalizedMode,
|
|
284
|
+
fallbackUsed: sourcesUsed.some((source) => source !== "hybrid"),
|
|
285
|
+
sourcesUsed,
|
|
286
|
+
disclosure: options.disclosure,
|
|
287
|
+
budgetsApplied: snapshotForSerialization.budgetsApplied,
|
|
288
|
+
latencyMs: snapshotForSerialization.latencyMs,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async serializeRecallResults(
|
|
293
|
+
snapshot: LastRecallSnapshot | null,
|
|
294
|
+
disclosure: RecallDisclosure,
|
|
295
|
+
rawContext:
|
|
296
|
+
| {
|
|
297
|
+
query: string;
|
|
298
|
+
sessionKey?: string;
|
|
299
|
+
/**
|
|
300
|
+
* Read-authorization-gated namespace for the raw-excerpt LCM lookup
|
|
301
|
+
* (#1505 thread 2f7). When the caller supplies it, the raw lookup uses
|
|
302
|
+
* THIS namespace prefix instead of `snapshot.namespace` (the
|
|
303
|
+
* write/overlay namespace), so raw disclosure honours the SAME read
|
|
304
|
+
* gate as normal recall + `lcmSearch`. Omitted ⇒ falls back to the
|
|
305
|
+
* snapshot namespace (single-store / sessionless callers, unchanged).
|
|
306
|
+
*/
|
|
307
|
+
rawExcerptNamespace?: string;
|
|
308
|
+
/**
|
|
309
|
+
* Ordered, read-authorized LCM read session_id SET (#1505 fallback
|
|
310
|
+
* unification). When supplied, raw disclosure queries each key (primary
|
|
311
|
+
* coding overlay → read fallbacks) and merges rows so a branch-scoped
|
|
312
|
+
* session finds excerpts archived at project/root scope. Already
|
|
313
|
+
* read-gated, so no unauthorized overlay key is present. Omitted ⇒ the
|
|
314
|
+
* legacy single `rawExcerptNamespace`-prefixed key (unchanged).
|
|
315
|
+
*/
|
|
316
|
+
rawExcerptSessionIds?: string[];
|
|
317
|
+
/**
|
|
318
|
+
* Force NO raw excerpts even when `disclosure === "raw"` (#1505 thread
|
|
319
|
+
* NBHWz). Set by callers when the IMPLICIT raw-excerpt read gate found
|
|
320
|
+
* NO readable LCM namespace (a restrictive `default` READ policy with
|
|
321
|
+
* no readable overlay/self namespace). The lookup must NOT fall back to
|
|
322
|
+
* `snapshot.namespace` (the write/overlay namespace the read gate
|
|
323
|
+
* excludes) — it returns empty excerpts so raw recall degrades
|
|
324
|
+
* gracefully instead of leaking unreadable rows or throwing.
|
|
325
|
+
*/
|
|
326
|
+
rawExcerptsSuppressed?: boolean;
|
|
327
|
+
}
|
|
328
|
+
| null = null,
|
|
329
|
+
): Promise<EngramAccessMemorySummary[]> {
|
|
330
|
+
if (!snapshot) return [];
|
|
331
|
+
const namespace = snapshot.namespace ? this.deps.resolveNamespace(snapshot.namespace) : this.deps.orchestrator.config.defaultNamespace;
|
|
332
|
+
const storage = await this.deps.orchestrator.getStorage(namespace);
|
|
333
|
+
const storageDir = storage.dir;
|
|
334
|
+
const recallNamespaces = Array.from(
|
|
335
|
+
new Set(
|
|
336
|
+
[
|
|
337
|
+
namespace,
|
|
338
|
+
...(Array.isArray(snapshot.recallNamespaces)
|
|
339
|
+
? snapshot.recallNamespaces.map((ns) => this.deps.resolveNamespace(ns))
|
|
340
|
+
: []),
|
|
341
|
+
this.deps.orchestrator.config.defaultNamespace,
|
|
342
|
+
this.deps.orchestrator.config.sharedNamespace,
|
|
343
|
+
...(this.deps.orchestrator.config.namespacePolicies ?? []).map((p) => p.name),
|
|
344
|
+
].filter((ns): ns is string => typeof ns === "string" && ns.length > 0),
|
|
345
|
+
),
|
|
346
|
+
);
|
|
347
|
+
const results: EngramAccessMemorySummary[] = [];
|
|
348
|
+
const seen = new Set<string>();
|
|
349
|
+
const collectionNamespaceFromPrefix = (collectionPrefix: string): string | null => {
|
|
350
|
+
const baseCollection = this.deps.orchestrator.config.qmdCollection;
|
|
351
|
+
if (collectionPrefix === baseCollection) return this.deps.orchestrator.config.defaultNamespace;
|
|
352
|
+
const namespaceSuffix = collectionPrefix.startsWith(`${baseCollection}--`)
|
|
353
|
+
? collectionPrefix.slice(baseCollection.length + 2)
|
|
354
|
+
: "";
|
|
355
|
+
if (!namespaceSuffix) return null;
|
|
356
|
+
|
|
357
|
+
const decoded = namespaceIdentityFromToken(namespaceSuffix);
|
|
358
|
+
if (decoded !== null) return decoded || this.deps.orchestrator.config.defaultNamespace;
|
|
359
|
+
if (namespaceSuffix.startsWith("ns--")) {
|
|
360
|
+
const legacyNamespace = namespaceSuffix.slice("ns--".length).trim();
|
|
361
|
+
return legacyNamespace || null;
|
|
362
|
+
}
|
|
363
|
+
return null;
|
|
364
|
+
};
|
|
365
|
+
const readResultPath = async (
|
|
366
|
+
memoryPath: string,
|
|
367
|
+
): Promise<{ memory: MemoryFile; baseDir: string } | null> => {
|
|
368
|
+
const parts = qmdCollectionPathParts(memoryPath);
|
|
369
|
+
const coldCollection =
|
|
370
|
+
this.deps.orchestrator.config.qmdColdCollection ?? "openclaw-engram-cold";
|
|
371
|
+
if (parts && parts.collection === coldCollection) {
|
|
372
|
+
const storages: Array<{ storage: StorageManager; dir: string }> = [];
|
|
373
|
+
const seenStorageDirs = new Set<string>();
|
|
374
|
+
const addStorage = (candidateStorage: StorageManager): void => {
|
|
375
|
+
const candidateDir = nodePath.resolve(candidateStorage.dir);
|
|
376
|
+
if (seenStorageDirs.has(candidateDir)) return;
|
|
377
|
+
seenStorageDirs.add(candidateDir);
|
|
378
|
+
storages.push({ storage: candidateStorage, dir: candidateDir });
|
|
379
|
+
};
|
|
380
|
+
addStorage(storage);
|
|
381
|
+
for (const recallNamespace of recallNamespaces) {
|
|
382
|
+
try {
|
|
383
|
+
addStorage(await this.deps.orchestrator.getStorage(recallNamespace));
|
|
384
|
+
} catch {
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
for (const candidateStorage of storages) {
|
|
389
|
+
try {
|
|
390
|
+
const coldRoot = nodePath.join(candidateStorage.dir, "cold");
|
|
391
|
+
for (const candidatePath of qmdResultPathCandidates(
|
|
392
|
+
coldRoot,
|
|
393
|
+
parts.relativePath,
|
|
394
|
+
)) {
|
|
395
|
+
const memory =
|
|
396
|
+
await candidateStorage.storage.readMemoryByPath(candidatePath);
|
|
397
|
+
if (memory) return { memory, baseDir: candidateStorage.dir };
|
|
398
|
+
}
|
|
399
|
+
} catch (err) {
|
|
400
|
+
if (err instanceof SecureStoreLockedError) throw err;
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const collectionNamespace = parts
|
|
408
|
+
? collectionNamespaceFromPrefix(parts.collection)
|
|
409
|
+
: null;
|
|
410
|
+
|
|
411
|
+
if (parts && collectionNamespace) {
|
|
412
|
+
try {
|
|
413
|
+
const collectionStorage =
|
|
414
|
+
await this.deps.orchestrator.getStorage(collectionNamespace);
|
|
415
|
+
for (const candidate of qmdResultPathCandidates(
|
|
416
|
+
collectionStorage.dir,
|
|
417
|
+
parts.relativePath,
|
|
418
|
+
)) {
|
|
419
|
+
const memory = await collectionStorage.readMemoryByPath(candidate);
|
|
420
|
+
if (memory) return { memory, baseDir: collectionStorage.dir };
|
|
421
|
+
}
|
|
422
|
+
return null;
|
|
423
|
+
} catch (err) {
|
|
424
|
+
if (err instanceof SecureStoreLockedError) throw err;
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (nodePath.isAbsolute(memoryPath)) {
|
|
430
|
+
const ownerStorage = await this.deps.storageForAbsoluteRecallPath(
|
|
431
|
+
memoryPath,
|
|
432
|
+
namespace,
|
|
433
|
+
recallNamespaces,
|
|
434
|
+
);
|
|
435
|
+
if (!ownerStorage) return null;
|
|
436
|
+
for (const candidate of qmdResultPathCandidates(
|
|
437
|
+
ownerStorage.dir,
|
|
438
|
+
memoryPath,
|
|
439
|
+
)) {
|
|
440
|
+
const memory = await ownerStorage.storage.readMemoryByPath(candidate);
|
|
441
|
+
if (memory) return { memory, baseDir: ownerStorage.dir };
|
|
442
|
+
}
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
for (const candidate of qmdResultPathCandidates(storageDir, memoryPath)) {
|
|
447
|
+
const memory = await storage.readMemoryByPath(candidate);
|
|
448
|
+
if (memory) return { memory, baseDir: storageDir };
|
|
449
|
+
}
|
|
450
|
+
return null;
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
// Pre-fetch raw excerpts once when `disclosure === "raw"` so we don't
|
|
454
|
+
// hit the LCM archive per-result (issue #677 PR 2/4). Excerpts are
|
|
455
|
+
// attached to the first result; per-result attribution is reserved
|
|
456
|
+
// for a future PR if/when the LCM index can be joined to memory ids.
|
|
457
|
+
// Coerce `null` (non-raw disclosure) to `undefined` so the optional
|
|
458
|
+
// serializer field is never explicitly `null`.
|
|
459
|
+
// Namespace for the LCM `${namespace}:${sessionKey}` prefix: prefer the
|
|
460
|
+
// caller-supplied READ-AUTHORIZATION-GATED `rawExcerptNamespace` (#1505
|
|
461
|
+
// thread 2f7) so raw disclosure honours the same read gate as normal recall
|
|
462
|
+
// + `lcmSearch` and never attaches `<principal>-project-*` overlay rows the
|
|
463
|
+
// gate excludes. Fall back to the snapshot's resolved namespace only when no
|
|
464
|
+
// gated namespace was threaded (sessionless / legacy callers) — unchanged.
|
|
465
|
+
const rawExcerptsResult =
|
|
466
|
+
rawContext?.rawExcerptsSuppressed === true
|
|
467
|
+
? // Implicit raw recall with NO readable LCM namespace (#1505 thread
|
|
468
|
+
// NBHWz): emit empty excerpts rather than falling back to the
|
|
469
|
+
// write/overlay `namespace` the read gate excludes.
|
|
470
|
+
[]
|
|
471
|
+
: await this.deps.fetchRawExcerpts(
|
|
472
|
+
disclosure,
|
|
473
|
+
rawContext
|
|
474
|
+
? {
|
|
475
|
+
query: rawContext.query,
|
|
476
|
+
...(rawContext.sessionKey
|
|
477
|
+
? { sessionKey: rawContext.sessionKey }
|
|
478
|
+
: {}),
|
|
479
|
+
namespace: rawContext.rawExcerptNamespace ?? namespace,
|
|
480
|
+
...(rawContext.rawExcerptSessionIds !== undefined
|
|
481
|
+
? { lcmSessionIds: rawContext.rawExcerptSessionIds }
|
|
482
|
+
: {}),
|
|
483
|
+
}
|
|
484
|
+
: null,
|
|
485
|
+
);
|
|
486
|
+
const rawExcerpts = rawExcerptsResult ?? undefined;
|
|
487
|
+
|
|
488
|
+
for (const memoryPath of snapshot.resultPaths ?? []) {
|
|
489
|
+
if (!memoryPath || seen.has(memoryPath)) continue;
|
|
490
|
+
const resolved = await readResultPath(memoryPath);
|
|
491
|
+
if (!resolved) continue;
|
|
492
|
+
const { memory, baseDir } = resolved;
|
|
493
|
+
seen.add(memoryPath);
|
|
494
|
+
results.push(
|
|
495
|
+
this.deps.serializeMemorySummary(
|
|
496
|
+
memory,
|
|
497
|
+
baseDir,
|
|
498
|
+
disclosure,
|
|
499
|
+
// Attach the (possibly empty) raw excerpts to the first raw
|
|
500
|
+
// result; subsequent results do not duplicate the array.
|
|
501
|
+
results.length === 0 ? rawExcerpts : undefined,
|
|
502
|
+
),
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (results.length > 0) return results;
|
|
507
|
+
|
|
508
|
+
for (const memoryId of snapshot.memoryIds) {
|
|
509
|
+
const memory = await storage.getMemoryById(memoryId);
|
|
510
|
+
if (!memory || seen.has(memory.path)) continue;
|
|
511
|
+
seen.add(memory.path);
|
|
512
|
+
results.push(
|
|
513
|
+
this.deps.serializeMemorySummary(
|
|
514
|
+
memory,
|
|
515
|
+
storageDir,
|
|
516
|
+
disclosure,
|
|
517
|
+
results.length === 0 ? rawExcerpts : undefined,
|
|
518
|
+
),
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
return results;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Fetch raw transcript excerpts from the LCM archive for `disclosure ===
|
|
526
|
+
* "raw"` recalls (issue #677 PR 2/4). Returns `null` for non-raw recall
|
|
527
|
+
* depths, an empty array when LCM is disabled / not initialized / has no
|
|
528
|
+
* matches, and an array of LCM-side excerpts otherwise. Errors are
|
|
529
|
+
* swallowed and treated as "no excerpts" so a failing LCM never breaks
|
|
530
|
+
* the recall response.
|
|
531
|
+
*
|
|
532
|
+
* Namespace handling: LCM archival prefixes non-default-namespace
|
|
533
|
+
* sessions with `${namespace}:${sessionKey}` (see `observe()` around
|
|
534
|
+
* line 2498), so the lookup must mirror that prefix or raw recalls in
|
|
535
|
+
* non-default namespaces miss their own excerpts.
|
|
536
|
+
*/
|
|
537
|
+
async fetchRawExcerpts(
|
|
538
|
+
disclosure: RecallDisclosure,
|
|
539
|
+
context: {
|
|
540
|
+
query: string;
|
|
541
|
+
sessionKey?: string;
|
|
542
|
+
namespace?: string;
|
|
543
|
+
/**
|
|
544
|
+
* Pre-resolved, ordered, read-authorized LCM read session_id SET (#1505
|
|
545
|
+
* fallback unification). When supplied, raw disclosure queries each key in
|
|
546
|
+
* order (primary coding overlay → read fallbacks) and merges rows, exactly
|
|
547
|
+
* as the orchestrator recall path and `lcmSearch` do, so a branch-scoped
|
|
548
|
+
* session finds excerpts archived at project/root scope. Already
|
|
549
|
+
* read-gated by `resolveLcmReadSessionIds`, so an unauthorized
|
|
550
|
+
* `<principal>-project-*` key is never present. Falls back to the legacy
|
|
551
|
+
* single `namespace`-prefixed key when absent (sessionless / legacy
|
|
552
|
+
* callers).
|
|
553
|
+
*/
|
|
554
|
+
lcmSessionIds?: string[];
|
|
555
|
+
} | null,
|
|
556
|
+
): Promise<EngramAccessMemorySummary["rawExcerpts"] | null> {
|
|
557
|
+
if (disclosure !== "raw") return null;
|
|
558
|
+
if (!context || !context.query) return [];
|
|
559
|
+
// Privacy guard: raw disclosure must be session-scoped. Without a
|
|
560
|
+
// sessionKey, `lcm.searchContextFull(query, n, undefined)` searches
|
|
561
|
+
// across every archived session in the LCM store and would return
|
|
562
|
+
// excerpts from unrelated sessions (potentially crossing namespaces
|
|
563
|
+
// via their `${namespace}:${sessionKey}` prefix encoding). Treat a
|
|
564
|
+
// missing sessionKey as "no excerpts" — callers asking for raw
|
|
565
|
+
// disclosure outside a session get an empty list, not a leak.
|
|
566
|
+
if (!context.sessionKey) return [];
|
|
567
|
+
const lcm = this.deps.orchestrator.lcmEngine;
|
|
568
|
+
if (!lcm || !lcm.enabled) return [];
|
|
569
|
+
try {
|
|
570
|
+
const legacyKey =
|
|
571
|
+
context.namespace &&
|
|
572
|
+
context.namespace !== this.deps.orchestrator.config.defaultNamespace
|
|
573
|
+
? `${context.namespace}:${context.sessionKey}`
|
|
574
|
+
: context.sessionKey;
|
|
575
|
+
const lcmSessionIds =
|
|
576
|
+
context.lcmSessionIds !== undefined
|
|
577
|
+
? context.lcmSessionIds
|
|
578
|
+
: [legacyKey];
|
|
579
|
+
// Cap the excerpt fanout so recall responses stay bounded. Five matches
|
|
580
|
+
// is enough to anchor the model in the raw transcript without ballooning
|
|
581
|
+
// token spend; raw is meant as the escape hatch, not the default. The cap
|
|
582
|
+
// is applied across the MERGED result set so adding fallback keys never
|
|
583
|
+
// inflates the excerpt budget.
|
|
584
|
+
const limit = 5;
|
|
585
|
+
const seenRows = new Set<string>();
|
|
586
|
+
const excerpts: NonNullable<EngramAccessMemorySummary["rawExcerpts"]> = [];
|
|
587
|
+
const settledRows = await Promise.allSettled(
|
|
588
|
+
lcmSessionIds.map(async (lcmSessionKey) =>
|
|
589
|
+
lcm.searchContextFull(context.query, limit, lcmSessionKey),
|
|
590
|
+
),
|
|
591
|
+
);
|
|
592
|
+
for (const result of settledRows) {
|
|
593
|
+
if (excerpts.length >= limit) break;
|
|
594
|
+
if (result.status !== "fulfilled") continue;
|
|
595
|
+
for (const r of result.value) {
|
|
596
|
+
const dedupeKey = `${r.session_id} ${r.turn_index}`;
|
|
597
|
+
if (seenRows.has(dedupeKey)) continue;
|
|
598
|
+
seenRows.add(dedupeKey);
|
|
599
|
+
excerpts.push({
|
|
600
|
+
turnIndex: r.turn_index,
|
|
601
|
+
role: r.role,
|
|
602
|
+
content: r.content,
|
|
603
|
+
sessionId: r.session_id,
|
|
604
|
+
});
|
|
605
|
+
if (excerpts.length >= limit) break;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
return excerpts;
|
|
609
|
+
} catch {
|
|
610
|
+
// CLAUDE.md rule 13: never let an external subsystem (LCM/SQLite)
|
|
611
|
+
// crash the primary recall flow.
|
|
612
|
+
return [];
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
async executeRecall(
|
|
617
|
+
request: EngramAccessRecallRequest,
|
|
618
|
+
): Promise<{
|
|
619
|
+
response: EngramAccessRecallResponse;
|
|
620
|
+
budgetRecordPrincipal: string | null;
|
|
621
|
+
}> {
|
|
622
|
+
const query = request.query;
|
|
623
|
+
// Disclosure depth (issue #677). Default to `"chunk"` when omitted so
|
|
624
|
+
// pre-#677 callers see unchanged behavior. Reject explicitly invalid
|
|
625
|
+
// string values per CLAUDE.md rule 51 (do not silently fall back).
|
|
626
|
+
const callerProvidedDisclosure =
|
|
627
|
+
request.disclosure !== undefined && request.disclosure !== null;
|
|
628
|
+
const requestedDisclosure: RecallDisclosure = (() => {
|
|
629
|
+
if (!callerProvidedDisclosure) {
|
|
630
|
+
return DEFAULT_RECALL_DISCLOSURE;
|
|
631
|
+
}
|
|
632
|
+
if (!isRecallDisclosure(request.disclosure)) {
|
|
633
|
+
throw new EngramAccessInputError(
|
|
634
|
+
`disclosure must be one of: chunk, section, raw (got: ${String(request.disclosure)})`,
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
return request.disclosure;
|
|
638
|
+
})();
|
|
639
|
+
// Attach any coding context shipped with the recall request BEFORE
|
|
640
|
+
// namespace resolution so the overlay applies to this recall (issue #569).
|
|
641
|
+
if (request.codingContext !== undefined && request.sessionKey) {
|
|
642
|
+
this.deps.setCodingContext({
|
|
643
|
+
sessionKey: request.sessionKey,
|
|
644
|
+
codingContext: request.codingContext,
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
// Auto-resolve coding context from cwd/projectTag when no explicit
|
|
648
|
+
// codingContext was supplied (issue #569 wiring). This allows Claude
|
|
649
|
+
// Code hooks and OpenClaw connectors to get project-scoped memory
|
|
650
|
+
// transparently.
|
|
651
|
+
if (request.codingContext === undefined && request.sessionKey) {
|
|
652
|
+
await this.deps.maybeAttachCodingContext(request.sessionKey, {
|
|
653
|
+
cwd: request.cwd,
|
|
654
|
+
projectTag: request.projectTag,
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
const authenticatedPrincipal = request.authenticatedPrincipal?.trim();
|
|
658
|
+
const namespaceOverride = this.deps.resolveRecallNamespace(
|
|
659
|
+
request.namespace,
|
|
660
|
+
request.sessionKey,
|
|
661
|
+
authenticatedPrincipal,
|
|
662
|
+
);
|
|
663
|
+
const namespace = namespaceOverride ?? this.deps.orchestrator.config.defaultNamespace;
|
|
664
|
+
// Normalize mode early so that no_recall / invalid modes skip budget
|
|
665
|
+
// accounting (Codex P1: budget recorded before mode validation).
|
|
666
|
+
const mode = this.deps.normalizeRecallMode(request.mode);
|
|
667
|
+
const maybePrincipal = this.deps.resolveRequestPrincipal(request.sessionKey, authenticatedPrincipal);
|
|
668
|
+
if (resolveNamespaceCapabilities(this.deps.orchestrator.config).namespaces && !maybePrincipal) {
|
|
669
|
+
throw new EngramAccessInputError(
|
|
670
|
+
"authentication required: namespaces are enabled and no principal was supplied",
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
const principal = maybePrincipal ?? "default";
|
|
674
|
+
const principalNamespace = defaultNamespaceForPrincipal(principal, this.deps.orchestrator.config);
|
|
675
|
+
const profileCodingContext =
|
|
676
|
+
request.sessionKey && typeof this.deps.orchestrator.getCodingContextForSession === "function"
|
|
677
|
+
? this.deps.orchestrator.getCodingContextForSession(request.sessionKey)
|
|
678
|
+
: null;
|
|
679
|
+
const profileCodingOverlay =
|
|
680
|
+
!namespaceOverride &&
|
|
681
|
+
profileCodingContext &&
|
|
682
|
+
resolveNamespaceCapabilities(this.deps.orchestrator.config).namespaces &&
|
|
683
|
+
this.deps.orchestrator.config.codingMode?.projectScope
|
|
684
|
+
? resolveCodingNamespaceOverlay(
|
|
685
|
+
profileCodingContext,
|
|
686
|
+
this.deps.orchestrator.config.codingMode,
|
|
687
|
+
this.deps.orchestrator.config.defaultNamespace,
|
|
688
|
+
)
|
|
689
|
+
: null;
|
|
690
|
+
const profilePlan = namespaceOverride
|
|
691
|
+
? null
|
|
692
|
+
: resolveScopeProfilePlan({
|
|
693
|
+
config: this.deps.orchestrator.config,
|
|
694
|
+
principal,
|
|
695
|
+
codingContext: profileCodingContext,
|
|
696
|
+
codingOverlay: profileCodingOverlay,
|
|
697
|
+
});
|
|
698
|
+
// Skip budget checks for modes that never perform a cross-namespace read.
|
|
699
|
+
const modeSkipsBudget = mode === "no_recall";
|
|
700
|
+
// Derive the full set of namespaces the orchestrator will actually search.
|
|
701
|
+
// When no explicit override is provided, `recallNamespacesForPrincipal()` may
|
|
702
|
+
// expand to shared / policy-default namespaces. Budget must be checked
|
|
703
|
+
// against every cross-namespace entry in the effective set so that omitting
|
|
704
|
+
// `namespace` cannot bypass the limiter (Cursor/Codex review feedback).
|
|
705
|
+
//
|
|
706
|
+
const legacyRecallNamespaces = Array.isArray(this.deps.orchestrator.config.defaultRecallNamespaces)
|
|
707
|
+
? recallNamespacesForPrincipal(principal, this.deps.orchestrator.config)
|
|
708
|
+
: [];
|
|
709
|
+
const effectiveNamespaces = namespaceOverride
|
|
710
|
+
? [namespaceOverride]
|
|
711
|
+
: profilePlan
|
|
712
|
+
? expandScopeProfileReadNamespaces({
|
|
713
|
+
profilePlan,
|
|
714
|
+
principalSelfNamespace: profilePlan.baseNamespace,
|
|
715
|
+
config: this.deps.orchestrator.config,
|
|
716
|
+
principal,
|
|
717
|
+
codingOverlay: profileCodingOverlay,
|
|
718
|
+
legacyRecallNamespaces,
|
|
719
|
+
})
|
|
720
|
+
: legacyRecallNamespaces;
|
|
721
|
+
const budgetPrincipalNamespace = profilePlan?.baseNamespace ?? principalNamespace;
|
|
722
|
+
let budgetDecision: BudgetDecision;
|
|
723
|
+
let recordBudgetAfterSuccess = false;
|
|
724
|
+
if (modeSkipsBudget) {
|
|
725
|
+
budgetDecision = {
|
|
726
|
+
allowed: true as const,
|
|
727
|
+
reason: "allowed-same-namespace" as const,
|
|
728
|
+
count: 0,
|
|
729
|
+
limit: {
|
|
730
|
+
softLimit: this.deps.orchestrator.config.recallCrossNamespaceBudgetSoftLimit ?? 10,
|
|
731
|
+
hardLimit: this.deps.orchestrator.config.recallCrossNamespaceBudgetHardLimit ?? 30,
|
|
732
|
+
windowMs: this.deps.orchestrator.config.recallCrossNamespaceBudgetWindowMs ?? 60_000,
|
|
733
|
+
},
|
|
734
|
+
};
|
|
735
|
+
} else {
|
|
736
|
+
// Peek at every effective namespace to determine whether ANY would be
|
|
737
|
+
// cross-namespace WITHOUT recording side effects (Cursor review:
|
|
738
|
+
// multi-count bug). Record a single budget event only when at least
|
|
739
|
+
// one effective namespace differs from the principal's self namespace,
|
|
740
|
+
// and only after recall succeeds so retried transient failures do not
|
|
741
|
+
// consume budget multiple times before a successful response can be
|
|
742
|
+
// cached behind the request idempotency key.
|
|
743
|
+
let anyCrossNamespace = false;
|
|
744
|
+
let denied: BudgetDecision | null = null;
|
|
745
|
+
let crossNamespaceDecision: BudgetDecision | null = null;
|
|
746
|
+
for (const ns of effectiveNamespaces) {
|
|
747
|
+
const peek = this.deps.budget.peek({
|
|
748
|
+
principal,
|
|
749
|
+
principalNamespace: budgetPrincipalNamespace,
|
|
750
|
+
queryNamespace: ns,
|
|
751
|
+
});
|
|
752
|
+
if (peek.reason !== "allowed-same-namespace") {
|
|
753
|
+
anyCrossNamespace = true;
|
|
754
|
+
crossNamespaceDecision ??= peek;
|
|
755
|
+
}
|
|
756
|
+
if (!peek.allowed) {
|
|
757
|
+
denied = peek;
|
|
758
|
+
break;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
if (denied) {
|
|
762
|
+
// The peek projected a denial — deny without recording so the
|
|
763
|
+
// bucket is not inflated by rejected attempts.
|
|
764
|
+
budgetDecision = denied;
|
|
765
|
+
} else if (anyCrossNamespace) {
|
|
766
|
+
budgetDecision = crossNamespaceDecision ?? {
|
|
767
|
+
allowed: true as const,
|
|
768
|
+
reason: "allowed-under-soft" as const,
|
|
769
|
+
count: 0,
|
|
770
|
+
limit: {
|
|
771
|
+
softLimit: this.deps.orchestrator.config.recallCrossNamespaceBudgetSoftLimit ?? 10,
|
|
772
|
+
hardLimit: this.deps.orchestrator.config.recallCrossNamespaceBudgetHardLimit ?? 30,
|
|
773
|
+
windowMs: this.deps.orchestrator.config.recallCrossNamespaceBudgetWindowMs ?? 60_000,
|
|
774
|
+
},
|
|
775
|
+
};
|
|
776
|
+
recordBudgetAfterSuccess = true;
|
|
777
|
+
} else {
|
|
778
|
+
budgetDecision = {
|
|
779
|
+
allowed: true as const,
|
|
780
|
+
reason: "allowed-same-namespace" as const,
|
|
781
|
+
count: 0,
|
|
782
|
+
limit: {
|
|
783
|
+
softLimit: this.deps.orchestrator.config.recallCrossNamespaceBudgetSoftLimit ?? 10,
|
|
784
|
+
hardLimit: this.deps.orchestrator.config.recallCrossNamespaceBudgetHardLimit ?? 30,
|
|
785
|
+
windowMs: this.deps.orchestrator.config.recallCrossNamespaceBudgetWindowMs ?? 60_000,
|
|
786
|
+
},
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
if (!budgetDecision.allowed) {
|
|
790
|
+
throw new EngramAccessInputError(
|
|
791
|
+
`recall denied: cross-namespace budget exceeded (${budgetDecision.count}/${budgetDecision.limit.hardLimit} in ${budgetDecision.limit.windowMs}ms window)`,
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
// Prune expired principal buckets to prevent unbounded Map growth from
|
|
795
|
+
// high-cardinality / transient principals (Codex P2 review feedback).
|
|
796
|
+
this.deps.budget.gc();
|
|
797
|
+
}
|
|
798
|
+
const topK = Number.isFinite(request.topK) ? Math.max(0, Math.floor(request.topK ?? 0)) : undefined;
|
|
799
|
+
// Issue #680 — historical recall pin. Validate at the input
|
|
800
|
+
// boundary so a malformed `asOf` is rejected with a structured
|
|
801
|
+
// 400 instead of silently flooring at NaN inside the orchestrator
|
|
802
|
+
// (CLAUDE.md rule 51, gotcha #51). Empty / undefined is fine —
|
|
803
|
+
// means "no pin".
|
|
804
|
+
let asOf: string | undefined;
|
|
805
|
+
if (request.asOf !== undefined && request.asOf !== null) {
|
|
806
|
+
if (typeof request.asOf !== "string" || request.asOf.trim().length === 0) {
|
|
807
|
+
throw new EngramAccessInputError(
|
|
808
|
+
"asOf must be a non-empty ISO 8601 timestamp string",
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
const parsed = Date.parse(request.asOf);
|
|
812
|
+
if (!Number.isFinite(parsed)) {
|
|
813
|
+
throw new EngramAccessInputError(
|
|
814
|
+
`asOf must be a parseable ISO 8601 timestamp (got: "${request.asOf}")`,
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
asOf = request.asOf;
|
|
818
|
+
}
|
|
819
|
+
const recallOptions: RecallInvocationOptions = {
|
|
820
|
+
namespace: namespaceOverride,
|
|
821
|
+
topK,
|
|
822
|
+
mode,
|
|
823
|
+
...(authenticatedPrincipal ? { principalOverride: authenticatedPrincipal } : {}),
|
|
824
|
+
...(asOf !== undefined ? { asOf } : {}),
|
|
825
|
+
...(request.includeLowConfidence === true ? { includeLowConfidence: true } : {}),
|
|
826
|
+
};
|
|
827
|
+
const startedAt = Date.now();
|
|
828
|
+
const context = await this.deps.orchestrator.recall(query, request.sessionKey, recallOptions);
|
|
829
|
+
const snapshot = request.sessionKey
|
|
830
|
+
? this.deps.orchestrator.lastRecall.get(request.sessionKey)
|
|
831
|
+
: null;
|
|
832
|
+
const effectiveNamespace = snapshot?.namespace
|
|
833
|
+
? this.deps.resolveNamespace(snapshot.namespace)
|
|
834
|
+
: namespace;
|
|
835
|
+
// Auto-escalation policy (issue #677 PR 4/4). When the operator
|
|
836
|
+
// configured `recallDisclosureEscalation: "auto"` AND the caller
|
|
837
|
+
// did not explicitly choose a disclosure level AND recall produced
|
|
838
|
+
// a low-confidence result set (proxied by fill ratio: results
|
|
839
|
+
// returned / topK requested), we escalate the default `chunk`
|
|
840
|
+
// shape to `section` so the LLM gets richer context to compensate
|
|
841
|
+
// for ambiguous retrieval. Manual mode and explicit caller
|
|
842
|
+
// disclosure both bypass the policy. Documented in
|
|
843
|
+
// `recall-disclosure-escalation.ts` and unit-tested there.
|
|
844
|
+
// Confidence-proxy denominator: priority order is
|
|
845
|
+
// 1. `snapshot.budgetsApplied.appliedTopK` (ALWAYS wins) — this is
|
|
846
|
+
// the limit the orchestrator actually applied after planner /
|
|
847
|
+
// minimal-mode / section-cap narrowing. Codex P1 rounds 2+3
|
|
848
|
+
// on #705 emphasize that even a caller's explicit `request.topK`
|
|
849
|
+
// is wrong when the orchestrator caps below it (e.g. topK=50
|
|
850
|
+
// but appliedTopK=3 makes a 2-hit recall actually 0.67, not
|
|
851
|
+
// 0.04).
|
|
852
|
+
// 2. The caller's explicit `topK` when the snapshot lacks
|
|
853
|
+
// `budgetsApplied` (early-return paths, error cases).
|
|
854
|
+
// 3. Config `qmdMaxResults` as a last-resort fallback.
|
|
855
|
+
// Floor at observed-results so the ratio stays in [0, 1] even if
|
|
856
|
+
// any of the signals drifts below the actual hit count.
|
|
857
|
+
const resultsReturned = snapshot?.memoryIds?.length ?? 0;
|
|
858
|
+
const appliedTopK = snapshot?.budgetsApplied?.appliedTopK;
|
|
859
|
+
const configMaxResults =
|
|
860
|
+
typeof this.deps.orchestrator.config.qmdMaxResults === "number" &&
|
|
861
|
+
Number.isFinite(this.deps.orchestrator.config.qmdMaxResults) &&
|
|
862
|
+
this.deps.orchestrator.config.qmdMaxResults > 0
|
|
863
|
+
? this.deps.orchestrator.config.qmdMaxResults
|
|
864
|
+
: 0;
|
|
865
|
+
const topKDenominator =
|
|
866
|
+
typeof appliedTopK === "number" &&
|
|
867
|
+
Number.isFinite(appliedTopK) &&
|
|
868
|
+
appliedTopK > 0
|
|
869
|
+
? Math.max(appliedTopK, resultsReturned)
|
|
870
|
+
: typeof topK === "number" && topK > 0
|
|
871
|
+
? Math.max(topK, resultsReturned)
|
|
872
|
+
: Math.max(configMaxResults, resultsReturned, 1);
|
|
873
|
+
// When the recall produced no snapshot (sessionless / namespace
|
|
874
|
+
// mismatch / early-return path), there is no confidence signal to
|
|
875
|
+
// base escalation on. Pass `undefined` so the helper takes its
|
|
876
|
+
// `no-top-k-confidence` branch instead of computing 0/N=0 and
|
|
877
|
+
// forcing auto-escalation on every sessionless caller (Codex P2
|
|
878
|
+
// review on PR #705).
|
|
879
|
+
const topKConfidence =
|
|
880
|
+
snapshot && topKDenominator > 0
|
|
881
|
+
? Math.min(1, resultsReturned / topKDenominator)
|
|
882
|
+
: undefined;
|
|
883
|
+
const escalationDecision = decideDisclosureEscalation({
|
|
884
|
+
mode: this.deps.orchestrator.config.recallDisclosureEscalation,
|
|
885
|
+
threshold: this.deps.orchestrator.config.recallDisclosureEscalationThreshold,
|
|
886
|
+
originalDisclosure: requestedDisclosure,
|
|
887
|
+
callerProvidedDisclosure,
|
|
888
|
+
topKConfidence,
|
|
889
|
+
});
|
|
890
|
+
const disclosure = escalationDecision.effective;
|
|
891
|
+
// Gate the raw-excerpt LCM read with the SAME read-authorization namespace
|
|
892
|
+
// `lcmSearch` + the in-prompt LCM sections use (#1505 thread 2f7), so
|
|
893
|
+
// `disclosure: "raw"` never attaches `<principal>-project-*` overlay rows
|
|
894
|
+
// when the principal can WRITE but not READ its self base (or
|
|
895
|
+
// `defaultRecallNamespaces` omits `self`). Computed ONLY for raw disclosure:
|
|
896
|
+
// it is the sole consumer, and resolving the overlay on every chunk/section
|
|
897
|
+
// recall would be wasted work — keeping non-raw recall byte-for-byte
|
|
898
|
+
// unchanged.
|
|
899
|
+
// Trim the sessionKey to match what `orchestrator.recall(...)` already does
|
|
900
|
+
// (`request.sessionKey?.trim() || undefined`) and what the x-ray raw-excerpt
|
|
901
|
+
// path uses (cursor "Raw excerpt key not trimmed"). A whitespace-padded key
|
|
902
|
+
// otherwise drives recall under one identity but resolves the raw-excerpt
|
|
903
|
+
// overlay namespace + LCM `session_id` under a DIFFERENT (untrimmed) prefix,
|
|
904
|
+
// so excerpts are gated/queried inconsistently with recall and the x-ray path.
|
|
905
|
+
const trimmedSessionKey = request.sessionKey?.trim() || undefined;
|
|
906
|
+
const rawExcerptNamespace =
|
|
907
|
+
disclosure === "raw"
|
|
908
|
+
? this.deps.resolveRawExcerptReadNamespace(
|
|
909
|
+
request.namespace,
|
|
910
|
+
trimmedSessionKey,
|
|
911
|
+
authenticatedPrincipal,
|
|
912
|
+
)
|
|
913
|
+
: undefined;
|
|
914
|
+
// `undefined` for an IMPLICIT raw recall means NO readable LCM namespace
|
|
915
|
+
// exists (restrictive `default` READ policy, no readable overlay/self) —
|
|
916
|
+
// suppress excerpts rather than fall back to the write/overlay namespace the
|
|
917
|
+
// read gate excludes (#1505 thread NBHWz). An EXPLICIT namespace always
|
|
918
|
+
// resolves (or throws) above, so suppression only applies to the implicit
|
|
919
|
+
// path.
|
|
920
|
+
const hasExplicitNamespace =
|
|
921
|
+
typeof request.namespace === "string" &&
|
|
922
|
+
request.namespace.trim().length > 0;
|
|
923
|
+
const rawExcerptsSuppressed =
|
|
924
|
+
disclosure === "raw" &&
|
|
925
|
+
!hasExplicitNamespace &&
|
|
926
|
+
rawExcerptNamespace === undefined;
|
|
927
|
+
// Ordered, read-authorized LCM read key SET (#1505 fallback unification) so
|
|
928
|
+
// raw disclosure finds excerpts a branch-scoped session archived at
|
|
929
|
+
// project/root scope — exactly as recall + `lcmSearch` do. Only with a
|
|
930
|
+
// concrete sessionKey; already read-gated.
|
|
931
|
+
const rawExcerptSessionIds =
|
|
932
|
+
disclosure === "raw" && rawExcerptNamespace && trimmedSessionKey
|
|
933
|
+
? this.deps.resolveLcmReadSessionIds(
|
|
934
|
+
request.namespace,
|
|
935
|
+
rawExcerptNamespace,
|
|
936
|
+
trimmedSessionKey,
|
|
937
|
+
authenticatedPrincipal,
|
|
938
|
+
)
|
|
939
|
+
: undefined;
|
|
940
|
+
let results = await this.deps.serializeRecallResults(snapshot, disclosure, {
|
|
941
|
+
query,
|
|
942
|
+
sessionKey: trimmedSessionKey,
|
|
943
|
+
...(rawExcerptNamespace ? { rawExcerptNamespace } : {}),
|
|
944
|
+
...(rawExcerptSessionIds !== undefined ? { rawExcerptSessionIds } : {}),
|
|
945
|
+
...(rawExcerptsSuppressed ? { rawExcerptsSuppressed } : {}),
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
// Tag filter (issue #689). Applied post-recall, post-serialization so
|
|
949
|
+
// the actual frontmatter tags are already loaded onto each result. When
|
|
950
|
+
// `tags` is absent or empty the filter is a no-op; an invalid `tagMatch`
|
|
951
|
+
// throws via `parseTagMatch` (CLAUDE.md rule 51).
|
|
952
|
+
const filterTags = normalizeTags(request.tags);
|
|
953
|
+
let tagMatchMode: TagMatchMode | undefined;
|
|
954
|
+
try {
|
|
955
|
+
tagMatchMode = parseTagMatch(request.tagMatch);
|
|
956
|
+
} catch (err) {
|
|
957
|
+
throw new EngramAccessInputError(
|
|
958
|
+
err instanceof Error ? err.message : String(err),
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
let effectiveContext = context;
|
|
962
|
+
if (filterTags && filterTags.length > 0) {
|
|
963
|
+
const beforeIds = results.map((r) => r.id);
|
|
964
|
+
const { results: admitted } = applyTagFilter(results, {
|
|
965
|
+
tags: filterTags,
|
|
966
|
+
tagMatch: tagMatchMode,
|
|
967
|
+
});
|
|
968
|
+
results = admitted;
|
|
969
|
+
// Codex P1: `context` was generated by orchestrator.recall(...)
|
|
970
|
+
// BEFORE the tag filter ran, so it can contain memories that don't
|
|
971
|
+
// match the requested tags. Surfaces consuming `context` (the
|
|
972
|
+
// prompt-injection string) would leak excluded content into the
|
|
973
|
+
// LLM. When the filter actually drops any result, rebuild context
|
|
974
|
+
// from the admitted set so excluded content is unreachable through
|
|
975
|
+
// any field of the response. The rebuilt context concatenates each
|
|
976
|
+
// admitted result's available text (full content at section/raw
|
|
977
|
+
// disclosure, otherwise the preview) — a different wire format
|
|
978
|
+
// than the orchestrator's native context, but a strict subset
|
|
979
|
+
// safe to inject.
|
|
980
|
+
const admittedIds = new Set(results.map((r) => r.id));
|
|
981
|
+
const droppedAny = beforeIds.some((id) => !admittedIds.has(id));
|
|
982
|
+
if (droppedAny) {
|
|
983
|
+
effectiveContext = results
|
|
984
|
+
.map((r) => {
|
|
985
|
+
const content =
|
|
986
|
+
typeof (r as { content?: unknown }).content === "string"
|
|
987
|
+
? ((r as { content?: string }).content ?? "")
|
|
988
|
+
: "";
|
|
989
|
+
const preview =
|
|
990
|
+
typeof (r as { preview?: unknown }).preview === "string"
|
|
991
|
+
? ((r as { preview?: string }).preview ?? "")
|
|
992
|
+
: "";
|
|
993
|
+
return content || preview;
|
|
994
|
+
})
|
|
995
|
+
.filter((s) => s.length > 0)
|
|
996
|
+
.join("\n\n");
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
const filteredMemoryIds = filterTags && filterTags.length > 0
|
|
1000
|
+
? results.map((r) => r.id)
|
|
1001
|
+
: (snapshot?.memoryIds ?? []);
|
|
1002
|
+
const debug = await this.deps.buildRecallDebug(
|
|
1003
|
+
snapshot,
|
|
1004
|
+
effectiveNamespace,
|
|
1005
|
+
request.includeDebug === true,
|
|
1006
|
+
request.sessionKey,
|
|
1007
|
+
);
|
|
1008
|
+
|
|
1009
|
+
// Fire-and-forget audit recording. Must never block or crash recall.
|
|
1010
|
+
let auditAnomalies: AccessAuditResult["anomalies"] | undefined;
|
|
1011
|
+
if (this.deps.auditAdapter) {
|
|
1012
|
+
try {
|
|
1013
|
+
const resolvedAgentId = principal ?? "__anonymous__";
|
|
1014
|
+
const auditEntry = {
|
|
1015
|
+
ts: new Date().toISOString(),
|
|
1016
|
+
sessionKey: request.sessionKey ?? "",
|
|
1017
|
+
agentId: resolvedAgentId,
|
|
1018
|
+
trigger: "access-surface",
|
|
1019
|
+
queryText: query,
|
|
1020
|
+
candidateMemoryIds: snapshot?.memoryIds ?? [],
|
|
1021
|
+
// Audit must reflect what was actually injected, not what
|
|
1022
|
+
// recall produced before the tag filter. Using `context`
|
|
1023
|
+
// (pre-filter) overstates injectedChars and can leak content
|
|
1024
|
+
// from excluded memories into the audit summary (cursor
|
|
1025
|
+
// Medium on PR #712).
|
|
1026
|
+
summary: effectiveContext.slice(0, 200) || null,
|
|
1027
|
+
injectedChars: effectiveContext.length,
|
|
1028
|
+
toggleState: "enabled" as const,
|
|
1029
|
+
latencyMs: Date.now() - startedAt,
|
|
1030
|
+
plannerMode: snapshot?.plannerMode ?? mode,
|
|
1031
|
+
requestedMode: mode,
|
|
1032
|
+
fallbackUsed: snapshot?.fallbackUsed ?? false,
|
|
1033
|
+
};
|
|
1034
|
+
const auditResult = await this.deps.auditAdapter.record(
|
|
1035
|
+
resolvedAgentId || "__anonymous__",
|
|
1036
|
+
auditEntry,
|
|
1037
|
+
);
|
|
1038
|
+
auditAnomalies = auditResult.anomalies;
|
|
1039
|
+
} catch {
|
|
1040
|
+
// Audit failures must never crash the recall path.
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
return {
|
|
1045
|
+
response: {
|
|
1046
|
+
query,
|
|
1047
|
+
sessionKey: request.sessionKey,
|
|
1048
|
+
namespace: effectiveNamespace,
|
|
1049
|
+
context: effectiveContext,
|
|
1050
|
+
count: filterTags && filterTags.length > 0
|
|
1051
|
+
? results.length
|
|
1052
|
+
: (snapshot?.memoryIds.length ?? results.length),
|
|
1053
|
+
memoryIds: filteredMemoryIds,
|
|
1054
|
+
results,
|
|
1055
|
+
recordedAt: snapshot?.recordedAt,
|
|
1056
|
+
traceId: snapshot?.traceId,
|
|
1057
|
+
plannerMode: snapshot?.plannerMode ?? mode,
|
|
1058
|
+
fallbackUsed: snapshot?.fallbackUsed ?? false,
|
|
1059
|
+
sourcesUsed: snapshot?.sourcesUsed ?? [],
|
|
1060
|
+
disclosure,
|
|
1061
|
+
budgetsApplied: snapshot?.budgetsApplied,
|
|
1062
|
+
auditAnomalies,
|
|
1063
|
+
budgetWarning: budgetDecision.reason === "warn-over-soft" ? budgetDecision : undefined,
|
|
1064
|
+
latencyMs: snapshot?.latencyMs ?? (Date.now() - startedAt),
|
|
1065
|
+
debug,
|
|
1066
|
+
},
|
|
1067
|
+
budgetRecordPrincipal: recordBudgetAfterSuccess ? principal : null,
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* Recall X-ray (issue #570). Runs a recall with `xrayCapture: true`
|
|
1073
|
+
* and returns the resulting snapshot as structured JSON so every
|
|
1074
|
+
* surface (CLI / HTTP / MCP) gets the same payload. Namespace scope
|
|
1075
|
+
* is enforced before the recall fires (CLAUDE.md rule 42 — read and
|
|
1076
|
+
* write paths must resolve through the same namespace layer) so an
|
|
1077
|
+
* unauthorized principal cannot capture an x-ray for a namespace it
|
|
1078
|
+
* cannot read.
|
|
1079
|
+
*/
|
|
1080
|
+
async recallXray(request: {
|
|
1081
|
+
query: string;
|
|
1082
|
+
sessionKey?: string;
|
|
1083
|
+
namespace?: string;
|
|
1084
|
+
budget?: number;
|
|
1085
|
+
authenticatedPrincipal?: string;
|
|
1086
|
+
/**
|
|
1087
|
+
* Disclosure depth used to shape per-result payload (issue #677
|
|
1088
|
+
* PR 3/4). When set, each X-ray result is decorated with the
|
|
1089
|
+
* matching `disclosure` field and `estimatedTokens` computed from
|
|
1090
|
+
* the actual rendered content at that depth, so the renderer's
|
|
1091
|
+
* "Token spend by disclosure" summary reflects real spend rather
|
|
1092
|
+
* than staying empty when no caller wires the depth knob through.
|
|
1093
|
+
*/
|
|
1094
|
+
disclosure?: RecallDisclosure;
|
|
1095
|
+
/**
|
|
1096
|
+
* Free-form recall tag filter (issue #689). Mirrors the field on
|
|
1097
|
+
* `EngramAccessRecallRequest`. When non-empty, the captured X-ray
|
|
1098
|
+
* snapshot's `results` are filtered down to memories whose
|
|
1099
|
+
* frontmatter tags satisfy `tagMatch` ("any" by default), and a
|
|
1100
|
+
* `tag-filter` entry is appended to `filters`.
|
|
1101
|
+
*/
|
|
1102
|
+
tags?: string[];
|
|
1103
|
+
/** Match mode for `tags`. See `EngramAccessRecallRequest.tagMatch`. */
|
|
1104
|
+
tagMatch?: "any" | "all";
|
|
1105
|
+
/** Recall planner mode override. Mirrors `EngramAccessRecallRequest.mode`. */
|
|
1106
|
+
mode?: RecallPlanMode | "auto";
|
|
1107
|
+
/**
|
|
1108
|
+
* User-aware context scopes active for this recall. Forwarded into
|
|
1109
|
+
* provenance construction so boundary scopes are evaluated against
|
|
1110
|
+
* the caller's real context instead of an empty-context default.
|
|
1111
|
+
*/
|
|
1112
|
+
currentContextScopes?: readonly unknown[];
|
|
1113
|
+
/**
|
|
1114
|
+
* Internal inspector affordance: include a recall-shaped response
|
|
1115
|
+
* derived from the same X-ray snapshot. Left off by default so the
|
|
1116
|
+
* regular X-ray API/CLI/MCP surfaces keep their existing payload shape.
|
|
1117
|
+
*/
|
|
1118
|
+
includeRecall?: boolean;
|
|
1119
|
+
}): Promise<{
|
|
1120
|
+
snapshotFound: boolean;
|
|
1121
|
+
snapshot?: RecallXraySnapshot;
|
|
1122
|
+
recall?: EngramAccessRecallResponse;
|
|
1123
|
+
}> {
|
|
1124
|
+
const query = typeof request.query === "string" ? request.query : "";
|
|
1125
|
+
if (query.trim().length === 0) {
|
|
1126
|
+
// Match the CLI contract (CLAUDE.md rule 51): reject empty
|
|
1127
|
+
// input with an explicit error rather than silently producing
|
|
1128
|
+
// an empty snapshot.
|
|
1129
|
+
throw new Error("recallXray: query is required and must be non-empty");
|
|
1130
|
+
}
|
|
1131
|
+
// Validate disclosure UP FRONT — before recall executes, before
|
|
1132
|
+
// the xray queue mutex is acquired, before namespace resolution.
|
|
1133
|
+
// A bad value should fail fast rather than after we've burned
|
|
1134
|
+
// cycles on an irreversible recall (Cursor Medium review on PR
|
|
1135
|
+
// #699).
|
|
1136
|
+
if (
|
|
1137
|
+
request.disclosure !== undefined &&
|
|
1138
|
+
!isRecallDisclosure(request.disclosure)
|
|
1139
|
+
) {
|
|
1140
|
+
throw new EngramAccessInputError(
|
|
1141
|
+
`recallXray: disclosure must be one of: chunk, section, raw (got: ${String(request.disclosure)})`,
|
|
1142
|
+
);
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
const namespacesEnabled = resolveNamespaceCapabilities(this.deps.orchestrator.config).namespaces;
|
|
1146
|
+
const requestedNamespace = request.namespace?.trim()
|
|
1147
|
+
? this.deps.resolveNamespace(request.namespace)
|
|
1148
|
+
: undefined;
|
|
1149
|
+
const authenticatedPrincipal = request.authenticatedPrincipal?.trim();
|
|
1150
|
+
const principal =
|
|
1151
|
+
authenticatedPrincipal
|
|
1152
|
+
|| resolvePrincipal(request.sessionKey, this.deps.orchestrator.config);
|
|
1153
|
+
|
|
1154
|
+
if (requestedNamespace) {
|
|
1155
|
+
if (
|
|
1156
|
+
!canReadNamespace(
|
|
1157
|
+
principal,
|
|
1158
|
+
requestedNamespace,
|
|
1159
|
+
this.deps.orchestrator.config,
|
|
1160
|
+
)
|
|
1161
|
+
) {
|
|
1162
|
+
return { snapshotFound: false };
|
|
1163
|
+
}
|
|
1164
|
+
} else if (
|
|
1165
|
+
namespacesEnabled
|
|
1166
|
+
&& !authenticatedPrincipal
|
|
1167
|
+
&& !request.sessionKey?.trim()
|
|
1168
|
+
) {
|
|
1169
|
+
// Namespaces enabled but no identity supplied — reject rather
|
|
1170
|
+
// than scanning the global namespace (CLAUDE.md rule 48:
|
|
1171
|
+
// least-privileged default).
|
|
1172
|
+
return { snapshotFound: false };
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// Optional `--budget` override must be a positive integer. Invalid
|
|
1176
|
+
// values throw rather than silently defaulting (CLAUDE.md rule 51).
|
|
1177
|
+
let budgetOverride: number | undefined;
|
|
1178
|
+
if (request.budget !== undefined && request.budget !== null) {
|
|
1179
|
+
const parsed =
|
|
1180
|
+
typeof request.budget === "number"
|
|
1181
|
+
? request.budget
|
|
1182
|
+
: Number(request.budget);
|
|
1183
|
+
if (
|
|
1184
|
+
!Number.isFinite(parsed)
|
|
1185
|
+
|| parsed <= 0
|
|
1186
|
+
|| !Number.isInteger(parsed)
|
|
1187
|
+
) {
|
|
1188
|
+
throw new Error(
|
|
1189
|
+
`recallXray: budget expects a positive integer; got ${JSON.stringify(request.budget)}`,
|
|
1190
|
+
);
|
|
1191
|
+
}
|
|
1192
|
+
budgetOverride = parsed;
|
|
1193
|
+
}
|
|
1194
|
+
const mode = this.deps.normalizeRecallMode(request.mode);
|
|
1195
|
+
const disclosure = request.disclosure ?? DEFAULT_RECALL_DISCLOSURE;
|
|
1196
|
+
|
|
1197
|
+
// Serialize x-ray invocations behind a per-service mutex so the
|
|
1198
|
+
// per-process `getLastXraySnapshot()` slot cannot be clobbered by
|
|
1199
|
+
// a concurrent capturing call before this caller reads it back.
|
|
1200
|
+
// Budget and principal are now threaded through
|
|
1201
|
+
// `RecallInvocationOptions`, so global config mutation is gone
|
|
1202
|
+
// (CLAUDE.md rule 47: no shared mutable state across async
|
|
1203
|
+
// boundaries). The mutex stays only for the snapshot-slot
|
|
1204
|
+
// ordering guarantee.
|
|
1205
|
+
const previousQueue = this.deps.xrayQueue;
|
|
1206
|
+
let release: () => void = () => {};
|
|
1207
|
+
this.deps.xrayQueue = new Promise<void>((resolve) => {
|
|
1208
|
+
release = resolve;
|
|
1209
|
+
});
|
|
1210
|
+
await previousQueue;
|
|
1211
|
+
const recallStartedAt = Date.now();
|
|
1212
|
+
|
|
1213
|
+
const recallSessionKey = request.sessionKey?.trim() || undefined;
|
|
1214
|
+
let xrayResponse: {
|
|
1215
|
+
snapshotFound: boolean;
|
|
1216
|
+
snapshot?: RecallXraySnapshot;
|
|
1217
|
+
} = { snapshotFound: false };
|
|
1218
|
+
|
|
1219
|
+
try {
|
|
1220
|
+
// Clear any prior snapshot so a capture failure surfaces as
|
|
1221
|
+
// `{snapshotFound: false}` rather than returning stale data
|
|
1222
|
+
// from an earlier call on the same orchestrator.
|
|
1223
|
+
this.deps.orchestrator.clearLastXraySnapshot();
|
|
1224
|
+
await this.deps.orchestrator.recall(query, recallSessionKey, {
|
|
1225
|
+
xrayCapture: true,
|
|
1226
|
+
...(requestedNamespace ? { namespace: requestedNamespace } : {}),
|
|
1227
|
+
...(budgetOverride !== undefined
|
|
1228
|
+
? { budgetCharsOverride: budgetOverride }
|
|
1229
|
+
: {}),
|
|
1230
|
+
...(mode !== undefined ? { mode } : {}),
|
|
1231
|
+
// When the caller supplies an authenticated principal, forward
|
|
1232
|
+
// it via the dedicated override channel so orchestrator-side
|
|
1233
|
+
// ACL decisions use the SAME principal the access-surface
|
|
1234
|
+
// pre-check above authorized. Threading an
|
|
1235
|
+
// `authenticatedPrincipal` through `sessionKey` would be wrong:
|
|
1236
|
+
// `resolvePrincipal(sessionKey)` only maps configured raw
|
|
1237
|
+
// session keys and otherwise collapses to `"default"`, which
|
|
1238
|
+
// in namespace-enabled deployments produces false denials /
|
|
1239
|
+
// wrong-scope serving despite the pre-check passing
|
|
1240
|
+
// (CLAUDE.md rule 42).
|
|
1241
|
+
...(authenticatedPrincipal
|
|
1242
|
+
? { principalOverride: authenticatedPrincipal }
|
|
1243
|
+
: {}),
|
|
1244
|
+
...(request.currentContextScopes !== undefined
|
|
1245
|
+
? { currentContextScopes: request.currentContextScopes }
|
|
1246
|
+
: {}),
|
|
1247
|
+
});
|
|
1248
|
+
|
|
1249
|
+
const rawSnapshot = this.deps.orchestrator.getLastXraySnapshot();
|
|
1250
|
+
// Re-check namespace after capture: the recall may have served
|
|
1251
|
+
// from a different namespace than the caller requested. Drop
|
|
1252
|
+
// the snapshot rather than leak cross-tenant data (CLAUDE.md
|
|
1253
|
+
// rules 42 + 47). The comparison is strict so a snapshot whose
|
|
1254
|
+
// namespace is `undefined` cannot bypass the scope the caller
|
|
1255
|
+
// asked for.
|
|
1256
|
+
const namespaceMismatch =
|
|
1257
|
+
requestedNamespace !== undefined &&
|
|
1258
|
+
rawSnapshot?.namespace !== requestedNamespace;
|
|
1259
|
+
if (!rawSnapshot) {
|
|
1260
|
+
xrayResponse = { snapshotFound: false };
|
|
1261
|
+
} else if (namespaceMismatch) {
|
|
1262
|
+
xrayResponse = { snapshotFound: false };
|
|
1263
|
+
} else {
|
|
1264
|
+
// Tag filter (issue #689). Mirrors `recall()` semantics — applied
|
|
1265
|
+
// post-capture by reading each result's frontmatter tags and
|
|
1266
|
+
// dropping non-matching results. Filter activity surfaces as a
|
|
1267
|
+
// `tag-filter` entry in `snapshot.filters` so X-ray consumers can
|
|
1268
|
+
// see the "considered → admitted" delta.
|
|
1269
|
+
let snapshot = rawSnapshot;
|
|
1270
|
+
const xrayFilterTags = normalizeTags(request.tags);
|
|
1271
|
+
let xrayTagMatch: TagMatchMode | undefined;
|
|
1272
|
+
try {
|
|
1273
|
+
xrayTagMatch = parseTagMatch(request.tagMatch);
|
|
1274
|
+
} catch (err) {
|
|
1275
|
+
throw new EngramAccessInputError(
|
|
1276
|
+
err instanceof Error ? err.message : String(err),
|
|
1277
|
+
);
|
|
1278
|
+
}
|
|
1279
|
+
if (xrayFilterTags && xrayFilterTags.length > 0) {
|
|
1280
|
+
const namespace = snapshot.namespace
|
|
1281
|
+
? this.deps.resolveNamespace(snapshot.namespace)
|
|
1282
|
+
: this.deps.orchestrator.config.defaultNamespace;
|
|
1283
|
+
const tagsByIndex = await Promise.all(
|
|
1284
|
+
snapshot.results.map(async (result) => {
|
|
1285
|
+
try {
|
|
1286
|
+
const storage = await this.deps.orchestrator.getStorage(namespace);
|
|
1287
|
+
const memory = await storage.readMemoryByPath(result.path);
|
|
1288
|
+
const t = memory?.frontmatter?.tags;
|
|
1289
|
+
// Normalize identically to the recall path
|
|
1290
|
+
// (`normalizeProjectionTags`): trim and drop empty strings
|
|
1291
|
+
// so X-ray tag matching stays consistent with the recall
|
|
1292
|
+
// surface. Without this, a frontmatter tag like " draft "
|
|
1293
|
+
// would match in recall but not in X-ray (cursor review).
|
|
1294
|
+
return Array.isArray(t) ? normalizeProjectionTags(t) : [];
|
|
1295
|
+
} catch {
|
|
1296
|
+
return [];
|
|
1297
|
+
}
|
|
1298
|
+
}),
|
|
1299
|
+
);
|
|
1300
|
+
const tagged = snapshot.results.map((result, index) => ({
|
|
1301
|
+
result,
|
|
1302
|
+
tags: tagsByIndex[index] ?? [],
|
|
1303
|
+
}));
|
|
1304
|
+
const { results: admittedTagged, trace } = applyTagFilter(tagged, {
|
|
1305
|
+
tags: xrayFilterTags,
|
|
1306
|
+
tagMatch: xrayTagMatch,
|
|
1307
|
+
});
|
|
1308
|
+
const admittedResults = admittedTagged.map((entry) => entry.result);
|
|
1309
|
+
const filters = trace ? [...snapshot.filters, trace] : snapshot.filters;
|
|
1310
|
+
snapshot = { ...snapshot, results: admittedResults, filters };
|
|
1311
|
+
}
|
|
1312
|
+
// Decorate per-result disclosure + token estimate when the caller
|
|
1313
|
+
// wired a depth knob (issue #677 PR 3/4 — codex review on #699
|
|
1314
|
+
// flagged that the renderer's per-disclosure summary stays empty
|
|
1315
|
+
// until callers populate these fields). Estimate tokens from
|
|
1316
|
+
// the actual rendered payload at the requested depth so the
|
|
1317
|
+
// summary reflects real spend; chunk uses the preview, section
|
|
1318
|
+
// and raw use full content. Best-effort only — a missing
|
|
1319
|
+
// memory or read failure is silently dropped (CLAUDE.md rule 13).
|
|
1320
|
+
if (request.disclosure !== undefined) {
|
|
1321
|
+
// Disclosure already validated up front; pin to the narrowed
|
|
1322
|
+
// type here. Re-validation inside the queue would be dead code.
|
|
1323
|
+
const disclosure: RecallDisclosure = request.disclosure;
|
|
1324
|
+
const namespace = snapshot.namespace
|
|
1325
|
+
? this.deps.resolveNamespace(snapshot.namespace)
|
|
1326
|
+
: this.deps.orchestrator.config.defaultNamespace;
|
|
1327
|
+
// Pre-fetch raw excerpts ONCE so the first raw-disclosure
|
|
1328
|
+
// result's token estimate includes the LCM-side excerpt spend
|
|
1329
|
+
// that `shapeMemorySummary` actually attaches in the recall
|
|
1330
|
+
// response. Without this, raw recalls systematically
|
|
1331
|
+
// undercounted spend on the first result (Cursor Medium review
|
|
1332
|
+
// on PR #699). Excerpts are scoped to the same session +
|
|
1333
|
+
// namespace as the recall.
|
|
1334
|
+
// Trim sessionKey to match what `orchestrator.recall(...)`
|
|
1335
|
+
// already does (`request.sessionKey?.trim() || undefined`),
|
|
1336
|
+
// otherwise a whitespace-padded key drives recall under one
|
|
1337
|
+
// identity but probes LCM under a different prefix and
|
|
1338
|
+
// misses stored excerpts (Cursor Low review on PR #699).
|
|
1339
|
+
const trimmedSessionKey = request.sessionKey?.trim() || undefined;
|
|
1340
|
+
// Read-authorization-gated namespace for the raw-excerpt LCM lookup
|
|
1341
|
+
// (#1505 thread 2f7). NOT `snapshot.namespace` (the write/overlay
|
|
1342
|
+
// namespace), which would attach `<principal>-project-*` overlay rows
|
|
1343
|
+
// when the principal can WRITE but not READ its self base. Mirrors the
|
|
1344
|
+
// recall + `lcmSearch` read gate. Resolved ONLY for raw disclosure (its
|
|
1345
|
+
// sole consumer); the `namespace` above is still used for the
|
|
1346
|
+
// memory-FILE reads below (a separate, snapshot-scoped read), so non-raw
|
|
1347
|
+
// x-ray decoration stays byte-for-byte unchanged.
|
|
1348
|
+
const rawExcerptNamespace =
|
|
1349
|
+
disclosure === "raw"
|
|
1350
|
+
? this.deps.resolveRawExcerptReadNamespace(
|
|
1351
|
+
request.namespace,
|
|
1352
|
+
trimmedSessionKey,
|
|
1353
|
+
authenticatedPrincipal,
|
|
1354
|
+
)
|
|
1355
|
+
: namespace;
|
|
1356
|
+
// `undefined` for an IMPLICIT raw recall means NO readable LCM namespace
|
|
1357
|
+
// exists (restrictive `default` READ policy, no readable overlay/self)
|
|
1358
|
+
// — suppress excerpts rather than fall back to the write/overlay
|
|
1359
|
+
// namespace the read gate excludes (#1505 thread NBHWz).
|
|
1360
|
+
const xrayHasExplicitNamespace =
|
|
1361
|
+
typeof request.namespace === "string" &&
|
|
1362
|
+
request.namespace.trim().length > 0;
|
|
1363
|
+
const rawExcerptsSuppressed =
|
|
1364
|
+
disclosure === "raw" &&
|
|
1365
|
+
!xrayHasExplicitNamespace &&
|
|
1366
|
+
rawExcerptNamespace === undefined;
|
|
1367
|
+
// Ordered, read-authorized LCM read key SET (#1505 fallback
|
|
1368
|
+
// unification) so raw disclosure finds excerpts a branch-scoped session
|
|
1369
|
+
// archived at project/root scope — exactly as recall + `lcmSearch` do.
|
|
1370
|
+
// Only meaningful with a concrete sessionKey + a readable namespace;
|
|
1371
|
+
// already read-gated so no unauthorized overlay key is present.
|
|
1372
|
+
const rawExcerptSessionIds =
|
|
1373
|
+
disclosure === "raw" && trimmedSessionKey && rawExcerptNamespace
|
|
1374
|
+
? this.deps.resolveLcmReadSessionIds(
|
|
1375
|
+
request.namespace,
|
|
1376
|
+
rawExcerptNamespace,
|
|
1377
|
+
trimmedSessionKey,
|
|
1378
|
+
authenticatedPrincipal,
|
|
1379
|
+
)
|
|
1380
|
+
: undefined;
|
|
1381
|
+
const rawExcerpts =
|
|
1382
|
+
disclosure === "raw" && !rawExcerptsSuppressed
|
|
1383
|
+
? await this.deps.fetchRawExcerpts(disclosure, {
|
|
1384
|
+
query,
|
|
1385
|
+
...(trimmedSessionKey ? { sessionKey: trimmedSessionKey } : {}),
|
|
1386
|
+
...(rawExcerptNamespace
|
|
1387
|
+
? { namespace: rawExcerptNamespace }
|
|
1388
|
+
: {}),
|
|
1389
|
+
...(rawExcerptSessionIds !== undefined
|
|
1390
|
+
? { lcmSessionIds: rawExcerptSessionIds }
|
|
1391
|
+
: {}),
|
|
1392
|
+
})
|
|
1393
|
+
: disclosure === "raw"
|
|
1394
|
+
? []
|
|
1395
|
+
: null;
|
|
1396
|
+
const rawExcerptText =
|
|
1397
|
+
rawExcerpts && rawExcerpts.length > 0
|
|
1398
|
+
? rawExcerpts.map((e) => e.content).join("\n")
|
|
1399
|
+
: "";
|
|
1400
|
+
// Pre-load every memory in parallel so we can:
|
|
1401
|
+
// (a) re-attribute raw excerpts to the *first readable* result
|
|
1402
|
+
// rather than always to index 0 (Cursor Low review on PR
|
|
1403
|
+
// #699: a missing/unreadable result[0] orphaned the excerpt
|
|
1404
|
+
// budget); and
|
|
1405
|
+
// (b) include the metadata fields `shapeMemorySummary` actually
|
|
1406
|
+
// emits at every depth (id, path, category, status, created,
|
|
1407
|
+
// updated, tags, entityRef) in the token estimate, so the
|
|
1408
|
+
// summary reflects real spend rather than only payload-body
|
|
1409
|
+
// spend (Cursor Low review on PR #699).
|
|
1410
|
+
const memoryByIndex = await Promise.all(
|
|
1411
|
+
snapshot.results.map(async (result) => {
|
|
1412
|
+
try {
|
|
1413
|
+
const storage = await this.deps.orchestrator.getStorage(namespace);
|
|
1414
|
+
return await storage.readMemoryByPath(result.path);
|
|
1415
|
+
} catch {
|
|
1416
|
+
return null;
|
|
1417
|
+
}
|
|
1418
|
+
}),
|
|
1419
|
+
);
|
|
1420
|
+
const firstReadableIndex = memoryByIndex.findIndex((m) => m !== null);
|
|
1421
|
+
const baseDir =
|
|
1422
|
+
(await this.deps.orchestrator.getStorage(namespace)).dir;
|
|
1423
|
+
const decorated = snapshot.results.map((result, index) => {
|
|
1424
|
+
const memory = memoryByIndex[index];
|
|
1425
|
+
if (!memory) {
|
|
1426
|
+
// Unreadable result: attach the disclosure tag anyway so
|
|
1427
|
+
// the per-disclosure summary classifies it correctly,
|
|
1428
|
+
// but skip the token estimate since we don't have the
|
|
1429
|
+
// content to measure. Without the disclosure tag the
|
|
1430
|
+
// result silently flows into the `unspecified` bucket
|
|
1431
|
+
// even though the caller explicitly requested a depth
|
|
1432
|
+
// (Cursor Low review on PR #699).
|
|
1433
|
+
return { ...result, disclosure };
|
|
1434
|
+
}
|
|
1435
|
+
// Build a representative shaped summary so the estimate
|
|
1436
|
+
// counts every field `shapeMemorySummary` actually emits.
|
|
1437
|
+
// The serialized JSON form is a close-enough proxy for the
|
|
1438
|
+
// wire payload size.
|
|
1439
|
+
const shaped = shapeMemorySummary(
|
|
1440
|
+
memory,
|
|
1441
|
+
baseDir,
|
|
1442
|
+
disclosure,
|
|
1443
|
+
disclosure === "raw" &&
|
|
1444
|
+
index === firstReadableIndex &&
|
|
1445
|
+
rawExcerpts &&
|
|
1446
|
+
rawExcerpts.length > 0
|
|
1447
|
+
? rawExcerpts
|
|
1448
|
+
: undefined,
|
|
1449
|
+
);
|
|
1450
|
+
return {
|
|
1451
|
+
...result,
|
|
1452
|
+
disclosure,
|
|
1453
|
+
estimatedTokens: estimateRecallTokens(JSON.stringify(shaped)),
|
|
1454
|
+
};
|
|
1455
|
+
});
|
|
1456
|
+
// Edge case: every result was unreadable but rawExcerpts
|
|
1457
|
+
// still has content — credit that spend to result[0] rather
|
|
1458
|
+
// than dropping it on the floor. Without this, the raw row
|
|
1459
|
+
// in the per-disclosure summary under-reports spend whenever
|
|
1460
|
+
// every memory file is missing/unreadable.
|
|
1461
|
+
if (
|
|
1462
|
+
disclosure === "raw" &&
|
|
1463
|
+
firstReadableIndex === -1 &&
|
|
1464
|
+
rawExcerptText.length > 0 &&
|
|
1465
|
+
decorated.length > 0
|
|
1466
|
+
) {
|
|
1467
|
+
decorated[0] = {
|
|
1468
|
+
...decorated[0]!,
|
|
1469
|
+
disclosure,
|
|
1470
|
+
estimatedTokens: estimateRecallTokens(rawExcerptText),
|
|
1471
|
+
};
|
|
1472
|
+
}
|
|
1473
|
+
const decoratedSnapshot = { ...snapshot, results: decorated };
|
|
1474
|
+
xrayResponse = {
|
|
1475
|
+
snapshotFound: true,
|
|
1476
|
+
snapshot: decoratedSnapshot,
|
|
1477
|
+
};
|
|
1478
|
+
} else {
|
|
1479
|
+
xrayResponse = {
|
|
1480
|
+
snapshotFound: true,
|
|
1481
|
+
snapshot,
|
|
1482
|
+
};
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
} finally {
|
|
1486
|
+
release();
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
if (
|
|
1490
|
+
request.includeRecall === true &&
|
|
1491
|
+
xrayResponse.snapshotFound === true &&
|
|
1492
|
+
xrayResponse.snapshot
|
|
1493
|
+
) {
|
|
1494
|
+
// Same read-authorization-gated raw-excerpt namespace the recall path uses
|
|
1495
|
+
// (#1505 thread 2f7), so the includeRecall x-ray path can't leak overlay
|
|
1496
|
+
// transcript rows via raw disclosure. Resolved ONLY for raw disclosure (the
|
|
1497
|
+
// sole consumer) so non-raw x-ray recall stays byte-for-byte unchanged. The
|
|
1498
|
+
// ordered LCM read key SET (#1505 fallback unification) adds the coding read
|
|
1499
|
+
// fallbacks so a branch-scoped session also finds excerpts at project/root
|
|
1500
|
+
// scope.
|
|
1501
|
+
const xrayRawExcerptNamespace =
|
|
1502
|
+
disclosure === "raw"
|
|
1503
|
+
? this.deps.resolveRawExcerptReadNamespace(
|
|
1504
|
+
request.namespace,
|
|
1505
|
+
recallSessionKey,
|
|
1506
|
+
authenticatedPrincipal,
|
|
1507
|
+
)
|
|
1508
|
+
: undefined;
|
|
1509
|
+
// `undefined` for an IMPLICIT raw recall means NO readable LCM namespace
|
|
1510
|
+
// exists — suppress excerpts rather than fall back to the write/overlay
|
|
1511
|
+
// namespace the read gate excludes (#1505 thread NBHWz).
|
|
1512
|
+
const xrayHasExplicitNamespace =
|
|
1513
|
+
typeof request.namespace === "string" &&
|
|
1514
|
+
request.namespace.trim().length > 0;
|
|
1515
|
+
const xrayRawExcerptsSuppressed =
|
|
1516
|
+
disclosure === "raw" &&
|
|
1517
|
+
!xrayHasExplicitNamespace &&
|
|
1518
|
+
xrayRawExcerptNamespace === undefined;
|
|
1519
|
+
const xrayRawExcerptSessionIds =
|
|
1520
|
+
disclosure === "raw" && xrayRawExcerptNamespace && recallSessionKey
|
|
1521
|
+
? this.deps.resolveLcmReadSessionIds(
|
|
1522
|
+
request.namespace,
|
|
1523
|
+
xrayRawExcerptNamespace,
|
|
1524
|
+
recallSessionKey,
|
|
1525
|
+
authenticatedPrincipal,
|
|
1526
|
+
)
|
|
1527
|
+
: undefined;
|
|
1528
|
+
return {
|
|
1529
|
+
...xrayResponse,
|
|
1530
|
+
recall: await this.deps.buildRecallResponseFromXraySnapshot({
|
|
1531
|
+
query,
|
|
1532
|
+
sessionKey: recallSessionKey,
|
|
1533
|
+
snapshot: xrayResponse.snapshot,
|
|
1534
|
+
disclosure,
|
|
1535
|
+
startedAt: recallStartedAt,
|
|
1536
|
+
requestedMode: request.mode,
|
|
1537
|
+
normalizedMode: mode,
|
|
1538
|
+
...(xrayRawExcerptNamespace
|
|
1539
|
+
? { rawExcerptNamespace: xrayRawExcerptNamespace }
|
|
1540
|
+
: {}),
|
|
1541
|
+
...(xrayRawExcerptSessionIds !== undefined
|
|
1542
|
+
? { rawExcerptSessionIds: xrayRawExcerptSessionIds }
|
|
1543
|
+
: {}),
|
|
1544
|
+
...(xrayRawExcerptsSuppressed
|
|
1545
|
+
? { rawExcerptsSuppressed: xrayRawExcerptsSuppressed }
|
|
1546
|
+
: {}),
|
|
1547
|
+
}),
|
|
1548
|
+
};
|
|
1549
|
+
}
|
|
1550
|
+
return xrayResponse;
|
|
1551
|
+
}
|
|
1552
|
+
}
|