hippo-memory 1.43.3 → 1.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -12
- package/bin/hippo.js +3 -1
- package/dist/api.d.ts +21 -21
- package/dist/api.js +69 -42
- package/dist/audit-prune.js +4 -1
- package/dist/audit.d.ts +1 -1
- package/dist/audit.js +9 -0
- package/dist/autolearn.d.ts +1 -1
- package/dist/autolearn.js +2 -1
- package/dist/capture.d.ts +11 -10
- package/dist/capture.js +22 -30
- package/dist/cli.d.ts +4 -0
- package/dist/cli.js +309 -171
- package/dist/client.d.ts +4 -21
- package/dist/client.js +3 -112
- package/dist/config.js +2 -1
- package/dist/consolidate.d.ts +3 -0
- package/dist/consolidate.js +44 -42
- package/dist/dag.d.ts +1 -0
- package/dist/dag.js +10 -5
- package/dist/dashboard.js +7 -3
- package/dist/db.js +16 -24
- package/dist/dedupe.d.ts +1 -0
- package/dist/dedupe.js +4 -7
- package/dist/embedding-provider.d.ts +1 -1
- package/dist/embedding-provider.js +3 -2
- package/dist/embeddings.js +81 -8
- package/dist/extract.d.ts +2 -0
- package/dist/extract.js +9 -4
- package/dist/goals.js +12 -3
- package/dist/mcp/server.js +21 -36
- package/dist/memory.d.ts +3 -0
- package/dist/memory.js +7 -1
- package/dist/physics-state.js +4 -1
- package/dist/raw-archive.js +5 -2
- package/dist/recall-trace.js +4 -1
- package/dist/refine-llm.js +3 -2
- package/dist/rerankers/jev.js +3 -2
- package/dist/rerankers/llm.js +3 -2
- package/dist/search.js +3 -3
- package/dist/server.d.ts +5 -0
- package/dist/server.js +39 -29
- package/dist/shared.js +1 -1
- package/dist/stdin.d.ts +12 -0
- package/dist/stdin.js +41 -0
- package/dist/store.d.ts +18 -11
- package/dist/store.js +152 -65
- package/dist/version.d.ts +4 -6
- package/dist/version.js +4 -7
- package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
- package/extensions/openclaw-plugin/package.json +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +8 -5
- package/dist/ablation.d.ts.map +0 -1
- package/dist/ablation.js.map +0 -1
- package/dist/ambient.d.ts.map +0 -1
- package/dist/ambient.js.map +0 -1
- package/dist/api.d.ts.map +0 -1
- package/dist/api.js.map +0 -1
- package/dist/audit-prune.d.ts.map +0 -1
- package/dist/audit-prune.js.map +0 -1
- package/dist/audit.d.ts.map +0 -1
- package/dist/audit.js.map +0 -1
- package/dist/auth.d.ts.map +0 -1
- package/dist/auth.js.map +0 -1
- package/dist/autolearn.d.ts.map +0 -1
- package/dist/autolearn.js.map +0 -1
- package/dist/availability.d.ts.map +0 -1
- package/dist/availability.js.map +0 -1
- package/dist/benchmarks/e1.3/incident-recall-eval.js +0 -78
- package/dist/benchmarks/e1.3/incident-recall-eval.js.map +0 -1
- package/dist/benchmarks/e1.3/slack-1000-event-smoke.js +0 -103
- package/dist/benchmarks/e1.3/slack-1000-event-smoke.js.map +0 -1
- package/dist/capture.d.ts.map +0 -1
- package/dist/capture.js.map +0 -1
- package/dist/card-detail.d.ts.map +0 -1
- package/dist/card-detail.js.map +0 -1
- package/dist/card.d.ts.map +0 -1
- package/dist/card.js.map +0 -1
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/client.d.ts.map +0 -1
- package/dist/client.js.map +0 -1
- package/dist/compare.d.ts.map +0 -1
- package/dist/compare.js.map +0 -1
- package/dist/config.d.ts.map +0 -1
- package/dist/config.js.map +0 -1
- package/dist/connectors/github/backfill.d.ts.map +0 -1
- package/dist/connectors/github/backfill.js.map +0 -1
- package/dist/connectors/github/cli-impl.d.ts.map +0 -1
- package/dist/connectors/github/cli-impl.js.map +0 -1
- package/dist/connectors/github/deletion.d.ts.map +0 -1
- package/dist/connectors/github/deletion.js.map +0 -1
- package/dist/connectors/github/dlq.d.ts.map +0 -1
- package/dist/connectors/github/dlq.js.map +0 -1
- package/dist/connectors/github/idempotency.d.ts.map +0 -1
- package/dist/connectors/github/idempotency.js.map +0 -1
- package/dist/connectors/github/ingest.d.ts.map +0 -1
- package/dist/connectors/github/ingest.js.map +0 -1
- package/dist/connectors/github/octokit-client.d.ts.map +0 -1
- package/dist/connectors/github/octokit-client.js.map +0 -1
- package/dist/connectors/github/ratelimit.d.ts.map +0 -1
- package/dist/connectors/github/ratelimit.js.map +0 -1
- package/dist/connectors/github/scope.d.ts.map +0 -1
- package/dist/connectors/github/scope.js.map +0 -1
- package/dist/connectors/github/signature.d.ts.map +0 -1
- package/dist/connectors/github/signature.js.map +0 -1
- package/dist/connectors/github/tenant-routing.d.ts.map +0 -1
- package/dist/connectors/github/tenant-routing.js.map +0 -1
- package/dist/connectors/github/transform.d.ts.map +0 -1
- package/dist/connectors/github/transform.js.map +0 -1
- package/dist/connectors/github/types.d.ts.map +0 -1
- package/dist/connectors/github/types.js.map +0 -1
- package/dist/connectors/slack/backfill.d.ts.map +0 -1
- package/dist/connectors/slack/backfill.js.map +0 -1
- package/dist/connectors/slack/deletion.d.ts.map +0 -1
- package/dist/connectors/slack/deletion.js.map +0 -1
- package/dist/connectors/slack/dlq.d.ts.map +0 -1
- package/dist/connectors/slack/dlq.js.map +0 -1
- package/dist/connectors/slack/idempotency.d.ts.map +0 -1
- package/dist/connectors/slack/idempotency.js.map +0 -1
- package/dist/connectors/slack/ingest.d.ts.map +0 -1
- package/dist/connectors/slack/ingest.js.map +0 -1
- package/dist/connectors/slack/ratelimit.d.ts.map +0 -1
- package/dist/connectors/slack/ratelimit.js.map +0 -1
- package/dist/connectors/slack/scope.d.ts.map +0 -1
- package/dist/connectors/slack/scope.js.map +0 -1
- package/dist/connectors/slack/signature.d.ts.map +0 -1
- package/dist/connectors/slack/signature.js.map +0 -1
- package/dist/connectors/slack/tenant-routing.d.ts.map +0 -1
- package/dist/connectors/slack/tenant-routing.js.map +0 -1
- package/dist/connectors/slack/transform.d.ts.map +0 -1
- package/dist/connectors/slack/transform.js.map +0 -1
- package/dist/connectors/slack/types.d.ts.map +0 -1
- package/dist/connectors/slack/types.js.map +0 -1
- package/dist/connectors/slack/web-client.d.ts.map +0 -1
- package/dist/connectors/slack/web-client.js.map +0 -1
- package/dist/connectors/slack/workspaces.d.ts.map +0 -1
- package/dist/connectors/slack/workspaces.js.map +0 -1
- package/dist/consolidate.d.ts.map +0 -1
- package/dist/consolidate.js.map +0 -1
- package/dist/correction-latency.d.ts.map +0 -1
- package/dist/correction-latency.js.map +0 -1
- package/dist/customer-notes.d.ts.map +0 -1
- package/dist/customer-notes.js.map +0 -1
- package/dist/dag.d.ts.map +0 -1
- package/dist/dag.js.map +0 -1
- package/dist/dashboard.d.ts.map +0 -1
- package/dist/dashboard.js.map +0 -1
- package/dist/db.d.ts.map +0 -1
- package/dist/db.js.map +0 -1
- package/dist/decisions.d.ts.map +0 -1
- package/dist/decisions.js.map +0 -1
- package/dist/dedupe.d.ts.map +0 -1
- package/dist/dedupe.js.map +0 -1
- package/dist/embedding-provider.d.ts.map +0 -1
- package/dist/embedding-provider.js.map +0 -1
- package/dist/embeddings.d.ts.map +0 -1
- package/dist/embeddings.js.map +0 -1
- package/dist/eval-suite.d.ts.map +0 -1
- package/dist/eval-suite.js.map +0 -1
- package/dist/eval.d.ts.map +0 -1
- package/dist/eval.js.map +0 -1
- package/dist/extensions/openclaw-plugin/index.js.map +0 -1
- package/dist/extract.d.ts.map +0 -1
- package/dist/extract.js.map +0 -1
- package/dist/forward-claim-detector.d.ts.map +0 -1
- package/dist/forward-claim-detector.js.map +0 -1
- package/dist/goals.d.ts.map +0 -1
- package/dist/goals.js.map +0 -1
- package/dist/graph-extract.d.ts.map +0 -1
- package/dist/graph-extract.js.map +0 -1
- package/dist/graph-recall.d.ts.map +0 -1
- package/dist/graph-recall.js.map +0 -1
- package/dist/graph-stream.d.ts.map +0 -1
- package/dist/graph-stream.js.map +0 -1
- package/dist/graph-view.d.ts.map +0 -1
- package/dist/graph-view.js.map +0 -1
- package/dist/graph.d.ts.map +0 -1
- package/dist/graph.js.map +0 -1
- package/dist/handoff.d.ts.map +0 -1
- package/dist/handoff.js.map +0 -1
- package/dist/hooks.d.ts.map +0 -1
- package/dist/hooks.js.map +0 -1
- package/dist/importers.d.ts.map +0 -1
- package/dist/importers.js.map +0 -1
- package/dist/incidents.d.ts.map +0 -1
- package/dist/incidents.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/invalidation.d.ts.map +0 -1
- package/dist/invalidation.js.map +0 -1
- package/dist/mcp/framing.d.ts.map +0 -1
- package/dist/mcp/framing.js.map +0 -1
- package/dist/mcp/server.d.ts.map +0 -1
- package/dist/mcp/server.js.map +0 -1
- package/dist/memory-value-weights.d.ts.map +0 -1
- package/dist/memory-value-weights.js.map +0 -1
- package/dist/memory-value.d.ts.map +0 -1
- package/dist/memory-value.js.map +0 -1
- package/dist/memory.d.ts.map +0 -1
- package/dist/memory.js.map +0 -1
- package/dist/multihop.d.ts.map +0 -1
- package/dist/multihop.js.map +0 -1
- package/dist/owner-validation.d.ts.map +0 -1
- package/dist/owner-validation.js.map +0 -1
- package/dist/path-context.d.ts.map +0 -1
- package/dist/path-context.js.map +0 -1
- package/dist/physics-config.d.ts.map +0 -1
- package/dist/physics-config.js.map +0 -1
- package/dist/physics-state.d.ts.map +0 -1
- package/dist/physics-state.js.map +0 -1
- package/dist/physics.d.ts.map +0 -1
- package/dist/physics.js.map +0 -1
- package/dist/policies.d.ts.map +0 -1
- package/dist/policies.js.map +0 -1
- package/dist/postinstall.d.ts.map +0 -1
- package/dist/postinstall.js.map +0 -1
- package/dist/predictions.d.ts.map +0 -1
- package/dist/predictions.js.map +0 -1
- package/dist/processes.d.ts.map +0 -1
- package/dist/processes.js.map +0 -1
- package/dist/project-briefs.d.ts.map +0 -1
- package/dist/project-briefs.js.map +0 -1
- package/dist/project-identity.d.ts.map +0 -1
- package/dist/project-identity.js.map +0 -1
- package/dist/provenance-coverage.d.ts.map +0 -1
- package/dist/provenance-coverage.js.map +0 -1
- package/dist/rate-limit.d.ts.map +0 -1
- package/dist/rate-limit.js.map +0 -1
- package/dist/raw-archive-mirror-cleanup.d.ts.map +0 -1
- package/dist/raw-archive-mirror-cleanup.js.map +0 -1
- package/dist/raw-archive.d.ts.map +0 -1
- package/dist/raw-archive.js.map +0 -1
- package/dist/recall-history.d.ts.map +0 -1
- package/dist/recall-history.js.map +0 -1
- package/dist/recall-scope.d.ts.map +0 -1
- package/dist/recall-scope.js.map +0 -1
- package/dist/recall-trace.d.ts.map +0 -1
- package/dist/recall-trace.js.map +0 -1
- package/dist/refine-llm.d.ts.map +0 -1
- package/dist/refine-llm.js.map +0 -1
- package/dist/reject-flow.d.ts.map +0 -1
- package/dist/reject-flow.js.map +0 -1
- package/dist/rejection.d.ts.map +0 -1
- package/dist/rejection.js.map +0 -1
- package/dist/replay.d.ts.map +0 -1
- package/dist/replay.js.map +0 -1
- package/dist/rerankers/cross-encoder.d.ts.map +0 -1
- package/dist/rerankers/cross-encoder.js.map +0 -1
- package/dist/rerankers/index.d.ts.map +0 -1
- package/dist/rerankers/index.js.map +0 -1
- package/dist/rerankers/jev.d.ts.map +0 -1
- package/dist/rerankers/jev.js.map +0 -1
- package/dist/rerankers/llm.d.ts.map +0 -1
- package/dist/rerankers/llm.js.map +0 -1
- package/dist/rerankers/types.d.ts.map +0 -1
- package/dist/rerankers/types.js.map +0 -1
- package/dist/rrf.d.ts.map +0 -1
- package/dist/rrf.js.map +0 -1
- package/dist/salience.d.ts.map +0 -1
- package/dist/salience.js.map +0 -1
- package/dist/scheduler.d.ts.map +0 -1
- package/dist/scheduler.js.map +0 -1
- package/dist/scope.d.ts.map +0 -1
- package/dist/scope.js.map +0 -1
- package/dist/search.d.ts.map +0 -1
- package/dist/search.js.map +0 -1
- package/dist/secret-detect.d.ts.map +0 -1
- package/dist/secret-detect.js.map +0 -1
- package/dist/server-detect.d.ts.map +0 -1
- package/dist/server-detect.js.map +0 -1
- package/dist/server.d.ts.map +0 -1
- package/dist/server.js.map +0 -1
- package/dist/shared.d.ts.map +0 -1
- package/dist/shared.js.map +0 -1
- package/dist/skills.d.ts.map +0 -1
- package/dist/skills.js.map +0 -1
- package/dist/sleep-redact.d.ts +0 -58
- package/dist/sleep-redact.d.ts.map +0 -1
- package/dist/sleep-redact.js +0 -80
- package/dist/sleep-redact.js.map +0 -1
- package/dist/src/ablation.js +0 -138
- package/dist/src/ablation.js.map +0 -1
- package/dist/src/ambient.js +0 -148
- package/dist/src/ambient.js.map +0 -1
- package/dist/src/api.js +0 -2109
- package/dist/src/api.js.map +0 -1
- package/dist/src/audit-prune.js +0 -106
- package/dist/src/audit-prune.js.map +0 -1
- package/dist/src/audit.js +0 -213
- package/dist/src/audit.js.map +0 -1
- package/dist/src/auth.js +0 -97
- package/dist/src/auth.js.map +0 -1
- package/dist/src/autolearn.js +0 -174
- package/dist/src/autolearn.js.map +0 -1
- package/dist/src/availability.js +0 -94
- package/dist/src/availability.js.map +0 -1
- package/dist/src/capture.js +0 -1354
- package/dist/src/capture.js.map +0 -1
- package/dist/src/card-detail.js +0 -15
- package/dist/src/card-detail.js.map +0 -1
- package/dist/src/card.js +0 -19
- package/dist/src/card.js.map +0 -1
- package/dist/src/cli.js +0 -9328
- package/dist/src/cli.js.map +0 -1
- package/dist/src/client.js +0 -237
- package/dist/src/client.js.map +0 -1
- package/dist/src/compare.js +0 -122
- package/dist/src/compare.js.map +0 -1
- package/dist/src/config.js +0 -135
- package/dist/src/config.js.map +0 -1
- package/dist/src/connectors/github/backfill.js +0 -281
- package/dist/src/connectors/github/backfill.js.map +0 -1
- package/dist/src/connectors/github/cli-impl.js +0 -234
- package/dist/src/connectors/github/cli-impl.js.map +0 -1
- package/dist/src/connectors/github/deletion.js +0 -85
- package/dist/src/connectors/github/deletion.js.map +0 -1
- package/dist/src/connectors/github/dlq.js +0 -190
- package/dist/src/connectors/github/dlq.js.map +0 -1
- package/dist/src/connectors/github/idempotency.js +0 -27
- package/dist/src/connectors/github/idempotency.js.map +0 -1
- package/dist/src/connectors/github/ingest.js +0 -156
- package/dist/src/connectors/github/ingest.js.map +0 -1
- package/dist/src/connectors/github/octokit-client.js +0 -70
- package/dist/src/connectors/github/octokit-client.js.map +0 -1
- package/dist/src/connectors/github/ratelimit.js +0 -31
- package/dist/src/connectors/github/ratelimit.js.map +0 -1
- package/dist/src/connectors/github/scope.js +0 -13
- package/dist/src/connectors/github/scope.js.map +0 -1
- package/dist/src/connectors/github/signature.js +0 -81
- package/dist/src/connectors/github/signature.js.map +0 -1
- package/dist/src/connectors/github/tenant-routing.js +0 -69
- package/dist/src/connectors/github/tenant-routing.js.map +0 -1
- package/dist/src/connectors/github/transform.js +0 -103
- package/dist/src/connectors/github/transform.js.map +0 -1
- package/dist/src/connectors/github/types.js +0 -100
- package/dist/src/connectors/github/types.js.map +0 -1
- package/dist/src/connectors/slack/backfill.js +0 -78
- package/dist/src/connectors/slack/backfill.js.map +0 -1
- package/dist/src/connectors/slack/deletion.js +0 -59
- package/dist/src/connectors/slack/deletion.js.map +0 -1
- package/dist/src/connectors/slack/dlq.js +0 -224
- package/dist/src/connectors/slack/dlq.js.map +0 -1
- package/dist/src/connectors/slack/idempotency.js +0 -31
- package/dist/src/connectors/slack/idempotency.js.map +0 -1
- package/dist/src/connectors/slack/ingest.js +0 -109
- package/dist/src/connectors/slack/ingest.js.map +0 -1
- package/dist/src/connectors/slack/ratelimit.js +0 -18
- package/dist/src/connectors/slack/ratelimit.js.map +0 -1
- package/dist/src/connectors/slack/scope.js +0 -13
- package/dist/src/connectors/slack/scope.js.map +0 -1
- package/dist/src/connectors/slack/signature.js +0 -27
- package/dist/src/connectors/slack/signature.js.map +0 -1
- package/dist/src/connectors/slack/tenant-routing.js +0 -41
- package/dist/src/connectors/slack/tenant-routing.js.map +0 -1
- package/dist/src/connectors/slack/transform.js +0 -47
- package/dist/src/connectors/slack/transform.js.map +0 -1
- package/dist/src/connectors/slack/types.js +0 -35
- package/dist/src/connectors/slack/types.js.map +0 -1
- package/dist/src/connectors/slack/web-client.js +0 -43
- package/dist/src/connectors/slack/web-client.js.map +0 -1
- package/dist/src/connectors/slack/workspaces.js +0 -62
- package/dist/src/connectors/slack/workspaces.js.map +0 -1
- package/dist/src/consolidate.js +0 -978
- package/dist/src/consolidate.js.map +0 -1
- package/dist/src/correction-latency.js +0 -74
- package/dist/src/correction-latency.js.map +0 -1
- package/dist/src/customer-notes.js +0 -325
- package/dist/src/customer-notes.js.map +0 -1
- package/dist/src/dag.js +0 -352
- package/dist/src/dag.js.map +0 -1
- package/dist/src/dashboard.js +0 -258
- package/dist/src/dashboard.js.map +0 -1
- package/dist/src/db.js +0 -2728
- package/dist/src/db.js.map +0 -1
- package/dist/src/decisions.js +0 -304
- package/dist/src/decisions.js.map +0 -1
- package/dist/src/dedupe.js +0 -141
- package/dist/src/dedupe.js.map +0 -1
- package/dist/src/embedding-provider.js +0 -314
- package/dist/src/embedding-provider.js.map +0 -1
- package/dist/src/embeddings.js +0 -543
- package/dist/src/embeddings.js.map +0 -1
- package/dist/src/eval-suite.js +0 -294
- package/dist/src/eval-suite.js.map +0 -1
- package/dist/src/eval.js +0 -187
- package/dist/src/eval.js.map +0 -1
- package/dist/src/extract.js +0 -117
- package/dist/src/extract.js.map +0 -1
- package/dist/src/forward-claim-detector.js +0 -117
- package/dist/src/forward-claim-detector.js.map +0 -1
- package/dist/src/goals.js +0 -390
- package/dist/src/goals.js.map +0 -1
- package/dist/src/graph-extract.js +0 -314
- package/dist/src/graph-extract.js.map +0 -1
- package/dist/src/graph-recall.js +0 -277
- package/dist/src/graph-recall.js.map +0 -1
- package/dist/src/graph-stream.js +0 -176
- package/dist/src/graph-stream.js.map +0 -1
- package/dist/src/graph-view.js +0 -310
- package/dist/src/graph-view.js.map +0 -1
- package/dist/src/graph.js +0 -762
- package/dist/src/graph.js.map +0 -1
- package/dist/src/handoff.js +0 -65
- package/dist/src/handoff.js.map +0 -1
- package/dist/src/hooks.js +0 -926
- package/dist/src/hooks.js.map +0 -1
- package/dist/src/importers.js +0 -881
- package/dist/src/importers.js.map +0 -1
- package/dist/src/incidents.js +0 -336
- package/dist/src/incidents.js.map +0 -1
- package/dist/src/index.js +0 -32
- package/dist/src/index.js.map +0 -1
- package/dist/src/invalidation.js +0 -129
- package/dist/src/invalidation.js.map +0 -1
- package/dist/src/mcp/framing.js +0 -45
- package/dist/src/mcp/framing.js.map +0 -1
- package/dist/src/mcp/server.js +0 -1298
- package/dist/src/mcp/server.js.map +0 -1
- package/dist/src/memory-value-weights.js +0 -31
- package/dist/src/memory-value-weights.js.map +0 -1
- package/dist/src/memory-value.js +0 -261
- package/dist/src/memory-value.js.map +0 -1
- package/dist/src/memory.js +0 -425
- package/dist/src/memory.js.map +0 -1
- package/dist/src/multihop.js +0 -35
- package/dist/src/multihop.js.map +0 -1
- package/dist/src/owner-validation.js +0 -56
- package/dist/src/owner-validation.js.map +0 -1
- package/dist/src/path-context.js +0 -48
- package/dist/src/path-context.js.map +0 -1
- package/dist/src/physics-config.js +0 -26
- package/dist/src/physics-config.js.map +0 -1
- package/dist/src/physics-state.js +0 -169
- package/dist/src/physics-state.js.map +0 -1
- package/dist/src/physics.js +0 -378
- package/dist/src/physics.js.map +0 -1
- package/dist/src/policies.js +0 -414
- package/dist/src/policies.js.map +0 -1
- package/dist/src/postinstall.js +0 -101
- package/dist/src/postinstall.js.map +0 -1
- package/dist/src/predictions.js +0 -629
- package/dist/src/predictions.js.map +0 -1
- package/dist/src/processes.js +0 -349
- package/dist/src/processes.js.map +0 -1
- package/dist/src/project-briefs.js +0 -492
- package/dist/src/project-briefs.js.map +0 -1
- package/dist/src/project-identity.js +0 -200
- package/dist/src/project-identity.js.map +0 -1
- package/dist/src/provenance-coverage.js +0 -23
- package/dist/src/provenance-coverage.js.map +0 -1
- package/dist/src/rate-limit.js +0 -60
- package/dist/src/rate-limit.js.map +0 -1
- package/dist/src/raw-archive-mirror-cleanup.js +0 -55
- package/dist/src/raw-archive-mirror-cleanup.js.map +0 -1
- package/dist/src/raw-archive.js +0 -88
- package/dist/src/raw-archive.js.map +0 -1
- package/dist/src/recall-history.js +0 -235
- package/dist/src/recall-history.js.map +0 -1
- package/dist/src/recall-scope.js +0 -89
- package/dist/src/recall-scope.js.map +0 -1
- package/dist/src/recall-trace.js +0 -183
- package/dist/src/recall-trace.js.map +0 -1
- package/dist/src/refine-llm.js +0 -156
- package/dist/src/refine-llm.js.map +0 -1
- package/dist/src/reject-flow.js +0 -209
- package/dist/src/reject-flow.js.map +0 -1
- package/dist/src/rejection.js +0 -160
- package/dist/src/rejection.js.map +0 -1
- package/dist/src/replay.js +0 -122
- package/dist/src/replay.js.map +0 -1
- package/dist/src/rerankers/cross-encoder.js +0 -137
- package/dist/src/rerankers/cross-encoder.js.map +0 -1
- package/dist/src/rerankers/index.js +0 -20
- package/dist/src/rerankers/index.js.map +0 -1
- package/dist/src/rerankers/jev.js +0 -119
- package/dist/src/rerankers/jev.js.map +0 -1
- package/dist/src/rerankers/llm.js +0 -80
- package/dist/src/rerankers/llm.js.map +0 -1
- package/dist/src/rerankers/types.js +0 -2
- package/dist/src/rerankers/types.js.map +0 -1
- package/dist/src/rrf.js +0 -61
- package/dist/src/rrf.js.map +0 -1
- package/dist/src/salience.js +0 -74
- package/dist/src/salience.js.map +0 -1
- package/dist/src/scheduler.js +0 -77
- package/dist/src/scheduler.js.map +0 -1
- package/dist/src/scope.js +0 -35
- package/dist/src/scope.js.map +0 -1
- package/dist/src/search.js +0 -1003
- package/dist/src/search.js.map +0 -1
- package/dist/src/secret-detect.js +0 -95
- package/dist/src/secret-detect.js.map +0 -1
- package/dist/src/server-detect.js +0 -231
- package/dist/src/server-detect.js.map +0 -1
- package/dist/src/server.js +0 -3255
- package/dist/src/server.js.map +0 -1
- package/dist/src/shared.js +0 -502
- package/dist/src/shared.js.map +0 -1
- package/dist/src/skills.js +0 -346
- package/dist/src/skills.js.map +0 -1
- package/dist/src/sleep-redact.js +0 -80
- package/dist/src/sleep-redact.js.map +0 -1
- package/dist/src/sso.js +0 -22
- package/dist/src/sso.js.map +0 -1
- package/dist/src/store.js +0 -3743
- package/dist/src/store.js.map +0 -1
- package/dist/src/tenant.js +0 -17
- package/dist/src/tenant.js.map +0 -1
- package/dist/src/trace.js +0 -72
- package/dist/src/trace.js.map +0 -1
- package/dist/src/version.js +0 -40
- package/dist/src/version.js.map +0 -1
- package/dist/src/working-memory.js +0 -157
- package/dist/src/working-memory.js.map +0 -1
- package/dist/src/yaml.js +0 -107
- package/dist/src/yaml.js.map +0 -1
- package/dist/sso.d.ts +0 -13
- package/dist/sso.d.ts.map +0 -1
- package/dist/sso.js +0 -22
- package/dist/sso.js.map +0 -1
- package/dist/store.d.ts.map +0 -1
- package/dist/store.js.map +0 -1
- package/dist/tenant.d.ts.map +0 -1
- package/dist/tenant.js.map +0 -1
- package/dist/trace.d.ts.map +0 -1
- package/dist/trace.js.map +0 -1
- package/dist/version.d.ts.map +0 -1
- package/dist/version.js.map +0 -1
- package/dist/working-memory.d.ts.map +0 -1
- package/dist/working-memory.js.map +0 -1
- package/dist/yaml.d.ts.map +0 -1
- package/dist/yaml.js.map +0 -1
package/dist/src/api.js
DELETED
|
@@ -1,2109 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Domain API layer for Hippo.
|
|
3
|
-
*
|
|
4
|
-
* Pure functions taking a Context (hippoRoot + tenantId + actor) plus
|
|
5
|
-
* operation options. Both the CLI (direct mode) and the HTTP server
|
|
6
|
-
* (`hippo serve`, A1) call into this module so the business logic lives
|
|
7
|
-
* in exactly one place.
|
|
8
|
-
*/
|
|
9
|
-
import { createHash } from 'node:crypto';
|
|
10
|
-
import { openHippoDb, closeHippoDb } from './db.js';
|
|
11
|
-
import { writeEntry, writeEntryDbOnly, stampOriginProject, writeEntryMirrors, readEntry, deleteEntry, loadRecallSearchEntries, loadEntriesByIds, loadChildrenOf, loadFreshRawMemories, loadSessionRawMemories, countSessionRawMemories, DEFAULT_SEARCH_CANDIDATE_LIMIT, removeEntryMirrors, loadActiveTaskSnapshot, loadFreshActiveTaskSnapshot, loadLatestHandoff, listSessionEvents, SNAPSHOT_AMBIENT_MAX_AGE_MS, loadIndex, saveIndex, loadAllEntries, loadAmbientCandidates, updateStats, isInitialized, markSummaryDirtyInTx, auditRejectionRefusal, } from './store.js';
|
|
12
|
-
import { RejectedValueError } from './rejection.js';
|
|
13
|
-
import { rejectValue, unrejectValue, listRejectionsForTenant } from './reject-flow.js';
|
|
14
|
-
import { formatHandoffEvidenceLine } from './handoff.js';
|
|
15
|
-
import { createMemory, applyOutcome, calculateStrength, Layer, } from './memory.js';
|
|
16
|
-
import { appendAuditEvent, queryAuditEvents, auditMemories, isContentWorthStoring, } from './audit.js';
|
|
17
|
-
import { promoteToGlobal, getGlobalRoot, autoShare, searchBothHybrid } from './shared.js';
|
|
18
|
-
import { writeRecallTrace, writeRecallTraceAtRoot, recordTraceOutcome } from './recall-trace.js';
|
|
19
|
-
import { evalNow, isRecallBoostAblated } from './ablation.js';
|
|
20
|
-
import { archiveRawMemory } from './raw-archive.js';
|
|
21
|
-
import { createApiKey, listApiKeys, revokeApiKey, } from './auth.js';
|
|
22
|
-
import { applyGoalStackBoost } from './goals.js';
|
|
23
|
-
import { markRetrieved, estimateTokens, hybridSearch, physicsSearch } from './search.js';
|
|
24
|
-
import { compareEntryIdentity, compareScoredResults } from './compare.js';
|
|
25
|
-
import { scopeMatch } from './scope.js';
|
|
26
|
-
import { consolidate } from './consolidate.js';
|
|
27
|
-
import { loadConfig } from './config.js';
|
|
28
|
-
import { resolveProjectIdentity, classifyOriginProject } from './project-identity.js';
|
|
29
|
-
import { detectSecret } from './secret-detect.js';
|
|
30
|
-
import { deduplicateStore } from './dedupe.js';
|
|
31
|
-
import { computeAmbientState } from './ambient.js';
|
|
32
|
-
import { loadPendingExtractionTenants, markPendingProcessedUpTo } from './graph.js';
|
|
33
|
-
import { extractGraph } from './graph-extract.js';
|
|
34
|
-
import { computePlanningFallacyOutput, } from './predictions.js';
|
|
35
|
-
import { detectAnchoring, hashQueryText, } from './recall-history.js';
|
|
36
|
-
import { detectAvailabilityBias } from './availability.js';
|
|
37
|
-
/**
|
|
38
|
-
* Helper for building process-local (admin-by-default) Actor values. v1.12.0
|
|
39
|
-
* factory used by CLI / MCP / connector Context constructors so the role
|
|
40
|
-
* boilerplate isn't repeated at every site. Bearer-authed callers (HTTP
|
|
41
|
-
* /v1/*) construct Actor directly from the api_keys row's role column via
|
|
42
|
-
* buildContextWithAuth in src/server.ts.
|
|
43
|
-
*/
|
|
44
|
-
export function adminActor(subject) {
|
|
45
|
-
return { subject, role: 'admin' };
|
|
46
|
-
}
|
|
47
|
-
/**
|
|
48
|
-
* Thrown by `api.recall` when a caller's options violate a recall contract
|
|
49
|
-
* that has been opted into via env. Carries a stable `code` field for HTTP /
|
|
50
|
-
* MCP / CLI render paths to discriminate without parsing the message.
|
|
51
|
-
*
|
|
52
|
-
* Codes:
|
|
53
|
-
* - 'fresh_tail_requires_session_id' — `freshTailCount > 0` AND no
|
|
54
|
-
* `freshTailSessionId` AND `HIPPO_REQUIRE_SESSION_SCOPED_FRESH_TAIL=1`.
|
|
55
|
-
* Default behaviour (env unset) returns tenant-wide rows; the env gate
|
|
56
|
-
* is opt-in so multi-session tenants can fail loud instead of silently
|
|
57
|
-
* surfacing cross-session rows tagged `isFreshTail=true`.
|
|
58
|
-
* - 'invalid_scorer_window' — `opts.scorerWindow` is set to a non-positive,
|
|
59
|
-
* non-integer, or non-finite value. Pre-v1.7.0 the value 0 routed
|
|
60
|
-
* through FTS/LIKE `LIMIT 0` and then fell through to an uncapped
|
|
61
|
-
* full-store fallback (codex v1.7.0 diff-pass P1). Validated upfront
|
|
62
|
-
* so the contract holds.
|
|
63
|
-
*/
|
|
64
|
-
export class RecallContractError extends Error {
|
|
65
|
-
code;
|
|
66
|
-
constructor(code, message) {
|
|
67
|
-
super(message);
|
|
68
|
-
this.name = 'RecallContractError';
|
|
69
|
-
this.code = code;
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
// v1.25.0: the recall-side scope predicates (PRIVATE_SCOPE_RE, isPrivateScope,
|
|
73
|
-
// passesScopeFilterForRecall) live in recall-scope.ts (leaf) so shared.ts can
|
|
74
|
-
// apply the same default-deny rule to searchBothHybrid's internal loads
|
|
75
|
-
// without an api.ts import cycle — same pattern as classifyOriginProject
|
|
76
|
-
// below. Imported here for this module's own call sites and re-exported for
|
|
77
|
-
// back-compat (`api.isPrivateScope`, test imports). NOTE: the import statement
|
|
78
|
-
// is required — a bare `export { x } from` re-export does not bind the local
|
|
79
|
-
// names this module's ~9 call sites use.
|
|
80
|
-
import { isPrivateScope, passesScopeFilterForRecall } from './recall-scope.js';
|
|
81
|
-
export { isPrivateScope, passesScopeFilterForRecall };
|
|
82
|
-
export { passesCliRecallScopeFilter } from './recall-scope.js';
|
|
83
|
-
// v39: classifyOriginProject lives in project-identity.ts (leaf) so
|
|
84
|
-
// shared.ts can use it without an api.ts import cycle. Re-exported here for
|
|
85
|
-
// callers that already import the api surface.
|
|
86
|
-
export { classifyOriginProject } from './project-identity.js';
|
|
87
|
-
/**
|
|
88
|
-
* v39: the single ambient-injection admission policy, shared by getContext
|
|
89
|
-
* and the CLI-side ambient-state summary so the two cannot drift.
|
|
90
|
-
*
|
|
91
|
-
* - S4 secret veto is UNCONDITIONAL: neither crossProject nor
|
|
92
|
-
* contextProjectIsolation:false re-includes secrets. A flagged row only
|
|
93
|
-
* injects inside its owning project; flagged rows with no project origin
|
|
94
|
-
* (''/null) never ambient-inject at all. Explicit recall is unaffected -
|
|
95
|
-
* recalling a secret is a deliberate act.
|
|
96
|
-
* - S2 envelope parity: private scopes + quarantine buckets never inject.
|
|
97
|
-
* - S3 origin partition: other-project rows are excluded unless
|
|
98
|
-
* `includeCrossProject`.
|
|
99
|
-
*/
|
|
100
|
-
function ambientAdmitEntry(e, currentProjectName, includeCrossProject) {
|
|
101
|
-
if (!ambientSecretAdmit(e, currentProjectName))
|
|
102
|
-
return false;
|
|
103
|
-
if (!passesScopeFilterForRecall(e.scope ?? null, undefined))
|
|
104
|
-
return false;
|
|
105
|
-
if (includeCrossProject)
|
|
106
|
-
return true;
|
|
107
|
-
return classifyOriginProject(e.origin_project, currentProjectName) !== 'cross-project';
|
|
108
|
-
}
|
|
109
|
-
/**
|
|
110
|
-
* v39 S4: the secret half of the ambient policy on its own, for surfaces
|
|
111
|
-
* with their own scope semantics (MCP hippo_context's explicit-scope
|
|
112
|
-
* exact-match). A flagged row is only admitted inside its owning project;
|
|
113
|
-
* flagged rows with no project origin never ambient-inject.
|
|
114
|
-
*/
|
|
115
|
-
export function ambientSecretAdmit(e, currentProjectName) {
|
|
116
|
-
if (!detectSecret(e).flagged)
|
|
117
|
-
return true;
|
|
118
|
-
const origin = e.origin_project;
|
|
119
|
-
if (origin === undefined || origin === null || origin === '')
|
|
120
|
-
return false;
|
|
121
|
-
return origin === currentProjectName;
|
|
122
|
-
}
|
|
123
|
-
// The pinned-only branch needs pins and recent-N candidates, not the corpus.
|
|
124
|
-
function loadAmbientEntries(hippoRoot, tenantId, pinnedOnly, includeRecent, admit) {
|
|
125
|
-
if (!pinnedOnly)
|
|
126
|
-
return loadAllEntries(hippoRoot, tenantId).filter(admit);
|
|
127
|
-
// DF3's quality floor runs on the recent-N slice AFTER this load, so the load
|
|
128
|
-
// counts by it too, or it stops short of a store whose newest rows are junk.
|
|
129
|
-
const admitAmbient = (e) => admit(e) && (e.pinned || isContentWorthStoring(e.content));
|
|
130
|
-
return loadAmbientCandidates(hippoRoot, tenantId, includeRecent, admitAmbient);
|
|
131
|
-
}
|
|
132
|
-
export function remember(ctx, opts) {
|
|
133
|
-
const entry = createMemory(opts.content, {
|
|
134
|
-
kind: opts.kind ?? 'distilled',
|
|
135
|
-
scope: opts.scope ?? null,
|
|
136
|
-
owner: opts.owner ?? null,
|
|
137
|
-
artifact_ref: opts.artifactRef ?? null,
|
|
138
|
-
tags: opts.tags,
|
|
139
|
-
tenantId: ctx.tenantId,
|
|
140
|
-
});
|
|
141
|
-
// writeEntry threads ctx.actor.subject into its internal audit hook, so exactly
|
|
142
|
-
// one 'remember' event lands in the log with the supplied actor.
|
|
143
|
-
writeEntry(ctx.hippoRoot, entry, { actor: ctx.actor.subject, afterWrite: opts.afterWrite });
|
|
144
|
-
return { id: entry.id, kind: entry.kind, tenantId: ctx.tenantId };
|
|
145
|
-
}
|
|
146
|
-
/**
|
|
147
|
-
* Shared construction helper for `RecallSuppressionSummary`. Used by
|
|
148
|
-
* `api.recall`, `cmdRecall`, and the MCP `hippo_recall` handler so all three
|
|
149
|
-
* pipelines produce the same shape without duplicating field-construction
|
|
150
|
-
* logic. Pass-through identity today; kept as a helper so future field
|
|
151
|
-
* additions (B4 interference counter wiring, etc.) land at one site.
|
|
152
|
-
*/
|
|
153
|
-
export function buildSuppressionSummary(counts) {
|
|
154
|
-
return {
|
|
155
|
-
totalCandidates: counts.totalCandidates,
|
|
156
|
-
droppedPreRank: counts.droppedPreRank,
|
|
157
|
-
droppedByBudget: counts.droppedByBudget,
|
|
158
|
-
summarySubstitutionsAdded: counts.summarySubstitutionsAdded,
|
|
159
|
-
freshTailAdded: counts.freshTailAdded,
|
|
160
|
-
suppressedByInterference: counts.suppressedByInterference,
|
|
161
|
-
};
|
|
162
|
-
}
|
|
163
|
-
/**
|
|
164
|
-
* Domain-level recall. Loads BM25-ranked candidates from SQLite scoped to
|
|
165
|
-
* `ctx.tenantId`. The `mode` flag is accepted for forward compatibility (the
|
|
166
|
-
* CLI exposes hybrid/physics paths) but Task 2 wires only the BM25 candidate
|
|
167
|
-
* loader; later tasks can extend this to call the physics/hybrid scorer.
|
|
168
|
-
*
|
|
169
|
-
* **api.recall does NOT mutate `index.last_retrieval_ids`** (v1.11.5 contract
|
|
170
|
-
* lock). The CLI `cmdRecall` (cli.ts) writes `last_retrieval_ids` because the
|
|
171
|
-
* CLI is interactive (user is about to run `hippo outcome --good`). SDK callers
|
|
172
|
-
* are programmatic: they either pass explicit ids to `api.outcome` or call
|
|
173
|
-
* `api.getContext` first for the context-then-outcome workflow (getContext
|
|
174
|
-
* DOES write `last_retrieval_ids`). Adding the side-effect here would change
|
|
175
|
-
* `api.recall` from a pure read into a read+write, breaking SDK callers who
|
|
176
|
-
* batch recall calls in a row. Locked by
|
|
177
|
-
* `tests/api-recall-no-side-effects.test.ts`.
|
|
178
|
-
*/
|
|
179
|
-
export function recall(ctx, opts) {
|
|
180
|
-
const limit = opts.limit ?? 10;
|
|
181
|
-
// F5 (v1.6.5) preflight — codex P1: original guard fired AFTER
|
|
182
|
-
// loadSearchEntries (which runs initStore, migrating legacy state on first
|
|
183
|
-
// call). For a true contract preflight we want the throw before any
|
|
184
|
-
// store-touching work. Single check here; the consumer site at
|
|
185
|
-
// `if (freshTailCount > 0)` does NOT re-validate (would be a no-op).
|
|
186
|
-
const freshTailCountPreflight = opts.freshTailCount ?? 0;
|
|
187
|
-
if (freshTailCountPreflight > 0 &&
|
|
188
|
-
!opts.freshTailSessionId &&
|
|
189
|
-
process.env.HIPPO_REQUIRE_SESSION_SCOPED_FRESH_TAIL === '1') {
|
|
190
|
-
throw new RecallContractError('fresh_tail_requires_session_id', 'fresh-tail requires a session id when HIPPO_REQUIRE_SESSION_SCOPED_FRESH_TAIL=1; ' +
|
|
191
|
-
'pass opts.freshTailSessionId or unset the env to allow tenant-wide fresh-tail.');
|
|
192
|
-
}
|
|
193
|
-
// F3 (v1.7.0): scorerWindow opt-in. When undefined (default),
|
|
194
|
-
// loadSearchEntries uses its own store-internal default — this
|
|
195
|
-
// preserves every pre-v1.7.0 caller's behaviour bit-for-bit (codex
|
|
196
|
-
// mk2-pass P0-1: defaulting to `limit` would have shrunk the
|
|
197
|
-
// candidate pool and killed overflow summaries).
|
|
198
|
-
// DEFAULT_SEARCH_CANDIDATE_LIMIT is imported from store.ts so the two
|
|
199
|
-
// values cannot drift (codex diff-pass P1 #3).
|
|
200
|
-
// Validate the input — codex diff-pass P1 #1 caught that scorerWindow=0
|
|
201
|
-
// would route through FTS/LIKE LIMIT 0 and then fall through to an
|
|
202
|
-
// uncapped full-store fallback. Reject non-positive / non-finite values.
|
|
203
|
-
if (opts.scorerWindow !== undefined) {
|
|
204
|
-
if (!Number.isFinite(opts.scorerWindow) ||
|
|
205
|
-
!Number.isInteger(opts.scorerWindow) ||
|
|
206
|
-
opts.scorerWindow < 1) {
|
|
207
|
-
throw new RecallContractError('invalid_scorer_window', `scorerWindow must be a positive integer; got ${opts.scorerWindow}`);
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
const windowSize = opts.scorerWindow ?? DEFAULT_SEARCH_CANDIDATE_LIMIT;
|
|
211
|
-
// v1.7.1 — root-cause fix for the `unknown:legacy` leak. Scope predicate
|
|
212
|
-
// is now pushed into `loadSearchRows` SQL via `loadRecallSearchEntries`.
|
|
213
|
-
// - opts.scope undefined / '': SQL excludes `unknown:legacy`.
|
|
214
|
-
// - opts.scope non-empty: SQL exact-matches m.scope = opts.scope.
|
|
215
|
-
// Tenant predicate still runs first, so a tenant-mismatched scope cannot
|
|
216
|
-
// surface another tenant's row even when both share the same scope string.
|
|
217
|
-
//
|
|
218
|
-
// **CALLER CONTRACT:** any future recall-mode loader MUST go through
|
|
219
|
-
// `loadRecallSearchEntries` (or invoke the SQL scope predicate equivalently).
|
|
220
|
-
// Calling `loadSearchEntries` from this code path re-introduces the v1.6.5
|
|
221
|
-
// codex-flagged leak. See `passesScopeFilterForRecall` in this file for
|
|
222
|
-
// the canonical recall-side scope rule (kept in sync with the SQL clause
|
|
223
|
-
// in loadSearchRows).
|
|
224
|
-
//
|
|
225
|
-
// Also fixes a latent code smell: pre-v1.7.1 passed `opts.scorerWindow`
|
|
226
|
-
// (raw, possibly undefined) where `windowSize` was intended.
|
|
227
|
-
// v1.12.13 / C5 — WYSIATI counters. Declared BEFORE the load step so the
|
|
228
|
-
// assignments at the existing filter sites (load / scope-filter / limit-
|
|
229
|
-
// slice / substitution / fresh-tail) are after declaration. The return at
|
|
230
|
-
// end-of-function reads them via buildSuppressionSummary.
|
|
231
|
-
let totalCandidatesCount = 0;
|
|
232
|
-
let droppedPreRankCount = 0;
|
|
233
|
-
let droppedByBudgetCount = 0;
|
|
234
|
-
let summarySubstitutionsCount = 0;
|
|
235
|
-
let freshTailAddedCount = 0;
|
|
236
|
-
const all = loadRecallSearchEntries(ctx.hippoRoot, opts.query, windowSize, ctx.tenantId, opts.scope);
|
|
237
|
-
// v1.12.13 / C5 — WYSIATI totalCandidates counter (post tenant + SQL scope
|
|
238
|
-
// predicate, pre JS scope filter).
|
|
239
|
-
totalCandidatesCount = all.length;
|
|
240
|
-
let entries;
|
|
241
|
-
if (opts.scope !== undefined && opts.scope !== '') {
|
|
242
|
-
// SQL already exact-matched in loadRecallSearchEntries; keep the JS
|
|
243
|
-
// filter as defense-in-depth so a future SQL-clause regression cannot
|
|
244
|
-
// silently surface cross-scope rows.
|
|
245
|
-
entries = all.filter((e) => e.scope === opts.scope);
|
|
246
|
-
}
|
|
247
|
-
else {
|
|
248
|
-
// SQL already excluded `unknown:legacy` AND (v1.25.0) pre-filtered
|
|
249
|
-
// ':private:' scopes with a conservative LIKE before the candidate
|
|
250
|
-
// window, so private rows can no longer starve admitted rows out of the
|
|
251
|
-
// LIMIT (codex review-stage P2). This JS filter stays as the exact
|
|
252
|
-
// anchored `<source>:private:*` rule (v1.2.1 generalization) and
|
|
253
|
-
// defense-in-depth: connector authors cannot silently surface private
|
|
254
|
-
// rows to no-scope callers even if the SQL clause regresses.
|
|
255
|
-
entries = all.filter((e) => !isPrivateScope(e.scope ?? null));
|
|
256
|
-
}
|
|
257
|
-
// v1.12.13 / C5 — WYSIATI dropped_pre_rank counter (JS scope filter drops
|
|
258
|
-
// for api.recall; cmdRecall pipeline rolls --outcome/--layer/--as-of/etc.
|
|
259
|
-
// into the same field per the plan's Task 3 mapping table).
|
|
260
|
-
droppedPreRankCount = all.length - entries.length;
|
|
261
|
-
// BM25 ordering already comes from loadRecallSearchEntries; cap to `limit`.
|
|
262
|
-
// Score is a placeholder — the physics/hybrid scorers in src/search.ts
|
|
263
|
-
// produce richer breakdowns and will replace this when wired up.
|
|
264
|
-
let baseSlice = entries.slice(0, limit);
|
|
265
|
-
// v1.12.13 / C5 — WYSIATI dropped_by_budget counter (candidates loaded but
|
|
266
|
-
// excluded by the final limit slice).
|
|
267
|
-
droppedByBudgetCount = entries.length - baseSlice.length;
|
|
268
|
-
// v1.7.4 -- single db handle for the goal-stack boost AND the audit-event
|
|
269
|
-
// emit below (codex P1: do not open a second short-lived handle for the
|
|
270
|
-
// appendAuditEvent call). The handle is closed in the matching `finally`
|
|
271
|
-
// immediately above the continuity block.
|
|
272
|
-
const db = openHippoDb(ctx.hippoRoot);
|
|
273
|
-
// v1.7.4 -- declared outside the try so the return statement (which lives
|
|
274
|
-
// outside, after the continuity block) can read the final values.
|
|
275
|
-
let rankedOut = [];
|
|
276
|
-
let tokensOut = 0;
|
|
277
|
-
let totalOut = 0;
|
|
278
|
-
// v1.7.4 -- dlPFC goal-stack boost on the PRIMARY band only. Appendix paths
|
|
279
|
-
// (fresh-tail, summary substitutions) are appended AFTER and keep their
|
|
280
|
-
// semantically-special placement.
|
|
281
|
-
let baseScored = baseSlice.map((entry, idx) => ({
|
|
282
|
-
entry,
|
|
283
|
-
score: Math.max(0, 1 - idx / Math.max(1, limit)),
|
|
284
|
-
}));
|
|
285
|
-
// A7 recall-trace: separate side-channel accumulator, allocated ONLY under
|
|
286
|
-
// explain. applyGoalStackBoost writes goal-boost steps here keyed by entry
|
|
287
|
-
// id; the baseRanked map reads it. When !explain it stays undefined and is
|
|
288
|
-
// never passed → the helper's default-path math is byte-identical.
|
|
289
|
-
const explainTrace = opts.explain ? new Map() : undefined;
|
|
290
|
-
try {
|
|
291
|
-
if (opts.sessionId && !opts.goalTag) {
|
|
292
|
-
baseScored = applyGoalStackBoost(db, baseScored, {
|
|
293
|
-
sessionId: opts.sessionId,
|
|
294
|
-
tenantId: ctx.tenantId,
|
|
295
|
-
limit,
|
|
296
|
-
// trace is optional on applyGoalStackBoost; explicitly passing
|
|
297
|
-
// undefined when !explain is identical to omitting the key.
|
|
298
|
-
trace: explainTrace,
|
|
299
|
-
});
|
|
300
|
-
baseSlice = baseScored.map((r) => r.entry);
|
|
301
|
-
}
|
|
302
|
-
// v1.5.0 DAG-aware substitution (Phase 1, Task 2). When entries overflow the
|
|
303
|
-
// limit and ≥2 of them share a level-2 parent summary, append the parent
|
|
304
|
-
// summary so the user sees a compact pointer to the dropped detail. Capped
|
|
305
|
-
// at ceil(limit * 0.3) substitutions so a runaway DAG can't expand results.
|
|
306
|
-
// Each substituted summary is tenant-scoped via loadEntriesByIds and
|
|
307
|
-
// re-checked against the active scope filter (default-deny on private).
|
|
308
|
-
// Drill-down (Task 3) reverses substitution: caller passes substitutedFor[]
|
|
309
|
-
// ids back through `drillDown` to recover the children.
|
|
310
|
-
const summarizeOverflow = opts.summarizeOverflow ?? true;
|
|
311
|
-
let substituted = [];
|
|
312
|
-
if (summarizeOverflow && entries.length > limit) {
|
|
313
|
-
const overflow = entries.slice(limit);
|
|
314
|
-
const baseIds = new Set(baseSlice.map((e) => e.id));
|
|
315
|
-
const overflowByParent = new Map();
|
|
316
|
-
for (const e of overflow) {
|
|
317
|
-
const parentId = e.dag_parent_id;
|
|
318
|
-
if (!parentId)
|
|
319
|
-
continue;
|
|
320
|
-
if ((e.dag_level ?? 0) > 1)
|
|
321
|
-
continue;
|
|
322
|
-
const list = overflowByParent.get(parentId) ?? [];
|
|
323
|
-
list.push(e);
|
|
324
|
-
overflowByParent.set(parentId, list);
|
|
325
|
-
}
|
|
326
|
-
const eligibleParentIds = Array.from(overflowByParent.keys()).filter((pid) => (overflowByParent.get(pid)?.length ?? 0) >= 2 && !baseIds.has(pid));
|
|
327
|
-
if (eligibleParentIds.length > 0) {
|
|
328
|
-
const parents = loadEntriesByIds(ctx.hippoRoot, eligibleParentIds, ctx.tenantId);
|
|
329
|
-
const eligibleParents = parents.filter((p) => (p.dag_level ?? 0) === 2 && passesScopeFilterForRecall(p.scope ?? null, opts.scope));
|
|
330
|
-
const maxSub = Math.max(1, Math.ceil(limit * 0.3));
|
|
331
|
-
// Order parents by overflow count descending so the most
|
|
332
|
-
// information-dense substitutions come first. Overflow count is the
|
|
333
|
-
// true primary key (unchanged); compareEntryIdentity is only a TAIL
|
|
334
|
-
// for the case two parents overflow the same number of children —
|
|
335
|
-
// without it that tie fell to SQLite scan order / loadEntriesByIds
|
|
336
|
-
// batch order (T2, deterministic tie keys).
|
|
337
|
-
eligibleParents.sort((a, b) => {
|
|
338
|
-
const ac = overflowByParent.get(a.id)?.length ?? 0;
|
|
339
|
-
const bc = overflowByParent.get(b.id)?.length ?? 0;
|
|
340
|
-
return bc !== ac ? bc - ac : compareEntryIdentity(a, b);
|
|
341
|
-
});
|
|
342
|
-
substituted = eligibleParents.slice(0, maxSub).map((p) => ({
|
|
343
|
-
entry: p,
|
|
344
|
-
childIds: (overflowByParent.get(p.id) ?? []).map((e) => e.id),
|
|
345
|
-
}));
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
// v1.12.13 / C5 — WYSIATI summary_substitutions_added counter.
|
|
349
|
-
summarySubstitutionsCount = substituted.length;
|
|
350
|
-
// v1.7.4 -- baseScored carries the (possibly boosted) per-row scores. When
|
|
351
|
-
// the goal-stack boost did not run, scores are identical to the original
|
|
352
|
-
// positional placeholder; when it did run, scores reflect the boost AND the
|
|
353
|
-
// rows are in the boosted order (helper sort()).
|
|
354
|
-
const baseRanked = baseScored.map((r) => {
|
|
355
|
-
const item = {
|
|
356
|
-
id: r.entry.id,
|
|
357
|
-
content: r.entry.content,
|
|
358
|
-
score: r.score,
|
|
359
|
-
layer: r.entry.layer,
|
|
360
|
-
strength: r.entry.strength,
|
|
361
|
-
};
|
|
362
|
-
// A7 recall-trace: under explain, every api band carries rerankPipeline:'api';
|
|
363
|
-
// only baseRanked passes through the goal-boost helper, so only it can carry
|
|
364
|
-
// a step (and only for rows that actually matched an active goal).
|
|
365
|
-
if (opts.explain) {
|
|
366
|
-
item.rerankPipeline = 'api';
|
|
367
|
-
const step = explainTrace?.get(r.entry.id);
|
|
368
|
-
if (step)
|
|
369
|
-
item.rerankTrace = [step];
|
|
370
|
-
}
|
|
371
|
-
return item;
|
|
372
|
-
});
|
|
373
|
-
// Substituted summaries land at the end with score = 0.5 (mid-rank), so
|
|
374
|
-
// they don't outrank top-N strong matches but stay above lowest-rank
|
|
375
|
-
// leaves on the consumer side. Caller sorts/filters as it sees fit.
|
|
376
|
-
const summaryRanked = substituted.map((s) => {
|
|
377
|
-
const item = {
|
|
378
|
-
id: s.entry.id,
|
|
379
|
-
content: s.entry.content,
|
|
380
|
-
score: 0.5,
|
|
381
|
-
layer: s.entry.layer,
|
|
382
|
-
strength: s.entry.strength,
|
|
383
|
-
isSummary: true,
|
|
384
|
-
substitutedFor: s.childIds,
|
|
385
|
-
descendantCount: s.entry.descendant_count ?? s.childIds.length,
|
|
386
|
-
};
|
|
387
|
-
// A7 recall-trace: summary band runs no re-ranking, but under explain it
|
|
388
|
-
// still carries the pipeline marker (no steps). Absent when !explain.
|
|
389
|
-
if (opts.explain)
|
|
390
|
-
item.rerankPipeline = 'api';
|
|
391
|
-
return item;
|
|
392
|
-
});
|
|
393
|
-
// v1.5.2 fresh-tail. Surface the last N kind='raw' rows so an agent's
|
|
394
|
-
// "what did I just see" recall path always covers the recent window even
|
|
395
|
-
// when the query terms don't match. Tenant + scope filtered.
|
|
396
|
-
//
|
|
397
|
-
// Dual-membership semantics: `loadSearchEntries` returns all tenant-scoped
|
|
398
|
-
// rows scored by BM25 (even rows with no token overlap can surface at
|
|
399
|
-
// score≈0), so a row in the recent window often ALSO appears as a BM25
|
|
400
|
-
// hit. We don't duplicate. Instead:
|
|
401
|
-
// 1. Mark any baseRanked entry that's in the recent set with isFreshTail.
|
|
402
|
-
// 2. Prepend genuinely-new recent rows (not in BM25 hits or summaries).
|
|
403
|
-
// Net: every recent row carries `isFreshTail=true`, exactly once.
|
|
404
|
-
const freshTailCount = opts.freshTailCount ?? 0;
|
|
405
|
-
const freshRanked = [];
|
|
406
|
-
if (freshTailCount > 0) {
|
|
407
|
-
// F5 contract guard fires at recall() preflight (top of function).
|
|
408
|
-
// No re-check needed here — by the time we reach this block the
|
|
409
|
-
// env/session policy has already been validated.
|
|
410
|
-
const recent = loadFreshRawMemories(ctx.hippoRoot, freshTailCount, ctx.tenantId, opts.freshTailSessionId);
|
|
411
|
-
const recentScoped = recent.filter((m) => passesScopeFilterForRecall(m.scope ?? null, opts.scope));
|
|
412
|
-
const recentIdSet = new Set(recentScoped.map((m) => m.id));
|
|
413
|
-
for (const r of baseRanked) {
|
|
414
|
-
if (recentIdSet.has(r.id))
|
|
415
|
-
r.isFreshTail = true;
|
|
416
|
-
}
|
|
417
|
-
const seenIds = new Set([
|
|
418
|
-
...baseRanked.map((r) => r.id),
|
|
419
|
-
...summaryRanked.map((r) => r.id),
|
|
420
|
-
]);
|
|
421
|
-
for (const m of recentScoped) {
|
|
422
|
-
if (seenIds.has(m.id))
|
|
423
|
-
continue;
|
|
424
|
-
const item = {
|
|
425
|
-
id: m.id,
|
|
426
|
-
content: m.content,
|
|
427
|
-
score: 1.0,
|
|
428
|
-
layer: m.layer,
|
|
429
|
-
strength: m.strength,
|
|
430
|
-
isFreshTail: true,
|
|
431
|
-
};
|
|
432
|
-
// A7 recall-trace: fresh-tail band runs no re-ranking; under explain
|
|
433
|
-
// it carries the pipeline marker (no steps). Absent when !explain.
|
|
434
|
-
if (opts.explain)
|
|
435
|
-
item.rerankPipeline = 'api';
|
|
436
|
-
freshRanked.push(item);
|
|
437
|
-
seenIds.add(m.id);
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
// v1.12.13 / C5 — WYSIATI fresh_tail_added counter. Captures the new rows
|
|
441
|
-
// prepended (NOT rows already in baseRanked that got tagged isFreshTail).
|
|
442
|
-
freshTailAddedCount = freshRanked.length;
|
|
443
|
-
rankedOut = [...freshRanked, ...baseRanked, ...summaryRanked];
|
|
444
|
-
tokensOut = rankedOut.reduce((acc, r) => acc + Math.ceil(r.content.length / 4), 0);
|
|
445
|
-
totalOut = entries.length;
|
|
446
|
-
// TODO(a1-task-4): emit via the shared audit hook in store.ts so we don't
|
|
447
|
-
// double-emit. Recall does not currently write through writeEntry, so no
|
|
448
|
-
// duplicate exists today, but we keep the same shape for symmetry.
|
|
449
|
-
// v1.7.4: reuse the `db` handle opened above for the goal-stack boost --
|
|
450
|
-
// single open/close spans both side effects.
|
|
451
|
-
// GDPR Path A: store a sha256 hash (16 hex chars) of the query text
|
|
452
|
-
// instead of the truncated query itself. If a caller queries with content
|
|
453
|
-
// that matches an archived (RTBF) memory, the original text must not
|
|
454
|
-
// persist in audit_log. query_length is preserved for debugging
|
|
455
|
-
// long-prompt patterns and compliance metrics.
|
|
456
|
-
appendAuditEvent(db, {
|
|
457
|
-
tenantId: ctx.tenantId,
|
|
458
|
-
actor: ctx.actor.subject,
|
|
459
|
-
op: 'recall',
|
|
460
|
-
metadata: {
|
|
461
|
-
query_hash: createHash('sha256').update(opts.query).digest('hex').slice(0, 16),
|
|
462
|
-
query_length: opts.query.length,
|
|
463
|
-
results: rankedOut.length,
|
|
464
|
-
},
|
|
465
|
-
});
|
|
466
|
-
// LC1 (docs/plans/2026-08-02-lc1-recall-trace-persistence.md): trace the
|
|
467
|
-
// returned ids+ranks+scores next to the audit emit, on the SAME open
|
|
468
|
-
// handle. v1.11.5 contract lock holds — api.recall does NOT write
|
|
469
|
-
// last_trace_id (tests/api-recall-no-side-effects.test.ts); a trace INSERT
|
|
470
|
-
// is the same observability class as the audit row it sits beside, not
|
|
471
|
-
// retrieval state. F2 fix: suppressed when the caller (currently only the
|
|
472
|
-
// MCP handler) traces its own, different result set — see
|
|
473
|
-
// opts.suppressRecallTrace JSDoc. Fail-soft internally; never throws.
|
|
474
|
-
if (!opts.suppressRecallTrace) {
|
|
475
|
-
writeRecallTrace(db, {
|
|
476
|
-
tenantId: ctx.tenantId,
|
|
477
|
-
sessionId: opts.sessionId ?? null,
|
|
478
|
-
pipeline: 'api',
|
|
479
|
-
query: opts.query,
|
|
480
|
-
explainMode: opts.explain === true,
|
|
481
|
-
results: rankedOut.map((r) => ({
|
|
482
|
-
memoryId: r.id,
|
|
483
|
-
score: r.score,
|
|
484
|
-
rerankSteps: r.rerankTrace,
|
|
485
|
-
})),
|
|
486
|
-
});
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
finally {
|
|
490
|
-
closeHippoDb(db);
|
|
491
|
-
}
|
|
492
|
-
let continuity;
|
|
493
|
-
let continuityTokens;
|
|
494
|
-
if (opts.includeContinuity) {
|
|
495
|
-
const snapshot = loadActiveTaskSnapshot(ctx.hippoRoot, ctx.tenantId);
|
|
496
|
-
// No active snapshot = no anchor = no handoff/events. Avoids resurrecting
|
|
497
|
-
// a stale handoff from a deleted/completed session.
|
|
498
|
-
const sessionId = snapshot?.session_id ?? undefined;
|
|
499
|
-
const sessionHandoff = sessionId
|
|
500
|
-
? loadLatestHandoff(ctx.hippoRoot, ctx.tenantId, sessionId)
|
|
501
|
-
: null;
|
|
502
|
-
const recentSessionEvents = sessionId
|
|
503
|
-
? listSessionEvents(ctx.hippoRoot, ctx.tenantId, { session_id: sessionId, limit: 5 })
|
|
504
|
-
: [];
|
|
505
|
-
// Scope filtering on continuity. Mirrors the memory-recall path:
|
|
506
|
-
// - opts.scope set: EXACT match required (no cross-scope leakage)
|
|
507
|
-
// - opts.scope unset: default-deny on ANY `<source>:private:*` AND on
|
|
508
|
-
// legacy 'unknown:legacy' rows quarantined by the v23 migration.
|
|
509
|
-
// Public and null scopes pass through.
|
|
510
|
-
// v1.1.0 wrongly wrote this as `opts.scope || isPublic`, which allowed
|
|
511
|
-
// ANY explicit scope to see ALL continuity rows. v1.2 closed the latent
|
|
512
|
-
// leak. v1.2.1 generalizes the private check from slack-only to any
|
|
513
|
-
// source so v1.3 GitHub (and future Jira/Linear/etc.) cannot leak.
|
|
514
|
-
const rowScope = (r) => r?.scope ?? null;
|
|
515
|
-
// v1.2: TaskSnapshot / SessionHandoff / SessionEvent now carry scope; the
|
|
516
|
-
// wrapper just normalizes null vs undefined. W1: was its own copy of
|
|
517
|
-
// passesScopeFilterForRecall (cloned 3x); calls the shared helper now.
|
|
518
|
-
const filteredSnapshot = snapshot && passesScopeFilterForRecall(rowScope(snapshot), opts.scope) ? snapshot : null;
|
|
519
|
-
const filteredHandoff = sessionHandoff && passesScopeFilterForRecall(rowScope(sessionHandoff), opts.scope) ? sessionHandoff : null;
|
|
520
|
-
const filteredEvents = recentSessionEvents.filter((e) => passesScopeFilterForRecall(rowScope(e), opts.scope));
|
|
521
|
-
continuity = {
|
|
522
|
-
activeSnapshot: filteredSnapshot,
|
|
523
|
-
sessionHandoff: filteredHandoff,
|
|
524
|
-
recentSessionEvents: filteredEvents,
|
|
525
|
-
};
|
|
526
|
-
const tokenize = (s) => s ? Math.ceil(s.length / 4) : 0;
|
|
527
|
-
continuityTokens =
|
|
528
|
-
tokenize(filteredSnapshot?.task) +
|
|
529
|
-
tokenize(filteredSnapshot?.summary) +
|
|
530
|
-
tokenize(filteredSnapshot?.next_step) +
|
|
531
|
-
tokenize(filteredHandoff?.summary) +
|
|
532
|
-
tokenize(filteredHandoff?.nextAction) +
|
|
533
|
-
(filteredHandoff?.artifacts ?? []).reduce((acc, a) => acc + tokenize(a), 0) +
|
|
534
|
-
(filteredHandoff?.constraints ?? []).reduce((acc, c) => acc + tokenize(c), 0) +
|
|
535
|
-
tokenize(filteredHandoff?.evidence ? formatHandoffEvidenceLine(filteredHandoff.evidence) : null) +
|
|
536
|
-
tokenize(filteredHandoff?.outcome) +
|
|
537
|
-
tokenize(filteredHandoff?.targetRuntime) +
|
|
538
|
-
tokenize(filteredHandoff?.cardId) +
|
|
539
|
-
filteredEvents.reduce((acc, e) => acc + tokenize(e.content), 0);
|
|
540
|
-
}
|
|
541
|
-
// v0.32 / J3.2 — auto-injection of reference-class baserate when the
|
|
542
|
-
// query carries a forward-prediction phrase AND the closest matching
|
|
543
|
-
// class has closed historical data. Pipeline-invariant (queryText-
|
|
544
|
-
// derived), so MCP and CLI both read this as the single source of
|
|
545
|
-
// truth instead of recomputing (unlike suppressionSummary which IS
|
|
546
|
-
// per-pipeline). opts.actor threads through to the inner
|
|
547
|
-
// computePredictionBaserate call so MCP/HTTP-originated hints attribute
|
|
548
|
-
// correctly instead of defaulting to 'cli'. Disabled by HIPPO_AUTODEBIAS=off.
|
|
549
|
-
// v1.13.4: switched from computePlanningFallacyHint to
|
|
550
|
-
// computePlanningFallacyOutput so the no-class-match / tiebreak
|
|
551
|
-
// watching variant can also reach the caller surface. The two
|
|
552
|
-
// outputs are mutually exclusive; we splat both as optional fields.
|
|
553
|
-
const planningFallacyOutput = computePlanningFallacyOutput(ctx.hippoRoot, ctx.tenantId, opts.query, { actor: ctx.actor.subject });
|
|
554
|
-
const planningFallacyHint = planningFallacyOutput.hint ?? null;
|
|
555
|
-
const planningFallacyWatching = planningFallacyOutput.watching ?? null;
|
|
556
|
-
// v0.33 / J1 (v1.13.2) — recall-recurrence anchoring detection.
|
|
557
|
-
// Uses opts.recallHistory (caller-supplied snapshot) + this pipeline's
|
|
558
|
-
// own top-1 from rankedOut[0]. PURE read — does NOT mutate the snapshot
|
|
559
|
-
// or any caller-side Map. Disabled by HIPPO_ANCHORING=off (which gates
|
|
560
|
-
// even the detectAnchoring call so disabled tenants pay zero work on
|
|
561
|
-
// this surface). On CLI-routed call paths opts.recallHistory is
|
|
562
|
-
// undefined because cmdRecall computes its own hint separately; the
|
|
563
|
-
// detect call returns null and api.recall's anchoringHint stays absent.
|
|
564
|
-
let anchoringHint = null;
|
|
565
|
-
let suppressedByInterferenceCount = 0;
|
|
566
|
-
if (process.env.HIPPO_ANCHORING !== 'off' && opts.recallHistory) {
|
|
567
|
-
const queryHash = hashQueryText(opts.query);
|
|
568
|
-
const topMemoryId = rankedOut[0]?.id ?? null;
|
|
569
|
-
anchoringHint = detectAnchoring(opts.recallHistory, queryHash, topMemoryId);
|
|
570
|
-
if (anchoringHint?.reason === 'memory_dominance') {
|
|
571
|
-
suppressedByInterferenceCount = 1;
|
|
572
|
-
// Emit audit op for the memory-dominance detection.
|
|
573
|
-
const db = openHippoDb(ctx.hippoRoot);
|
|
574
|
-
try {
|
|
575
|
-
appendAuditEvent(db, {
|
|
576
|
-
tenantId: ctx.tenantId,
|
|
577
|
-
actor: ctx.actor.subject,
|
|
578
|
-
op: 'recall_anchor_detected_memory_dominance',
|
|
579
|
-
targetId: anchoringHint.memoryId,
|
|
580
|
-
metadata: {
|
|
581
|
-
memory_id: anchoringHint.memoryId,
|
|
582
|
-
query_count: anchoringHint.queryCount ?? null,
|
|
583
|
-
},
|
|
584
|
-
});
|
|
585
|
-
}
|
|
586
|
-
finally {
|
|
587
|
-
closeHippoDb(db);
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
else if (anchoringHint?.reason === 'query_repeat') {
|
|
591
|
-
const db = openHippoDb(ctx.hippoRoot);
|
|
592
|
-
try {
|
|
593
|
-
appendAuditEvent(db, {
|
|
594
|
-
tenantId: ctx.tenantId,
|
|
595
|
-
actor: ctx.actor.subject,
|
|
596
|
-
op: 'recall_anchor_detected_query_repeat',
|
|
597
|
-
targetId: anchoringHint.memoryId,
|
|
598
|
-
metadata: { memory_id: anchoringHint.memoryId },
|
|
599
|
-
});
|
|
600
|
-
}
|
|
601
|
-
finally {
|
|
602
|
-
closeHippoDb(db);
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
// v1.13.x / J2 — availability/recency-bias detection. PURE read: compares
|
|
607
|
-
// the age distribution of the returned top-K (baseSlice, the post-goal-boost
|
|
608
|
-
// slice) against the matched candidate pool it was drawn from (entries, the
|
|
609
|
-
// scope/private-FILTERED candidate set baseSlice is sliced from — NOT `all`,
|
|
610
|
-
// which still holds private/cross-scope rows the caller is not eligible to see
|
|
611
|
-
// and that could never enter the top-K; counting them would leak hidden pool
|
|
612
|
-
// shape and inflate the signal). Soft warning only — does NOT filter, reorder,
|
|
613
|
-
// or suppress. Disabled by HIPPO_AVAILABILITY=off (gates even the detect call
|
|
614
|
-
// so disabled tenants pay zero work). Suppressed via opts.suppressAvailabilityHint
|
|
615
|
-
// when the caller computes its own per-pipeline hint (MCP), mirroring the J1
|
|
616
|
-
// opts.recallHistory gate above so we never double-emit the audit op. Audit
|
|
617
|
-
// emission is pipeline-local, mirroring the J1 block above.
|
|
618
|
-
let availabilityHint = null;
|
|
619
|
-
if (process.env.HIPPO_AVAILABILITY !== 'off' && !opts.suppressAvailabilityHint) {
|
|
620
|
-
availabilityHint = detectAvailabilityBias({
|
|
621
|
-
topK: baseSlice.map((e) => ({ id: e.id, created: e.created })),
|
|
622
|
-
pool: entries.map((e) => ({ id: e.id, created: e.created })),
|
|
623
|
-
});
|
|
624
|
-
if (availabilityHint) {
|
|
625
|
-
const db = openHippoDb(ctx.hippoRoot);
|
|
626
|
-
try {
|
|
627
|
-
appendAuditEvent(db, {
|
|
628
|
-
tenantId: ctx.tenantId,
|
|
629
|
-
actor: ctx.actor.subject,
|
|
630
|
-
op: 'recall_availability_detected',
|
|
631
|
-
metadata: {
|
|
632
|
-
recent_fraction: availabilityHint.recentFraction,
|
|
633
|
-
older_passed_over: availabilityHint.olderCandidatesPassedOver,
|
|
634
|
-
returned_count: availabilityHint.returnedCount,
|
|
635
|
-
},
|
|
636
|
-
});
|
|
637
|
-
}
|
|
638
|
-
finally {
|
|
639
|
-
closeHippoDb(db);
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
}
|
|
643
|
-
const result = {
|
|
644
|
-
results: rankedOut,
|
|
645
|
-
total: totalOut,
|
|
646
|
-
tokens: tokensOut,
|
|
647
|
-
continuity,
|
|
648
|
-
continuityTokens,
|
|
649
|
-
windowSize,
|
|
650
|
-
suppressionSummary: buildSuppressionSummary({
|
|
651
|
-
totalCandidates: totalCandidatesCount,
|
|
652
|
-
droppedPreRank: droppedPreRankCount,
|
|
653
|
-
droppedByBudget: droppedByBudgetCount,
|
|
654
|
-
summarySubstitutionsAdded: summarySubstitutionsCount,
|
|
655
|
-
freshTailAdded: freshTailAddedCount,
|
|
656
|
-
suppressedByInterference: suppressedByInterferenceCount,
|
|
657
|
-
}),
|
|
658
|
-
};
|
|
659
|
-
if (planningFallacyHint)
|
|
660
|
-
result.planningFallacyHint = planningFallacyHint;
|
|
661
|
-
if (planningFallacyWatching)
|
|
662
|
-
result.planningFallacyWatching = planningFallacyWatching;
|
|
663
|
-
if (anchoringHint)
|
|
664
|
-
result.anchoringHint = anchoringHint;
|
|
665
|
-
if (availabilityHint)
|
|
666
|
-
result.availabilityHint = availabilityHint;
|
|
667
|
-
return result;
|
|
668
|
-
}
|
|
669
|
-
/**
|
|
670
|
-
* Build a chronologically-ordered context window for a session. Adapts the
|
|
671
|
-
* lossless-claw context-engine pattern to Hippo's score-ranked memory store.
|
|
672
|
-
*
|
|
673
|
-
* Algorithm:
|
|
674
|
-
* 1. Load all kind='raw' rows for the session, tenant + scope filtered.
|
|
675
|
-
* 2. Split: newest `freshTailCount` are protected (fresh tail).
|
|
676
|
-
* 3. For older rows, when ≥2 share a level-2 parent, substitute the
|
|
677
|
-
* summary; everything else passes through as raw.
|
|
678
|
-
* 4. Hippo-additive eviction: when over-budget, drop the lowest-strength
|
|
679
|
-
* non-fresh-tail item first. Fresh-tail rows are never evicted.
|
|
680
|
-
*
|
|
681
|
-
* Strength-weighted eviction is the differentiator from lossless-claw,
|
|
682
|
-
* which evicts oldest-first. A high-strength older row (high retrieval
|
|
683
|
-
* count, slow decay) survives; a low-strength recent row (newer but
|
|
684
|
-
* unimportant) goes first.
|
|
685
|
-
*
|
|
686
|
-
* Returns `items: []` cleanly when:
|
|
687
|
-
* - sessionId is empty
|
|
688
|
-
* - no raws exist for the session
|
|
689
|
-
* - all rows fail the scope/tenant filter
|
|
690
|
-
*/
|
|
691
|
-
export function assemble(ctx, sessionId, opts = {}) {
|
|
692
|
-
const budget = opts.budget ?? 4000;
|
|
693
|
-
const freshTailCount = opts.freshTailCount ?? 10;
|
|
694
|
-
const summarizeOlder = opts.summarizeOlder ?? true;
|
|
695
|
-
const rowCap = opts.rowCap ?? 5000;
|
|
696
|
-
if (!sessionId) {
|
|
697
|
-
return { sessionId, items: [], tokens: 0, totalRaw: 0, summarized: 0, evicted: 0, truncated: false };
|
|
698
|
-
}
|
|
699
|
-
const rows = loadSessionRawMemories(ctx.hippoRoot, sessionId, ctx.tenantId, rowCap);
|
|
700
|
-
const truncated = rows.length === rowCap;
|
|
701
|
-
// v1.6.3 senior-review P0-1: report the FULL post-filter row count even
|
|
702
|
-
// when the cap windows the loaded set. Pre-v1.6.3 used `scoped.length`
|
|
703
|
-
// which under-reported on long sessions and made consumers render
|
|
704
|
-
// wrong "session has N msgs" UX.
|
|
705
|
-
const scoped = rows.filter((r) => passesScopeFilterForRecall(r.scope ?? null, opts.scope));
|
|
706
|
-
let totalRaw;
|
|
707
|
-
if (truncated) {
|
|
708
|
-
// v1.6.3 codex P1 / senior P0: scope-aware unbounded COUNT. The helper
|
|
709
|
-
// SQL-encodes the same default-deny rule passesScopeFilterForRecall
|
|
710
|
-
// applies in TS, so a no-scope caller cannot infer private rows by
|
|
711
|
-
// comparing totalRaw to items.length on a truncated session.
|
|
712
|
-
totalRaw = countSessionRawMemories(ctx.hippoRoot, sessionId, ctx.tenantId, opts.scope);
|
|
713
|
-
}
|
|
714
|
-
else {
|
|
715
|
-
totalRaw = scoped.length;
|
|
716
|
-
}
|
|
717
|
-
if (scoped.length === 0) {
|
|
718
|
-
return { sessionId, items: [], tokens: 0, totalRaw, summarized: 0, evicted: 0, truncated };
|
|
719
|
-
}
|
|
720
|
-
// Split newest N into fresh tail; rest is older.
|
|
721
|
-
const tailStartIdx = Math.max(0, scoped.length - freshTailCount);
|
|
722
|
-
const olderRows = scoped.slice(0, tailStartIdx);
|
|
723
|
-
const tailRows = scoped.slice(tailStartIdx);
|
|
724
|
-
// Substitute parent summaries for older rows that share one.
|
|
725
|
-
const olderItems = [];
|
|
726
|
-
let summarized = 0;
|
|
727
|
-
if (summarizeOlder && olderRows.length > 0) {
|
|
728
|
-
const olderByParent = new Map();
|
|
729
|
-
for (const r of olderRows) {
|
|
730
|
-
if (!r.dag_parent_id)
|
|
731
|
-
continue;
|
|
732
|
-
const list = olderByParent.get(r.dag_parent_id) ?? [];
|
|
733
|
-
list.push(r);
|
|
734
|
-
olderByParent.set(r.dag_parent_id, list);
|
|
735
|
-
}
|
|
736
|
-
const eligibleParentIds = Array.from(olderByParent.keys()).filter((pid) => (olderByParent.get(pid)?.length ?? 0) >= 2);
|
|
737
|
-
const parents = eligibleParentIds.length > 0
|
|
738
|
-
? loadEntriesByIds(ctx.hippoRoot, eligibleParentIds, ctx.tenantId)
|
|
739
|
-
.filter((p) => (p.dag_level ?? 0) === 2)
|
|
740
|
-
.filter((p) => passesScopeFilterForRecall(p.scope ?? null, opts.scope))
|
|
741
|
-
: [];
|
|
742
|
-
const claimedRawIds = new Set();
|
|
743
|
-
for (const parent of parents) {
|
|
744
|
-
const claimed = (olderByParent.get(parent.id) ?? []).map((r) => r.id);
|
|
745
|
-
claimed.forEach((id) => claimedRawIds.add(id));
|
|
746
|
-
olderItems.push({
|
|
747
|
-
id: parent.id,
|
|
748
|
-
content: parent.content,
|
|
749
|
-
createdAt: parent.earliest_at ?? parent.created,
|
|
750
|
-
isSummary: true,
|
|
751
|
-
substitutedFor: claimed,
|
|
752
|
-
strength: parent.strength,
|
|
753
|
-
});
|
|
754
|
-
summarized += claimed.length;
|
|
755
|
-
}
|
|
756
|
-
for (const r of olderRows) {
|
|
757
|
-
if (claimedRawIds.has(r.id))
|
|
758
|
-
continue;
|
|
759
|
-
olderItems.push({
|
|
760
|
-
id: r.id,
|
|
761
|
-
content: r.content,
|
|
762
|
-
createdAt: r.created,
|
|
763
|
-
strength: r.strength,
|
|
764
|
-
});
|
|
765
|
-
}
|
|
766
|
-
}
|
|
767
|
-
else {
|
|
768
|
-
for (const r of olderRows) {
|
|
769
|
-
olderItems.push({
|
|
770
|
-
id: r.id,
|
|
771
|
-
content: r.content,
|
|
772
|
-
createdAt: r.created,
|
|
773
|
-
strength: r.strength,
|
|
774
|
-
});
|
|
775
|
-
}
|
|
776
|
-
}
|
|
777
|
-
const tailItems = tailRows.map((r) => ({
|
|
778
|
-
id: r.id,
|
|
779
|
-
content: r.content,
|
|
780
|
-
createdAt: r.created,
|
|
781
|
-
isFreshTail: true,
|
|
782
|
-
strength: r.strength,
|
|
783
|
-
}));
|
|
784
|
-
// F4 (v1.6.5): byte compare canonical UTC ISO timestamps. ~50× faster than
|
|
785
|
-
// localeCompare and chronological by virtue of the timestamp invariant
|
|
786
|
-
// documented in src/memory.ts above MemoryEntry.
|
|
787
|
-
const cmpIso = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
788
|
-
olderItems.sort((a, b) => cmpIso(a.createdAt, b.createdAt));
|
|
789
|
-
tailItems.sort((a, b) => cmpIso(a.createdAt, b.createdAt));
|
|
790
|
-
let items = [...olderItems, ...tailItems];
|
|
791
|
-
let tokens = items.reduce((acc, it) => acc + Math.ceil(it.content.length / 4), 0);
|
|
792
|
-
let evicted = 0;
|
|
793
|
-
while (tokens > budget && items.length > 0) {
|
|
794
|
-
let worstIdx = -1;
|
|
795
|
-
let worstStrength = Infinity;
|
|
796
|
-
for (let i = 0; i < items.length; i++) {
|
|
797
|
-
if (items[i].isFreshTail)
|
|
798
|
-
continue;
|
|
799
|
-
if (items[i].strength < worstStrength) {
|
|
800
|
-
worstStrength = items[i].strength;
|
|
801
|
-
worstIdx = i;
|
|
802
|
-
}
|
|
803
|
-
}
|
|
804
|
-
if (worstIdx === -1)
|
|
805
|
-
break;
|
|
806
|
-
const cost = Math.ceil(items[worstIdx].content.length / 4);
|
|
807
|
-
items = items.filter((_, i) => i !== worstIdx);
|
|
808
|
-
tokens -= cost;
|
|
809
|
-
evicted++;
|
|
810
|
-
}
|
|
811
|
-
return { sessionId, items, tokens, totalRaw, summarized, evicted, truncated };
|
|
812
|
-
}
|
|
813
|
-
/**
|
|
814
|
-
* Walk one step down the DAG from a level-2 (or higher) summary to its direct
|
|
815
|
-
* children. Companion to `recall(... summarizeOverflow: true)` — when recall
|
|
816
|
-
* surfaces a summary with `substitutedFor: [...]`, the caller drills into the
|
|
817
|
-
* summary id to recover the original detail.
|
|
818
|
-
*
|
|
819
|
-
* Tenant scope: only summaries owned by `ctx.tenantId` are reachable. The same
|
|
820
|
-
* scope filter that recall applies is enforced on the children — a level-2
|
|
821
|
-
* summary in `slack:public:CGEN` cannot leak `slack:private:*` children even
|
|
822
|
-
* if the underlying DAG accidentally linked across scopes.
|
|
823
|
-
*
|
|
824
|
-
* Returns a discriminated `DrillDownOutcome`: `DrillDownResult` on success,
|
|
825
|
-
* or `{failure: '...'}` for `not_found` (covers genuinely-missing AND wrong-
|
|
826
|
-
* tenant, intentionally indistinguishable), `not_drillable` (id is a leaf
|
|
827
|
-
* row), or `scope_blocked` (caller has no scope grant for the row's scope).
|
|
828
|
-
*
|
|
829
|
-
* Pre-v1.6.4 returned null for all four cases. JS callers migrate via
|
|
830
|
-
* `'failure' in result` checks; HTTP route maps `not_drillable` to 422.
|
|
831
|
-
*/
|
|
832
|
-
export function drillDown(ctx, summaryId, opts = {}) {
|
|
833
|
-
const limit = opts.limit ?? 50;
|
|
834
|
-
// v0.30 / E5: depth defaults 1 (backward compat); hard cap 10 levels
|
|
835
|
-
// prevents pathological deep trees. CLI/HTTP/MCP reject invalid values.
|
|
836
|
-
const depth = Math.max(1, Math.min(Math.trunc(opts.depth ?? 1), 10));
|
|
837
|
-
const summary = readEntry(ctx.hippoRoot, summaryId, ctx.tenantId);
|
|
838
|
-
// No unscoped cross-tenant probe here — readEntry's null return covers
|
|
839
|
-
// both "doesn't exist" and "exists in another tenant" by design.
|
|
840
|
-
// Distinguishing them via an unscoped lookup would leak existence to
|
|
841
|
-
// unauthorised tenants. The two cases collapse into not_found.
|
|
842
|
-
if (!summary)
|
|
843
|
-
return { failure: 'not_found' };
|
|
844
|
-
if ((summary.dag_level ?? 0) < 2)
|
|
845
|
-
return { failure: 'not_drillable' };
|
|
846
|
-
if (!passesScopeFilterForRecall(summary.scope ?? null, undefined)) {
|
|
847
|
-
// codex round 3 P1: collapse to not_found. A distinguishable
|
|
848
|
-
// "scope_blocked" tells a no-scope caller "this row exists, just
|
|
849
|
-
// not for you" — same existence-leak the HTTP 404 collapse was
|
|
850
|
-
// already preventing. Match the HTTP behaviour at the API level.
|
|
851
|
-
return { failure: 'not_found' };
|
|
852
|
-
}
|
|
853
|
-
// v0.30 / E5: BFS walk levels 1..depth with visited-Set dedup. Defensive
|
|
854
|
-
// against shared-child data anomalies (dag_parent_id has no uniqueness
|
|
855
|
-
// constraint, so a misconfigured tree could double-emit at depth > 1).
|
|
856
|
-
// Each level uses loadChildrenOf which is tenant-scoped via ctx.tenantId.
|
|
857
|
-
const collected = [];
|
|
858
|
-
const visited = new Set([summaryId]);
|
|
859
|
-
let frontier = [summaryId];
|
|
860
|
-
// independent-review MED #4 fold: track level-0 direct-children count
|
|
861
|
-
// separately so the descendantCount fallback (for legacy summaries with
|
|
862
|
-
// null descendant_count) reflects DIRECT children, not BFS-collected total.
|
|
863
|
-
let level0DirectCount = 0;
|
|
864
|
-
for (let level = 0; level < depth; level++) {
|
|
865
|
-
const nextFrontier = [];
|
|
866
|
-
for (const parentId of frontier) {
|
|
867
|
-
const kids = loadChildrenOf(ctx.hippoRoot, parentId, ctx.tenantId);
|
|
868
|
-
const eligibleKids = kids.filter((c) => passesScopeFilterForRecall(c.scope ?? null, undefined));
|
|
869
|
-
for (const k of eligibleKids) {
|
|
870
|
-
if (visited.has(k.id))
|
|
871
|
-
continue;
|
|
872
|
-
visited.add(k.id);
|
|
873
|
-
collected.push(k);
|
|
874
|
-
nextFrontier.push(k.id);
|
|
875
|
-
if (level === 0)
|
|
876
|
-
level0DirectCount++;
|
|
877
|
-
}
|
|
878
|
-
}
|
|
879
|
-
if (nextFrontier.length === 0)
|
|
880
|
-
break;
|
|
881
|
-
frontier = nextFrontier;
|
|
882
|
-
}
|
|
883
|
-
// Apply global cumulative token budget + limit cap on collected.
|
|
884
|
-
let children = collected;
|
|
885
|
-
let truncated = false;
|
|
886
|
-
if (opts.budget !== undefined) {
|
|
887
|
-
const out = [];
|
|
888
|
-
let used = 0;
|
|
889
|
-
for (const c of collected) {
|
|
890
|
-
const t = Math.ceil(c.content.length / 4);
|
|
891
|
-
if (out.length > 0 && used + t > opts.budget) {
|
|
892
|
-
truncated = true;
|
|
893
|
-
break;
|
|
894
|
-
}
|
|
895
|
-
out.push(c);
|
|
896
|
-
used += t;
|
|
897
|
-
}
|
|
898
|
-
children = out;
|
|
899
|
-
}
|
|
900
|
-
if (children.length > limit) {
|
|
901
|
-
children = children.slice(0, limit);
|
|
902
|
-
truncated = true;
|
|
903
|
-
}
|
|
904
|
-
return {
|
|
905
|
-
summary: {
|
|
906
|
-
id: summary.id,
|
|
907
|
-
content: summary.content,
|
|
908
|
-
// v0.30 / E5: descendant_count stays the summary's STORED value
|
|
909
|
-
// (direct children at creation time). totalChildren below reflects
|
|
910
|
-
// the full BFS collection at the requested depth.
|
|
911
|
-
// independent-review MED #4 fold: legacy fallback uses level-0 direct
|
|
912
|
-
// count (NOT collected.length which is BFS-depth-N total).
|
|
913
|
-
descendantCount: summary.descendant_count ?? level0DirectCount,
|
|
914
|
-
earliestAt: summary.earliest_at ?? null,
|
|
915
|
-
latestAt: summary.latest_at ?? null,
|
|
916
|
-
},
|
|
917
|
-
children: children.map((c) => ({
|
|
918
|
-
id: c.id,
|
|
919
|
-
content: c.content,
|
|
920
|
-
layer: c.layer,
|
|
921
|
-
dagLevel: c.dag_level ?? 0,
|
|
922
|
-
created: c.created,
|
|
923
|
-
})),
|
|
924
|
-
// v0.30 / E5: totalChildren = BFS-collected count (depth-aware). For
|
|
925
|
-
// depth=1 this equals the eligible direct-children count (backward
|
|
926
|
-
// compat). For depth>1 it is the cumulative count across levels.
|
|
927
|
-
totalChildren: collected.length,
|
|
928
|
-
truncated,
|
|
929
|
-
};
|
|
930
|
-
}
|
|
931
|
-
export function outcome(ctx, ids, good, opts) {
|
|
932
|
-
const appliedIds = [];
|
|
933
|
-
const db = openHippoDb(ctx.hippoRoot);
|
|
934
|
-
try {
|
|
935
|
-
for (const id of ids) {
|
|
936
|
-
const entry = readEntry(ctx.hippoRoot, id, ctx.tenantId);
|
|
937
|
-
if (!entry)
|
|
938
|
-
continue;
|
|
939
|
-
const updated = applyOutcome(entry, good);
|
|
940
|
-
writeEntry(ctx.hippoRoot, updated, { actor: ctx.actor.subject });
|
|
941
|
-
appendAuditEvent(db, {
|
|
942
|
-
tenantId: ctx.tenantId,
|
|
943
|
-
actor: ctx.actor.subject,
|
|
944
|
-
op: 'outcome',
|
|
945
|
-
targetId: id,
|
|
946
|
-
metadata: { good },
|
|
947
|
-
});
|
|
948
|
-
appliedIds.push(id);
|
|
949
|
-
}
|
|
950
|
-
// LC1: link the outcome to its trace, recording only the ids actually
|
|
951
|
-
// credited (post tenant-filtering, matches appliedIds). Lives in its own
|
|
952
|
-
// append-only table so audit_log pruning can never erase training data.
|
|
953
|
-
if (opts?.traceId !== undefined && appliedIds.length > 0) {
|
|
954
|
-
recordTraceOutcome(db, {
|
|
955
|
-
traceId: opts.traceId,
|
|
956
|
-
tenantId: ctx.tenantId,
|
|
957
|
-
outcome: good ? 'positive' : 'negative',
|
|
958
|
-
memoryIds: appliedIds,
|
|
959
|
-
});
|
|
960
|
-
}
|
|
961
|
-
}
|
|
962
|
-
finally {
|
|
963
|
-
closeHippoDb(db);
|
|
964
|
-
}
|
|
965
|
-
return { applied: appliedIds.length, appliedIds };
|
|
966
|
-
}
|
|
967
|
-
export function forget(ctx, id) {
|
|
968
|
-
const db = openHippoDb(ctx.hippoRoot);
|
|
969
|
-
try {
|
|
970
|
-
// SAFETY: row's shape matches the single `tenant_id` column named in
|
|
971
|
-
// the SELECT above.
|
|
972
|
-
const row = db
|
|
973
|
-
.prepare(`SELECT tenant_id FROM memories WHERE id = ?`)
|
|
974
|
-
.get(id);
|
|
975
|
-
if (!row || row.tenant_id !== ctx.tenantId) {
|
|
976
|
-
throw new Error(`memory not found: ${id}`);
|
|
977
|
-
}
|
|
978
|
-
}
|
|
979
|
-
finally {
|
|
980
|
-
closeHippoDb(db);
|
|
981
|
-
}
|
|
982
|
-
const removed = deleteEntry(ctx.hippoRoot, id, { actor: ctx.actor.subject });
|
|
983
|
-
if (!removed) {
|
|
984
|
-
throw new Error(`memory not found: ${id}`);
|
|
985
|
-
}
|
|
986
|
-
// Counted here, not in the CLI: both callers of this function (cmdForget and
|
|
987
|
-
// the HTTP route) are the two paths of one user command, so neither can miss
|
|
988
|
-
// it. api.remember cannot take the same move; see the server route.
|
|
989
|
-
updateStats(ctx.hippoRoot, { forgotten: 1 });
|
|
990
|
-
return { ok: true, id };
|
|
991
|
-
}
|
|
992
|
-
/**
|
|
993
|
-
* Reject a value: tombstone its normalized digest so a matching write is
|
|
994
|
-
* refused everywhere (remember/capture/import/sync) until `unreject`. Two
|
|
995
|
-
* forms — pass exactly one:
|
|
996
|
-
* - `memoryId`: reject the CURRENT content of an existing memory. Removes
|
|
997
|
-
* that row and every other live row in the tenant whose normalized
|
|
998
|
-
* digest matches (not just the id passed).
|
|
999
|
-
* - `value`: pre-emptive form — tombstone content that may not currently
|
|
1000
|
-
* be stored (or is already gone). Zero removals.
|
|
1001
|
-
*
|
|
1002
|
-
* `reason` is required (the tombstone stores no content; reason is its
|
|
1003
|
-
* only human-readable identity). Throws if the memory id is not found in
|
|
1004
|
-
* `ctx.tenantId`, or if both/neither of `memoryId`/`value` are given.
|
|
1005
|
-
*/
|
|
1006
|
-
export function reject(ctx, opts) {
|
|
1007
|
-
if (opts.memoryId !== undefined) {
|
|
1008
|
-
// Tenant scope, same not-found-shaped denial as forget/promote above:
|
|
1009
|
-
// rejectValue itself also tenant-checks the id, but pre-checking here
|
|
1010
|
-
// keeps the error message consistent with the rest of this module.
|
|
1011
|
-
const db = openHippoDb(ctx.hippoRoot);
|
|
1012
|
-
try {
|
|
1013
|
-
// SAFETY: row's shape matches the single `tenant_id` column named in
|
|
1014
|
-
// the SELECT above.
|
|
1015
|
-
const row = db
|
|
1016
|
-
.prepare(`SELECT tenant_id FROM memories WHERE id = ?`)
|
|
1017
|
-
.get(opts.memoryId);
|
|
1018
|
-
if (!row || row.tenant_id !== ctx.tenantId) {
|
|
1019
|
-
throw new Error(`memory not found: ${opts.memoryId}`);
|
|
1020
|
-
}
|
|
1021
|
-
}
|
|
1022
|
-
finally {
|
|
1023
|
-
closeHippoDb(db);
|
|
1024
|
-
}
|
|
1025
|
-
}
|
|
1026
|
-
const result = rejectValue({
|
|
1027
|
-
hippoRoot: ctx.hippoRoot,
|
|
1028
|
-
tenantId: ctx.tenantId,
|
|
1029
|
-
actor: ctx.actor.subject,
|
|
1030
|
-
reason: opts.reason,
|
|
1031
|
-
memoryId: opts.memoryId,
|
|
1032
|
-
value: opts.value,
|
|
1033
|
-
});
|
|
1034
|
-
return { digest: result.digest, removedIds: result.removedIds };
|
|
1035
|
-
}
|
|
1036
|
-
/**
|
|
1037
|
-
* Delete a tombstone by exact digest or unambiguous prefix, restoring the
|
|
1038
|
-
* value's writability — the only v1 escape hatch (no per-write force flag).
|
|
1039
|
-
* Throws if `digestOrPrefix` matches no tombstone, is blank, or matches
|
|
1040
|
-
* more than one (use a longer prefix).
|
|
1041
|
-
*/
|
|
1042
|
-
export function unreject(ctx, digestOrPrefix) {
|
|
1043
|
-
const outcome = unrejectValue(ctx.hippoRoot, ctx.tenantId, digestOrPrefix, ctx.actor.subject);
|
|
1044
|
-
if (outcome.status === 'not_found') {
|
|
1045
|
-
throw new Error(`no rejected value matches: ${digestOrPrefix}`);
|
|
1046
|
-
}
|
|
1047
|
-
if (outcome.status === 'ambiguous') {
|
|
1048
|
-
throw new Error(`"${digestOrPrefix}" matches ${outcome.candidates.length} tombstones; use a longer prefix`);
|
|
1049
|
-
}
|
|
1050
|
-
return { ok: true, digest: outcome.digest };
|
|
1051
|
-
}
|
|
1052
|
-
/** List every rejected-value tombstone for `ctx.tenantId`, newest first. */
|
|
1053
|
-
export function listRejections(ctx) {
|
|
1054
|
-
return listRejectionsForTenant(ctx.hippoRoot, ctx.tenantId);
|
|
1055
|
-
}
|
|
1056
|
-
export function promote(ctx, id) {
|
|
1057
|
-
// Tenant scope: promoteToGlobal reads the entry from the local root via
|
|
1058
|
-
// readEntry without a tenant filter, so a Bearer for tenant A could
|
|
1059
|
-
// promote tenant B's row by guessing or leaking the id. Pre-check the
|
|
1060
|
-
// row's tenant_id and deny cross-tenant access with the same not-found
|
|
1061
|
-
// wording archiveRaw uses (no info leak about whether the id exists in
|
|
1062
|
-
// another tenant).
|
|
1063
|
-
const ownerDb = openHippoDb(ctx.hippoRoot);
|
|
1064
|
-
try {
|
|
1065
|
-
// SAFETY: row's shape matches the single `tenant_id` column named in
|
|
1066
|
-
// the SELECT above.
|
|
1067
|
-
const row = ownerDb
|
|
1068
|
-
.prepare(`SELECT tenant_id FROM memories WHERE id = ?`)
|
|
1069
|
-
.get(id);
|
|
1070
|
-
if (!row || row.tenant_id !== ctx.tenantId) {
|
|
1071
|
-
throw new Error(`memory not found: ${id}`);
|
|
1072
|
-
}
|
|
1073
|
-
}
|
|
1074
|
-
finally {
|
|
1075
|
-
closeHippoDb(ownerDb);
|
|
1076
|
-
}
|
|
1077
|
-
// promoteToGlobal threads ctx.actor.subject into the writeEntry call on the global
|
|
1078
|
-
// db, which emits a 'remember' audit row. We then add the user-facing
|
|
1079
|
-
// 'promote' event on the global db so the audit trail keeps the intent
|
|
1080
|
-
// distinct from the underlying upsert.
|
|
1081
|
-
const globalEntry = promoteToGlobal(ctx.hippoRoot, id, { actor: ctx.actor.subject, tenantId: ctx.tenantId });
|
|
1082
|
-
const db = openHippoDb(getGlobalRoot());
|
|
1083
|
-
try {
|
|
1084
|
-
appendAuditEvent(db, {
|
|
1085
|
-
tenantId: ctx.tenantId,
|
|
1086
|
-
actor: ctx.actor.subject,
|
|
1087
|
-
op: 'promote',
|
|
1088
|
-
targetId: globalEntry.id,
|
|
1089
|
-
metadata: { sourceId: id },
|
|
1090
|
-
});
|
|
1091
|
-
}
|
|
1092
|
-
finally {
|
|
1093
|
-
closeHippoDb(db);
|
|
1094
|
-
}
|
|
1095
|
-
return { ok: true, sourceId: id, globalId: globalEntry.id };
|
|
1096
|
-
}
|
|
1097
|
-
export function supersede(ctx, oldId, newContent) {
|
|
1098
|
-
// Read old (tenant-scoped). readEntry filters by tenantId, so a Bearer for
|
|
1099
|
-
// tenant A on tenant B's id throws "Memory not found" here without any
|
|
1100
|
-
// info leak.
|
|
1101
|
-
const old = readEntry(ctx.hippoRoot, oldId, ctx.tenantId);
|
|
1102
|
-
if (!old) {
|
|
1103
|
-
throw new Error(`Memory not found: ${oldId}`);
|
|
1104
|
-
}
|
|
1105
|
-
// Guard: not already superseded. The CAS UPDATE below race-safely closes
|
|
1106
|
-
// the window between this read and the write; this check just produces a
|
|
1107
|
-
// clearer error in the common single-writer case.
|
|
1108
|
-
if (old.superseded_by) {
|
|
1109
|
-
throw new Error(`Memory ${oldId} is already superseded by ${old.superseded_by}. Supersede that one instead.`);
|
|
1110
|
-
}
|
|
1111
|
-
const newEntry = createMemory(newContent, {
|
|
1112
|
-
layer: old.layer ?? Layer.Episodic,
|
|
1113
|
-
tags: [...old.tags],
|
|
1114
|
-
pinned: old.pinned,
|
|
1115
|
-
source: old.source,
|
|
1116
|
-
confidence: 'verified',
|
|
1117
|
-
tenantId: ctx.tenantId,
|
|
1118
|
-
});
|
|
1119
|
-
// Race-safe transition: open a fresh db handle, BEGIN IMMEDIATE, run all
|
|
1120
|
-
// three steps (CAS on old + writeEntryDbOnly(new) + supersede audit row)
|
|
1121
|
-
// inside the same transaction. Two concurrent supersedes: exactly one CAS
|
|
1122
|
-
// wins (changes=1), the other gets changes=0 and throws CONFLICT. No
|
|
1123
|
-
// dangling-pointer window: the new memory's row commits atomically with
|
|
1124
|
-
// the old.superseded_by pointer.
|
|
1125
|
-
const db = openHippoDb(ctx.hippoRoot);
|
|
1126
|
-
try {
|
|
1127
|
-
db.exec('BEGIN IMMEDIATE');
|
|
1128
|
-
try {
|
|
1129
|
-
// 1. CAS update: only succeed if old.superseded_by IS NULL AND the
|
|
1130
|
-
// row still belongs to ctx.tenantId. Tenant filter is belt-and-
|
|
1131
|
-
// braces with the readEntry above — it costs nothing and closes
|
|
1132
|
-
// a hypothetical window where ownership changes between read and
|
|
1133
|
-
// update.
|
|
1134
|
-
const result = db.prepare(`
|
|
1135
|
-
UPDATE memories
|
|
1136
|
-
SET superseded_by = ?
|
|
1137
|
-
WHERE id = ? AND tenant_id = ? AND superseded_by IS NULL
|
|
1138
|
-
`).run(newEntry.id, oldId, ctx.tenantId);
|
|
1139
|
-
if ((result.changes ?? 0) === 0) {
|
|
1140
|
-
db.exec('ROLLBACK');
|
|
1141
|
-
throw new Error(`Memory ${oldId} already superseded by another writer`);
|
|
1142
|
-
}
|
|
1143
|
-
// v0.30 / E2 — DAG live-coupling: OLD entry just transitioned to
|
|
1144
|
-
// superseded. Its parent (if any) needs rebuild. Lands strictly
|
|
1145
|
-
// between the rollback guard above and the writeEntryDbOnly(NEW)
|
|
1146
|
-
// below so a failed CAS hits throw before this hook. The NEW
|
|
1147
|
-
// entry's parent (typically same parent) is auto-marked by the
|
|
1148
|
-
// writeEntryDbOnly hook (same parent → idempotent, audits once).
|
|
1149
|
-
if (old.dag_parent_id) {
|
|
1150
|
-
markSummaryDirtyInTx(db, old.dag_parent_id, ctx.tenantId, ctx.actor.subject);
|
|
1151
|
-
}
|
|
1152
|
-
// 2. Write new memory inside same tx via writeEntryDbOnly (DB-only
|
|
1153
|
-
// path). This emits its OWN 'remember' audit row for the new
|
|
1154
|
-
// memory inside the SAVEPOINT — atomic with the row INSERT.
|
|
1155
|
-
writeEntryDbOnly(db, stampOriginProject(ctx.hippoRoot, newEntry), { actor: ctx.actor.subject });
|
|
1156
|
-
// 3. User-facing 'supersede' audit row inside the same tx so the
|
|
1157
|
-
// chain pointer + audit trail commit atomically.
|
|
1158
|
-
appendAuditEvent(db, {
|
|
1159
|
-
tenantId: ctx.tenantId,
|
|
1160
|
-
actor: ctx.actor.subject,
|
|
1161
|
-
op: 'supersede',
|
|
1162
|
-
targetId: oldId,
|
|
1163
|
-
metadata: { newId: newEntry.id },
|
|
1164
|
-
});
|
|
1165
|
-
db.exec('COMMIT');
|
|
1166
|
-
}
|
|
1167
|
-
catch (err) {
|
|
1168
|
-
try {
|
|
1169
|
-
db.exec('ROLLBACK');
|
|
1170
|
-
}
|
|
1171
|
-
catch { /* already rolled back */ }
|
|
1172
|
-
// AT1 (plan §3): refusal audit lands post-ROLLBACK, in a fresh
|
|
1173
|
-
// implicit transaction the aborted outer one cannot claw back — then
|
|
1174
|
-
// rethrow so the caller sees the refusal.
|
|
1175
|
-
if (err instanceof RejectedValueError) {
|
|
1176
|
-
auditRejectionRefusal(db, err, ctx.actor.subject);
|
|
1177
|
-
}
|
|
1178
|
-
throw err;
|
|
1179
|
-
}
|
|
1180
|
-
// Mirrors after COMMIT, while the db handle is still open. Same
|
|
1181
|
-
// invariant as the original writeEntry: a mirror failure leaves disk
|
|
1182
|
-
// MISSING the markdown for the new memory (self-heals on next backfill
|
|
1183
|
-
// via writeIndexMirror reading the DB) but DOES NOT desync the DB or
|
|
1184
|
-
// roll back the supersede. Logged + swallowed, non-fatal.
|
|
1185
|
-
try {
|
|
1186
|
-
writeEntryMirrors(ctx.hippoRoot, db, newEntry);
|
|
1187
|
-
}
|
|
1188
|
-
catch (mirrorErr) {
|
|
1189
|
-
console.error('supersede: mirror write failed (non-fatal, will self-heal):', mirrorErr);
|
|
1190
|
-
}
|
|
1191
|
-
}
|
|
1192
|
-
finally {
|
|
1193
|
-
closeHippoDb(db);
|
|
1194
|
-
}
|
|
1195
|
-
return { ok: true, oldId, newId: newEntry.id };
|
|
1196
|
-
}
|
|
1197
|
-
export function archiveRaw(ctx, id, reason, opts = {}) {
|
|
1198
|
-
const db = openHippoDb(ctx.hippoRoot);
|
|
1199
|
-
let mirrorOk = false;
|
|
1200
|
-
try {
|
|
1201
|
-
// Tenant scope: archiveRawMemory looks up the row by id alone, so a
|
|
1202
|
-
// Bearer for tenant A could archive tenant B's raw row without this
|
|
1203
|
-
// pre-check. Deny cross-tenant access with the same not-found message
|
|
1204
|
-
// archiveRawMemory itself would throw on a missing row, so we don't
|
|
1205
|
-
// leak whether the id exists in another tenant.
|
|
1206
|
-
// SAFETY: row's shape matches the single `tenant_id` column named in
|
|
1207
|
-
// the SELECT above.
|
|
1208
|
-
const row = db
|
|
1209
|
-
.prepare(`SELECT tenant_id FROM memories WHERE id = ?`)
|
|
1210
|
-
.get(id);
|
|
1211
|
-
if (!row || row.tenant_id !== ctx.tenantId) {
|
|
1212
|
-
throw new Error(`memory not found: ${id}`);
|
|
1213
|
-
}
|
|
1214
|
-
archiveRawMemory(db, id, {
|
|
1215
|
-
reason,
|
|
1216
|
-
who: ctx.actor.subject,
|
|
1217
|
-
afterArchive: opts.afterArchive,
|
|
1218
|
-
});
|
|
1219
|
-
// archiveRawMemory deletes the memories row but leaves any legacy markdown
|
|
1220
|
-
// mirror in <root>/{buffer,episodic,semantic}/<id>.md untouched. If we left
|
|
1221
|
-
// the mirror in place, a subsequent initStore() on an empty memories table
|
|
1222
|
-
// would silently re-import the row via bootstrapLegacyStore — defeating the
|
|
1223
|
-
// archive (and the GDPR right-to-be-forgotten promise on raw rows). Mirror
|
|
1224
|
-
// forget() at src/store.ts:1046, which uses the same removeEntryMirrors call.
|
|
1225
|
-
// The DB transaction has already committed; if filesystem unlink fails here
|
|
1226
|
-
// we log and continue. The mirror reaper in openHippoDb will catch it on
|
|
1227
|
-
// next DB open: raw_archive.mirror_cleaned_at stays NULL until every layer
|
|
1228
|
-
// mirror for this id is gone, so the reaper genuinely retries.
|
|
1229
|
-
try {
|
|
1230
|
-
removeEntryMirrors(ctx.hippoRoot, id);
|
|
1231
|
-
mirrorOk = true;
|
|
1232
|
-
}
|
|
1233
|
-
catch (mirrorErr) {
|
|
1234
|
-
console.error(`archiveRaw: mirror cleanup failed for ${id} (will retry via reaper on next openHippoDb):`, mirrorErr);
|
|
1235
|
-
}
|
|
1236
|
-
if (mirrorOk) {
|
|
1237
|
-
// Stamp mirror_cleaned_at now so the next openHippoDb reaper SELECT
|
|
1238
|
-
// returns empty for this row. NULL stays untouched on failure -> retry.
|
|
1239
|
-
db.prepare(`UPDATE raw_archive SET mirror_cleaned_at = ? WHERE memory_id = ?`).run(new Date().toISOString(), id);
|
|
1240
|
-
}
|
|
1241
|
-
}
|
|
1242
|
-
finally {
|
|
1243
|
-
closeHippoDb(db);
|
|
1244
|
-
}
|
|
1245
|
-
// Counted here rather than in the CLI: the HTTP archive route calls this too,
|
|
1246
|
-
// so a routed archive would otherwise never reach the forgotten counter.
|
|
1247
|
-
updateStats(ctx.hippoRoot, { forgotten: 1 });
|
|
1248
|
-
// archiveRawMemory does not return the archive_at timestamp it wrote. We
|
|
1249
|
-
// emit a fresh ISO timestamp here for the API response. Within a millisecond
|
|
1250
|
-
// of the actual write, fine for a server response shape.
|
|
1251
|
-
return { ok: true, archivedAt: new Date().toISOString() };
|
|
1252
|
-
}
|
|
1253
|
-
/**
|
|
1254
|
-
* Mint a new API key. The new key is ALWAYS bound to `ctx.tenantId`. Callers
|
|
1255
|
-
* cannot override the tenant via the opts bag — a previous `tenantId` field
|
|
1256
|
-
* was removed because the HTTP layer would happily forward `body.tenantId`,
|
|
1257
|
-
* letting tenant A mint a key for tenant B. The HTTP route handler at
|
|
1258
|
-
* `src/server.ts` POST /v1/auth/keys mirrors this: it ignores any body
|
|
1259
|
-
* `tenantId` and uses the resolved Bearer's tenant exclusively.
|
|
1260
|
-
*
|
|
1261
|
-
* Per A5 v2 follow-ups (TODOS.md), `auth_create` is currently unaudited —
|
|
1262
|
-
* we intentionally match that behavior here for consistency. When A5 v2
|
|
1263
|
-
* lands and adds the audit op, this function should mirror the cli handler.
|
|
1264
|
-
*/
|
|
1265
|
-
export function authCreate(ctx, opts) {
|
|
1266
|
-
const db = openHippoDb(ctx.hippoRoot);
|
|
1267
|
-
try {
|
|
1268
|
-
const role = opts.role ?? 'admin';
|
|
1269
|
-
const result = createApiKey(db, { tenantId: ctx.tenantId, label: opts.label, role });
|
|
1270
|
-
// v1.12.4: audit emit (closes the gap v1.12.3 CHANGELOG flagged as deferred).
|
|
1271
|
-
// Mirrors the auth_revoke pattern at authRevoke — same try/catch so audit
|
|
1272
|
-
// failure can't crash a successful mint. The plaintext is NEVER logged;
|
|
1273
|
-
// metadata carries label + role + the keyId (which is non-secret).
|
|
1274
|
-
try {
|
|
1275
|
-
appendAuditEvent(db, {
|
|
1276
|
-
tenantId: ctx.tenantId,
|
|
1277
|
-
actor: ctx.actor.subject,
|
|
1278
|
-
op: 'auth_create',
|
|
1279
|
-
targetId: result.keyId,
|
|
1280
|
-
metadata: {
|
|
1281
|
-
label: opts.label ?? null,
|
|
1282
|
-
role,
|
|
1283
|
-
},
|
|
1284
|
-
});
|
|
1285
|
-
}
|
|
1286
|
-
catch {
|
|
1287
|
-
// Audit must not crash a successful mint.
|
|
1288
|
-
}
|
|
1289
|
-
return { keyId: result.keyId, plaintext: result.plaintext, tenantId: ctx.tenantId, role };
|
|
1290
|
-
}
|
|
1291
|
-
finally {
|
|
1292
|
-
closeHippoDb(db);
|
|
1293
|
-
}
|
|
1294
|
-
}
|
|
1295
|
-
/**
|
|
1296
|
-
* List API keys visible to the calling tenant.
|
|
1297
|
-
*
|
|
1298
|
-
* Divergence from `cmdAuthList` in src/cli.ts: the CLI today returns ALL keys
|
|
1299
|
-
* regardless of tenant (single-tenant deployments). The API surface is tenant-
|
|
1300
|
-
* scoped because future multi-tenant deployments will share a hippoRoot, and
|
|
1301
|
-
* tenant A must not see tenant B's keys. Read-only — no audit emit (matches A5).
|
|
1302
|
-
*/
|
|
1303
|
-
export function authList(ctx, opts) {
|
|
1304
|
-
const db = openHippoDb(ctx.hippoRoot);
|
|
1305
|
-
try {
|
|
1306
|
-
const all = listApiKeys(db, opts);
|
|
1307
|
-
return all.filter((k) => k.tenantId === ctx.tenantId);
|
|
1308
|
-
}
|
|
1309
|
-
finally {
|
|
1310
|
-
closeHippoDb(db);
|
|
1311
|
-
}
|
|
1312
|
-
}
|
|
1313
|
-
export function authRevoke(ctx, keyId) {
|
|
1314
|
-
const db = openHippoDb(ctx.hippoRoot);
|
|
1315
|
-
try {
|
|
1316
|
-
// SAFETY: row's shape matches the three columns named in the SELECT
|
|
1317
|
-
// above.
|
|
1318
|
-
const row = db
|
|
1319
|
-
.prepare(`SELECT key_id, tenant_id, revoked_at FROM api_keys WHERE key_id = ?`)
|
|
1320
|
-
.get(keyId);
|
|
1321
|
-
if (!row) {
|
|
1322
|
-
throw new Error(`Unknown key_id: ${keyId}`);
|
|
1323
|
-
}
|
|
1324
|
-
// Cross-tenant access denied: same message as missing key, no info leak.
|
|
1325
|
-
if (row.tenant_id !== ctx.tenantId) {
|
|
1326
|
-
throw new Error(`Unknown key_id: ${keyId}`);
|
|
1327
|
-
}
|
|
1328
|
-
let revokedAt;
|
|
1329
|
-
let alreadyRevoked = false;
|
|
1330
|
-
if (row.revoked_at) {
|
|
1331
|
-
alreadyRevoked = true;
|
|
1332
|
-
revokedAt = row.revoked_at;
|
|
1333
|
-
}
|
|
1334
|
-
else {
|
|
1335
|
-
revokeApiKey(db, keyId);
|
|
1336
|
-
// SAFETY: updated's shape matches the single `revoked_at` column named
|
|
1337
|
-
// in the SELECT above.
|
|
1338
|
-
const updated = db
|
|
1339
|
-
.prepare(`SELECT revoked_at FROM api_keys WHERE key_id = ?`)
|
|
1340
|
-
.get(keyId);
|
|
1341
|
-
revokedAt = updated?.revoked_at ?? new Date().toISOString();
|
|
1342
|
-
}
|
|
1343
|
-
if (!alreadyRevoked) {
|
|
1344
|
-
try {
|
|
1345
|
-
appendAuditEvent(db, {
|
|
1346
|
-
tenantId: row.tenant_id, // M1: KEY's tenant, not ctx.tenantId.
|
|
1347
|
-
actor: ctx.actor.subject,
|
|
1348
|
-
op: 'auth_revoke',
|
|
1349
|
-
targetId: keyId,
|
|
1350
|
-
});
|
|
1351
|
-
}
|
|
1352
|
-
catch {
|
|
1353
|
-
// Audit must not crash a successful revoke.
|
|
1354
|
-
}
|
|
1355
|
-
}
|
|
1356
|
-
return { ok: true, revokedAt };
|
|
1357
|
-
}
|
|
1358
|
-
finally {
|
|
1359
|
-
closeHippoDb(db);
|
|
1360
|
-
}
|
|
1361
|
-
}
|
|
1362
|
-
/**
|
|
1363
|
-
* Read audit events scoped to `ctx.tenantId`. Read-only — no audit emit (matches
|
|
1364
|
-
* A5: cmdAuditList does not record a 'recall'-style read event).
|
|
1365
|
-
*/
|
|
1366
|
-
export function auditList(ctx, opts) {
|
|
1367
|
-
const db = openHippoDb(ctx.hippoRoot);
|
|
1368
|
-
try {
|
|
1369
|
-
return queryAuditEvents(db, {
|
|
1370
|
-
tenantId: ctx.tenantId,
|
|
1371
|
-
op: opts.op,
|
|
1372
|
-
since: opts.since,
|
|
1373
|
-
limit: opts.limit,
|
|
1374
|
-
});
|
|
1375
|
-
}
|
|
1376
|
-
finally {
|
|
1377
|
-
closeHippoDb(db);
|
|
1378
|
-
}
|
|
1379
|
-
}
|
|
1380
|
-
/**
|
|
1381
|
-
* Assemble a context bundle: recalled memories (pinned-only / strength-sorted
|
|
1382
|
-
* fallback / hybrid search) + active task snapshot + session handoff + recent
|
|
1383
|
-
* session events. Budget-bounded, tenant-scoped. Mutates `last_retrieval_ids`
|
|
1384
|
-
* + emits a 'recall' audit row for non-pinned, non-'*' queries.
|
|
1385
|
-
*
|
|
1386
|
-
* Behaves like the pre-extraction `cmdContext` data-loading + selection
|
|
1387
|
-
* pipeline. CLI presentation (markdown / json / additional-context rendering)
|
|
1388
|
-
* stays in `cli.ts`.
|
|
1389
|
-
*
|
|
1390
|
-
* Tenant scope: all `loadAllEntries` / snapshot / handoff / events reads use
|
|
1391
|
-
* `ctx.tenantId`. Cross-tenant rows are filtered out.
|
|
1392
|
-
*
|
|
1393
|
-
* Returns an empty result (`entries: []`, snapshot/handoff/events undefined)
|
|
1394
|
-
* when there's nothing to surface (no memories AND no snapshot AND no handoff
|
|
1395
|
-
* AND no recent events).
|
|
1396
|
-
*/
|
|
1397
|
-
export async function getContext(ctx, opts = {}) {
|
|
1398
|
-
const pinnedOnly = opts.pinnedOnly === true;
|
|
1399
|
-
const budget = opts.budget ?? 1500;
|
|
1400
|
-
const limit = opts.limit ?? Number.POSITIVE_INFINITY;
|
|
1401
|
-
const includeRecent = opts.includeRecent ?? 0;
|
|
1402
|
-
const activeScope = opts.scope ?? '';
|
|
1403
|
-
if (budget <= 0) {
|
|
1404
|
-
return { entries: [], tokens: 0 };
|
|
1405
|
-
}
|
|
1406
|
-
// Pinned-only path is allowed against an un-initialised local store (the
|
|
1407
|
-
// UserPromptSubmit hook can run in directories without a .hippo). Non-pinned
|
|
1408
|
-
// path requires an initialised local store; callers should check first.
|
|
1409
|
-
const hasLocal = isInitialized(ctx.hippoRoot);
|
|
1410
|
-
const query = (opts.q ?? '').trim() || '*';
|
|
1411
|
-
const globalRoot = getGlobalRoot();
|
|
1412
|
-
const hasGlobal = isInitialized(globalRoot);
|
|
1413
|
-
// v39 memory scope isolation (docs/plans/2026-07-01-memory-scope-isolation.md).
|
|
1414
|
-
// S2: envelope-filter parity with api.recall for AMBIENT context - private
|
|
1415
|
-
// scopes and quarantine buckets never inject. `requested` is deliberately
|
|
1416
|
-
// undefined: opts.scope is the scope-TAG boost input here, not an
|
|
1417
|
-
// envelope-scope request (api.recall's exact-match semantics don't apply).
|
|
1418
|
-
// S3: origin partition - other-project memories are excluded unless the
|
|
1419
|
-
// caller explicitly asks for them (crossProject) or isolation is disabled.
|
|
1420
|
-
const config = loadConfig(ctx.hippoRoot);
|
|
1421
|
-
const isolationEnabled = config.contextProjectIsolation !== false;
|
|
1422
|
-
const currentProjectName = opts.currentProject ?? resolveProjectIdentity(process.cwd()).name;
|
|
1423
|
-
const includeCrossProject = opts.crossProject === true || !isolationEnabled;
|
|
1424
|
-
const ambientAdmit = (e) => ambientAdmitEntry(e, currentProjectName, includeCrossProject);
|
|
1425
|
-
// Superseded rows never inject, and ambientAdmitEntry regex-scans content for
|
|
1426
|
-
// secrets, so WHICH rows reach this predicate is what loadAmbientEntries cares
|
|
1427
|
-
// about below.
|
|
1428
|
-
const admit = (e) => !e.superseded_by && ambientAdmit(e);
|
|
1429
|
-
// Tenant-scoped loads (v1.11.1 lesson: NEVER resolveTenantId({}) here).
|
|
1430
|
-
let localEntries = hasLocal
|
|
1431
|
-
? loadAmbientEntries(ctx.hippoRoot, ctx.tenantId, pinnedOnly, includeRecent, admit)
|
|
1432
|
-
: [];
|
|
1433
|
-
let globalEntries = hasGlobal
|
|
1434
|
-
? loadAmbientEntries(globalRoot, ctx.tenantId, pinnedOnly, includeRecent, admit)
|
|
1435
|
-
: [];
|
|
1436
|
-
// Computed below, after markRetrieved runs, so avgStrength reflects the
|
|
1437
|
-
// post-retrieval strengths rather than a stale pre-mutation snapshot.
|
|
1438
|
-
let ambientState;
|
|
1439
|
-
// DF1 T2: bounded read — an orphaned snapshot (no later pre-compact
|
|
1440
|
-
// superseded it, no session-end closed it) must age out of this ambient
|
|
1441
|
-
// surface instead of injecting into every future prompt forever. Owner
|
|
1442
|
-
// reads (opts.currentSessionId matches the snapshot's session_id) stay
|
|
1443
|
-
// unbounded; see loadFreshActiveTaskSnapshot's own doc comment for the
|
|
1444
|
-
// exact null/empty-id matching rules.
|
|
1445
|
-
const rowScope = (r) => r?.scope ?? null;
|
|
1446
|
-
const rawActiveSnapshot = hasLocal
|
|
1447
|
-
? loadFreshActiveTaskSnapshot(ctx.hippoRoot, ctx.tenantId, {
|
|
1448
|
-
sessionId: opts.currentSessionId,
|
|
1449
|
-
})
|
|
1450
|
-
: null;
|
|
1451
|
-
// W1: pre-existing leak; same `requested: undefined` ambientAdmitEntry
|
|
1452
|
-
// already uses when it scope-filters memory rows above.
|
|
1453
|
-
const activeSnapshot = rawActiveSnapshot && passesScopeFilterForRecall(rowScope(rawActiveSnapshot), undefined)
|
|
1454
|
-
? rawActiveSnapshot
|
|
1455
|
-
: null;
|
|
1456
|
-
// Key on the RAW snapshot: a scope-hidden active session must not fall through to another session's ambient handoff.
|
|
1457
|
-
const rawSessionHandoff = !hasLocal
|
|
1458
|
-
? null
|
|
1459
|
-
: rawActiveSnapshot?.session_id
|
|
1460
|
-
? loadLatestHandoff(ctx.hippoRoot, ctx.tenantId, rawActiveSnapshot.session_id)
|
|
1461
|
-
: loadLatestHandoff(ctx.hippoRoot, ctx.tenantId, undefined, {
|
|
1462
|
-
unfinishedOnly: true,
|
|
1463
|
-
maxAgeMs: SNAPSHOT_AMBIENT_MAX_AGE_MS,
|
|
1464
|
-
// codex P2: admit scope in SQL so a newer denied row can't hide an older eligible one before LIMIT 1.
|
|
1465
|
-
scopeFilter: 'default-deny',
|
|
1466
|
-
});
|
|
1467
|
-
const sessionHandoff = rawSessionHandoff && passesScopeFilterForRecall(rowScope(rawSessionHandoff), undefined)
|
|
1468
|
-
? rawSessionHandoff
|
|
1469
|
-
: null;
|
|
1470
|
-
// Raw session id here too: each event is admitted on its own scope, same as recall and the CLI.
|
|
1471
|
-
const recentSessionEvents = hasLocal && rawActiveSnapshot?.session_id
|
|
1472
|
-
? listSessionEvents(ctx.hippoRoot, ctx.tenantId, {
|
|
1473
|
-
session_id: rawActiveSnapshot.session_id,
|
|
1474
|
-
limit: 5,
|
|
1475
|
-
}).filter((e) => passesScopeFilterForRecall(rowScope(e), undefined))
|
|
1476
|
-
: [];
|
|
1477
|
-
if (localEntries.length === 0 &&
|
|
1478
|
-
globalEntries.length === 0 &&
|
|
1479
|
-
!activeSnapshot &&
|
|
1480
|
-
!sessionHandoff &&
|
|
1481
|
-
recentSessionEvents.length === 0) {
|
|
1482
|
-
return { entries: [], tokens: 0 };
|
|
1483
|
-
}
|
|
1484
|
-
let selectedItems = [];
|
|
1485
|
-
let totalTokens = 0;
|
|
1486
|
-
if (pinnedOnly) {
|
|
1487
|
-
// loadConfig is safe even when local isn't initialised — returns defaults.
|
|
1488
|
-
const pinnedCfg = loadConfig(ctx.hippoRoot);
|
|
1489
|
-
if (!pinnedCfg.pinnedInject.enabled) {
|
|
1490
|
-
return { entries: [], tokens: 0 };
|
|
1491
|
-
}
|
|
1492
|
-
// Effective budget: explicit opts.budget wins over config.
|
|
1493
|
-
const effBudget = opts.budget !== undefined ? budget : pinnedCfg.pinnedInject.budget;
|
|
1494
|
-
const nowP = evalNow(); // honors HIPPO_FAKE_NOW (eval-only; see ablation.ts)
|
|
1495
|
-
const selectedIds = new Set();
|
|
1496
|
-
let usedP = 0;
|
|
1497
|
-
// Pinned entries are explicit user intent, the recent-N list an automatic
|
|
1498
|
-
// backfill. Both loops share ONE budget and the recent loop runs first, so
|
|
1499
|
-
// pins are ranked here and reserve their share before it can spend.
|
|
1500
|
-
const pinnedLocal = localEntries.filter((e) => e.pinned);
|
|
1501
|
-
const pinnedGlobal = globalEntries.filter((e) => e.pinned);
|
|
1502
|
-
const rankedPinned = [
|
|
1503
|
-
...pinnedLocal.map((e) => ({ entry: e, isGlobal: false })),
|
|
1504
|
-
...pinnedGlobal.map((e) => ({ entry: e, isGlobal: true })),
|
|
1505
|
-
]
|
|
1506
|
-
.map(({ entry, isGlobal }) => {
|
|
1507
|
-
const scopeSig = scopeMatch(entry.tags, activeScope);
|
|
1508
|
-
const sBst = scopeSig === 1 ? 1.5 : scopeSig === -1 ? 0.5 : 1.0;
|
|
1509
|
-
return {
|
|
1510
|
-
entry,
|
|
1511
|
-
score: calculateStrength(entry, nowP) * (isGlobal ? 1 / 1.2 : 1) * sBst,
|
|
1512
|
-
tokens: estimateTokens(entry.content),
|
|
1513
|
-
isGlobal,
|
|
1514
|
-
};
|
|
1515
|
-
})
|
|
1516
|
-
.sort(compareScoredResults);
|
|
1517
|
-
// Mirror the pinned admission loop's own `continue`-not-`break`
|
|
1518
|
-
// semantics (further down) so the reserve equals what that loop will
|
|
1519
|
-
// actually admit -- a big pin near the front should not block smaller
|
|
1520
|
-
// pins behind it from reserving their share too.
|
|
1521
|
-
// Dedupe by id: `syncGlobalToLocal` copies global rows into the local
|
|
1522
|
-
// store preserving `entry.id`, so a synced pin appears in BOTH
|
|
1523
|
-
// `pinnedLocal` and `pinnedGlobal` and would otherwise reserve its cost
|
|
1524
|
-
// twice. The admission loop already dedupes via `selectedIds`; the
|
|
1525
|
-
// reserve has to mirror that or it silently starves recents of budget a
|
|
1526
|
-
// single returned pin never needed.
|
|
1527
|
-
let pinnedReserve = 0;
|
|
1528
|
-
const reservedIds = new Set();
|
|
1529
|
-
for (const r of rankedPinned) {
|
|
1530
|
-
if (reservedIds.has(r.entry.id))
|
|
1531
|
-
continue;
|
|
1532
|
-
if (pinnedReserve + r.tokens <= effBudget) {
|
|
1533
|
-
pinnedReserve += r.tokens;
|
|
1534
|
-
reservedIds.add(r.entry.id);
|
|
1535
|
-
}
|
|
1536
|
-
}
|
|
1537
|
-
// Known, accepted tradeoff: a pin that also lands in the recent-N slice
|
|
1538
|
-
// is counted once in `pinnedReserve` (here) AND admitted again by the
|
|
1539
|
-
// recent loop below, so a little budget goes unused (`recentBudget` is
|
|
1540
|
-
// more conservative than it needs to be in that case). That only
|
|
1541
|
-
// under-fills recents slightly -- it never displaces a pin -- so it is
|
|
1542
|
-
// the safe direction and is not worth extra bookkeeping to recover.
|
|
1543
|
-
const recentBudget = Math.max(0, effBudget - pinnedReserve);
|
|
1544
|
-
if (includeRecent > 0) {
|
|
1545
|
-
const recent = [
|
|
1546
|
-
...localEntries.map((entry) => ({ entry, isGlobal: false })),
|
|
1547
|
-
...globalEntries.map((entry) => ({ entry, isGlobal: true })),
|
|
1548
|
-
]
|
|
1549
|
-
// T2 (src/compare.ts) note: this already carries an explicit
|
|
1550
|
-
// per-instance tiebreak (created desc -> id localeCompare) and is
|
|
1551
|
-
// deliberately left as-is rather than routed through
|
|
1552
|
-
// compareEntryIdentity. `created` reflects ingest order, so it is
|
|
1553
|
-
// cross-ingest stable at ms granularity; the residual is honest,
|
|
1554
|
-
// not silently ignored — rows created in the same millisecond fall
|
|
1555
|
-
// to `id.localeCompare`, which is per-instance random (id is
|
|
1556
|
-
// crypto.randomUUID()), so this listing is per-instance-
|
|
1557
|
-
// deterministic but NOT cross-ingest-stable under same-ms
|
|
1558
|
-
// collisions.
|
|
1559
|
-
.sort((a, b) => {
|
|
1560
|
-
const byCreated = Date.parse(b.entry.created) - Date.parse(a.entry.created);
|
|
1561
|
-
return byCreated !== 0 ? byCreated : b.entry.id.localeCompare(a.entry.id);
|
|
1562
|
-
})
|
|
1563
|
-
// DF3 (docs/plans/2026-08-23-df3-include-recent-quality-floor.md):
|
|
1564
|
-
// filter before slice, not after — the caller asked for N recent
|
|
1565
|
-
// *useful* entries, so a junk row must be skipped and backfilled
|
|
1566
|
-
// past, not counted against the N. Skip-only: no mutation, no audit
|
|
1567
|
-
// row, nothing becomes unrecoverable.
|
|
1568
|
-
//
|
|
1569
|
-
// `entry.pinned ||` bypass IS needed here (codex review finding,
|
|
1570
|
-
// corrects the earlier claim in this comment that it wasn't): under
|
|
1571
|
-
// budget pressure, a pinned entry that fails the heuristic gets
|
|
1572
|
-
// dropped from this recent slice, and an unpinned entry backfills
|
|
1573
|
-
// into its slot and consumes `usedP` in the loop below. By the time
|
|
1574
|
-
// the pinned block runs (further down), the budget it needed is
|
|
1575
|
-
// already spent, so it hits `continue` and the pinned entry is
|
|
1576
|
-
// omitted entirely — the pinned block is NOT a safety net once the
|
|
1577
|
-
// recent loop has already spent the shared budget.
|
|
1578
|
-
.filter(({ entry }) => entry.pinned || isContentWorthStoring(entry.content))
|
|
1579
|
-
.slice(0, includeRecent)
|
|
1580
|
-
.map(({ entry, isGlobal }) => ({
|
|
1581
|
-
entry,
|
|
1582
|
-
score: calculateStrength(entry, nowP) * (isGlobal ? 1 / 1.2 : 1),
|
|
1583
|
-
tokens: estimateTokens(entry.content),
|
|
1584
|
-
isGlobal,
|
|
1585
|
-
}));
|
|
1586
|
-
for (const r of recent) {
|
|
1587
|
-
if (selectedIds.has(r.entry.id))
|
|
1588
|
-
continue;
|
|
1589
|
-
if (usedP + r.tokens > recentBudget)
|
|
1590
|
-
continue;
|
|
1591
|
-
selectedItems.push(r);
|
|
1592
|
-
selectedIds.add(r.entry.id);
|
|
1593
|
-
usedP += r.tokens;
|
|
1594
|
-
}
|
|
1595
|
-
}
|
|
1596
|
-
if (pinnedLocal.length === 0 &&
|
|
1597
|
-
pinnedGlobal.length === 0 &&
|
|
1598
|
-
selectedItems.length === 0) {
|
|
1599
|
-
return { entries: [], tokens: 0 };
|
|
1600
|
-
}
|
|
1601
|
-
for (const r of rankedPinned) {
|
|
1602
|
-
if (selectedIds.has(r.entry.id))
|
|
1603
|
-
continue;
|
|
1604
|
-
if (usedP + r.tokens > effBudget)
|
|
1605
|
-
continue;
|
|
1606
|
-
selectedItems.push(r);
|
|
1607
|
-
selectedIds.add(r.entry.id);
|
|
1608
|
-
usedP += r.tokens;
|
|
1609
|
-
}
|
|
1610
|
-
totalTokens = usedP;
|
|
1611
|
-
}
|
|
1612
|
-
else if (query === '*') {
|
|
1613
|
-
// No query: return strongest memories by strength, up to budget.
|
|
1614
|
-
const now = evalNow(); // honors HIPPO_FAKE_NOW (eval-only; see ablation.ts)
|
|
1615
|
-
const localRanked = localEntries
|
|
1616
|
-
.map((e) => ({
|
|
1617
|
-
entry: e,
|
|
1618
|
-
score: calculateStrength(e, now),
|
|
1619
|
-
tokens: estimateTokens(e.content),
|
|
1620
|
-
isGlobal: false,
|
|
1621
|
-
}))
|
|
1622
|
-
.sort(compareScoredResults);
|
|
1623
|
-
const globalRanked = globalEntries
|
|
1624
|
-
.map((e) => ({
|
|
1625
|
-
entry: e,
|
|
1626
|
-
score: calculateStrength(e, now) * (1 / 1.2),
|
|
1627
|
-
tokens: estimateTokens(e.content),
|
|
1628
|
-
isGlobal: true,
|
|
1629
|
-
}))
|
|
1630
|
-
.sort(compareScoredResults);
|
|
1631
|
-
const combined = [...localRanked, ...globalRanked].sort(compareScoredResults);
|
|
1632
|
-
let used = 0;
|
|
1633
|
-
for (const r of combined) {
|
|
1634
|
-
if (used + r.tokens > budget)
|
|
1635
|
-
continue;
|
|
1636
|
-
selectedItems.push(r);
|
|
1637
|
-
used += r.tokens;
|
|
1638
|
-
}
|
|
1639
|
-
totalTokens = used;
|
|
1640
|
-
}
|
|
1641
|
-
else {
|
|
1642
|
-
// Real query: hybrid search (global + local) or physics+hybrid (local only).
|
|
1643
|
-
let results;
|
|
1644
|
-
if (hasGlobal) {
|
|
1645
|
-
// searchBothHybrid loads from the store roots itself, so the ambient
|
|
1646
|
-
// filter above never saw its candidates. Admission runs INSIDE the
|
|
1647
|
-
// search via the opt-in entryFilter, BEFORE ranking, cross-store
|
|
1648
|
-
// content-dedupe, and budgeting - a post-filter instead would let an
|
|
1649
|
-
// excluded row saturate the budget (codex rounds 1+3) or shadow its
|
|
1650
|
-
// admitted duplicate in the dedupe pass (codex round 4). Recall paths
|
|
1651
|
-
// never set entryFilter, so their behavior is unchanged.
|
|
1652
|
-
const merged = await searchBothHybrid(query, ctx.hippoRoot, globalRoot, {
|
|
1653
|
-
budget,
|
|
1654
|
-
scope: activeScope,
|
|
1655
|
-
tenantId: ctx.tenantId,
|
|
1656
|
-
entryFilter: ambientAdmit,
|
|
1657
|
-
});
|
|
1658
|
-
const localIndex = loadIndex(ctx.hippoRoot);
|
|
1659
|
-
results = merged.map((r) => ({
|
|
1660
|
-
entry: r.entry,
|
|
1661
|
-
score: r.score,
|
|
1662
|
-
tokens: r.tokens,
|
|
1663
|
-
isGlobal: !localIndex.entries[r.entry.id],
|
|
1664
|
-
}));
|
|
1665
|
-
}
|
|
1666
|
-
else {
|
|
1667
|
-
const ctxConfig = loadConfig(ctx.hippoRoot);
|
|
1668
|
-
const usePhysicsCtx = ctxConfig.physics?.enabled !== false;
|
|
1669
|
-
const ctxResults = usePhysicsCtx
|
|
1670
|
-
? await physicsSearch(query, localEntries, {
|
|
1671
|
-
budget,
|
|
1672
|
-
hippoRoot: ctx.hippoRoot,
|
|
1673
|
-
physicsConfig: ctxConfig.physics,
|
|
1674
|
-
scope: activeScope,
|
|
1675
|
-
})
|
|
1676
|
-
: await hybridSearch(query, localEntries, {
|
|
1677
|
-
budget,
|
|
1678
|
-
hippoRoot: ctx.hippoRoot,
|
|
1679
|
-
scope: activeScope,
|
|
1680
|
-
});
|
|
1681
|
-
results = ctxResults.map((r) => ({
|
|
1682
|
-
entry: r.entry,
|
|
1683
|
-
score: r.score,
|
|
1684
|
-
tokens: r.tokens,
|
|
1685
|
-
isGlobal: false,
|
|
1686
|
-
}));
|
|
1687
|
-
}
|
|
1688
|
-
selectedItems = results;
|
|
1689
|
-
totalTokens = results.reduce((sum, r) => sum + r.tokens, 0);
|
|
1690
|
-
// A5 H4: emit recall audit row for context-mode searches (matches the
|
|
1691
|
-
// 'recall' op emitted by api.recall for parity). pinnedOnly + '*' fallback
|
|
1692
|
-
// never hit the search engines, so they don't emit (matches cmdContext).
|
|
1693
|
-
const ctxRecallMetadata = {
|
|
1694
|
-
query: query.slice(0, 200),
|
|
1695
|
-
results: selectedItems.length,
|
|
1696
|
-
mode: 'context',
|
|
1697
|
-
};
|
|
1698
|
-
if (hasLocal) {
|
|
1699
|
-
const localDb = openHippoDb(ctx.hippoRoot);
|
|
1700
|
-
try {
|
|
1701
|
-
appendAuditEvent(localDb, {
|
|
1702
|
-
tenantId: ctx.tenantId,
|
|
1703
|
-
actor: ctx.actor.subject,
|
|
1704
|
-
op: 'recall',
|
|
1705
|
-
metadata: ctxRecallMetadata,
|
|
1706
|
-
});
|
|
1707
|
-
}
|
|
1708
|
-
finally {
|
|
1709
|
-
closeHippoDb(localDb);
|
|
1710
|
-
}
|
|
1711
|
-
}
|
|
1712
|
-
if (hasGlobal) {
|
|
1713
|
-
const globalDb = openHippoDb(globalRoot);
|
|
1714
|
-
try {
|
|
1715
|
-
appendAuditEvent(globalDb, {
|
|
1716
|
-
tenantId: ctx.tenantId,
|
|
1717
|
-
actor: ctx.actor.subject,
|
|
1718
|
-
op: 'recall',
|
|
1719
|
-
metadata: ctxRecallMetadata,
|
|
1720
|
-
});
|
|
1721
|
-
}
|
|
1722
|
-
finally {
|
|
1723
|
-
closeHippoDb(globalDb);
|
|
1724
|
-
}
|
|
1725
|
-
}
|
|
1726
|
-
}
|
|
1727
|
-
if (limit < selectedItems.length) {
|
|
1728
|
-
selectedItems = selectedItems.slice(0, limit);
|
|
1729
|
-
totalTokens = selectedItems.reduce((sum, r) => sum + r.tokens, 0);
|
|
1730
|
-
}
|
|
1731
|
-
// v39: annotate every returned entry with its origin and how it relates to
|
|
1732
|
-
// the active project, so renderers can demarcate cross-project inclusions.
|
|
1733
|
-
selectedItems = selectedItems.map((r) => ({
|
|
1734
|
-
...r,
|
|
1735
|
-
origin: r.entry.origin_project ?? null,
|
|
1736
|
-
category: classifyOriginProject(r.entry.origin_project, currentProjectName),
|
|
1737
|
-
}));
|
|
1738
|
-
if (selectedItems.length === 0 &&
|
|
1739
|
-
!activeSnapshot &&
|
|
1740
|
-
!sessionHandoff &&
|
|
1741
|
-
recentSessionEvents.length === 0) {
|
|
1742
|
-
// LC1 F5 fix: this bare early-return used to skip tracing entirely — a
|
|
1743
|
-
// query that found nothing is exactly the coverage-gap signal Track LC
|
|
1744
|
-
// needs. Write an empty trace (result_count 0, no result rows) so it
|
|
1745
|
-
// lands in the training corpus. Never touches localIndex/
|
|
1746
|
-
// last_retrieval_ids/last_trace_id — by construction it can't desync
|
|
1747
|
-
// (mirrors the CLI zero-result path). Skipped under pinnedOnly (hot
|
|
1748
|
-
// path stays read-only, same reason it skips markRetrieved). Fail-soft
|
|
1749
|
-
// internally; never throws.
|
|
1750
|
-
if (!pinnedOnly) {
|
|
1751
|
-
// No snapshot in this branch, so the caller's own id is the only session to stamp.
|
|
1752
|
-
writeRecallTraceAtRoot(ctx.hippoRoot, {
|
|
1753
|
-
tenantId: ctx.tenantId,
|
|
1754
|
-
sessionId: opts.currentSessionId || null,
|
|
1755
|
-
pipeline: 'context',
|
|
1756
|
-
query,
|
|
1757
|
-
explainMode: false,
|
|
1758
|
-
results: [],
|
|
1759
|
-
});
|
|
1760
|
-
}
|
|
1761
|
-
return { entries: [], tokens: 0 };
|
|
1762
|
-
}
|
|
1763
|
-
// pinnedOnly is the UserPromptSubmit hot path — read-only so pinned
|
|
1764
|
-
// memories don't inflate retrieval_count or extend half_life by 2 days per
|
|
1765
|
-
// turn over a long session.
|
|
1766
|
-
if (!pinnedOnly) {
|
|
1767
|
-
const toUpdate = selectedItems.map((s) => s.entry);
|
|
1768
|
-
const updatedEntries = markRetrieved(toUpdate);
|
|
1769
|
-
const localIndex = loadIndex(ctx.hippoRoot);
|
|
1770
|
-
// EVAL-ONLY ablation (see ablation.ts): under the recall flag,
|
|
1771
|
-
// markRetrieved returns unmutated entries (ids preserved for outcome
|
|
1772
|
-
// attribution) and persistence is skipped (identical-row writes still
|
|
1773
|
-
// refresh updated_at / mirrors / DAG dirty flags).
|
|
1774
|
-
if (!isRecallBoostAblated()) {
|
|
1775
|
-
for (const u of updatedEntries) {
|
|
1776
|
-
const targetRoot = localIndex.entries[u.id]
|
|
1777
|
-
? ctx.hippoRoot
|
|
1778
|
-
: hasGlobal
|
|
1779
|
-
? globalRoot
|
|
1780
|
-
: ctx.hippoRoot;
|
|
1781
|
-
writeEntry(targetRoot, u);
|
|
1782
|
-
}
|
|
1783
|
-
}
|
|
1784
|
-
localIndex.last_retrieval_ids = updatedEntries.map((u) => u.id);
|
|
1785
|
-
// LC1 F1 structural fix (docs/plans/2026-08-02-lc1-recall-trace-persistence.md):
|
|
1786
|
-
// write the trace FIRST — post-limit, post-annotation `selectedItems`
|
|
1787
|
-
// actually returned, on a fresh short-lived connection (the audit
|
|
1788
|
-
// handles above ~2410 are already closed by this point, matching this
|
|
1789
|
-
// block's own per-call-handle convention: writeEntry, saveIndex) — then
|
|
1790
|
-
// fold the resulting id into `localIndex` so the SAME `saveIndex` call
|
|
1791
|
-
// below persists last_retrieval_ids + last_trace_id atomically.
|
|
1792
|
-
// LOCKSTEP INVARIANT: last_trace_id must only ever advance together
|
|
1793
|
-
// with last_retrieval_ids; a two-connection stamp-then-clear design
|
|
1794
|
-
// could desync them on a crash between writes. A failed trace write
|
|
1795
|
-
// (traceId null) sets last_trace_id to null rather than leaving the
|
|
1796
|
-
// OLD id pointing at ids that are about to be overwritten. Fail-soft
|
|
1797
|
-
// internally; never throws.
|
|
1798
|
-
const traceId = writeRecallTraceAtRoot(ctx.hippoRoot, {
|
|
1799
|
-
tenantId: ctx.tenantId,
|
|
1800
|
-
sessionId: opts.currentSessionId || activeSnapshot?.session_id || null,
|
|
1801
|
-
pipeline: 'context',
|
|
1802
|
-
query,
|
|
1803
|
-
explainMode: false,
|
|
1804
|
-
results: selectedItems.map((s) => ({
|
|
1805
|
-
memoryId: s.entry.id,
|
|
1806
|
-
score: s.score,
|
|
1807
|
-
})),
|
|
1808
|
-
});
|
|
1809
|
-
localIndex.last_trace_id = traceId !== null ? String(traceId) : null;
|
|
1810
|
-
saveIndex(ctx.hippoRoot, localIndex);
|
|
1811
|
-
updateStats(ctx.hippoRoot, { recalled: selectedItems.length });
|
|
1812
|
-
// Replace selectedItems entries with markRetrieved-updated copies so
|
|
1813
|
-
// the returned ContextResult reflects post-recall state.
|
|
1814
|
-
selectedItems = selectedItems.map((s) => ({
|
|
1815
|
-
...s,
|
|
1816
|
-
entry: updatedEntries.find((u) => u.id === s.entry.id) ?? s.entry,
|
|
1817
|
-
}));
|
|
1818
|
-
// Overlay by id (no re-read) so avgStrength reflects post-retrieval strength.
|
|
1819
|
-
if (config.ambient.enabled) {
|
|
1820
|
-
const updatedById = new Map(updatedEntries.map((u) => [u.id, u]));
|
|
1821
|
-
const overlaid = [...localEntries, ...globalEntries].map((e) => updatedById.get(e.id) ?? e);
|
|
1822
|
-
if (overlaid.length > 0) {
|
|
1823
|
-
ambientState = computeAmbientState(overlaid);
|
|
1824
|
-
}
|
|
1825
|
-
}
|
|
1826
|
-
}
|
|
1827
|
-
return {
|
|
1828
|
-
entries: selectedItems,
|
|
1829
|
-
tokens: totalTokens,
|
|
1830
|
-
activeSnapshot: activeSnapshot ?? undefined,
|
|
1831
|
-
sessionHandoff: sessionHandoff ?? undefined,
|
|
1832
|
-
recentEvents: recentSessionEvents.length > 0 ? recentSessionEvents : undefined,
|
|
1833
|
-
ambientState,
|
|
1834
|
-
};
|
|
1835
|
-
}
|
|
1836
|
-
const DEFAULT_SLEEP_PHASES = {
|
|
1837
|
-
consolidate,
|
|
1838
|
-
deduplicateStore,
|
|
1839
|
-
auditMemories,
|
|
1840
|
-
autoShare,
|
|
1841
|
-
loadAllEntries,
|
|
1842
|
-
deleteEntry,
|
|
1843
|
-
computeAmbientState,
|
|
1844
|
-
loadConfig,
|
|
1845
|
-
loadPendingExtractionTenants,
|
|
1846
|
-
extractGraph,
|
|
1847
|
-
};
|
|
1848
|
-
export async function sleep(ctx, opts = {}) {
|
|
1849
|
-
const dryRun = Boolean(opts.dryRun);
|
|
1850
|
-
// v1.12.2: resolve phase dependencies, allowing test-only `__phases`
|
|
1851
|
-
// override to inject deterministic throws for mid-phase failure coverage.
|
|
1852
|
-
const phases = { ...DEFAULT_SLEEP_PHASES, ...(opts.__phases ?? {}) };
|
|
1853
|
-
// v1.11.5: phase counters for the consolidate audit emit (in finally).
|
|
1854
|
-
// Accumulated as each phase completes so partial-failure paths still report
|
|
1855
|
-
// accurate "what got done before the failure" data.
|
|
1856
|
-
let consolidationCount = 0;
|
|
1857
|
-
let dedupCount = 0;
|
|
1858
|
-
let auditDeletedCount = 0;
|
|
1859
|
-
let ambientTotal = 0;
|
|
1860
|
-
let phaseError = null;
|
|
1861
|
-
let graphSnapshotError = null;
|
|
1862
|
-
let result = null;
|
|
1863
|
-
try {
|
|
1864
|
-
// Snapshot dirty tenants BEFORE any memory-deleting phase (consolidate /
|
|
1865
|
-
// dedup / audit). The graph_extraction_queue rows are FK'd to mirror
|
|
1866
|
-
// memories with ON DELETE CASCADE, so a phase that deletes a queued mirror
|
|
1867
|
-
// (e.g. dedup removing a near-duplicate superseding decision) would drop the
|
|
1868
|
-
// tenant from a drain-time load and leave its graph stale (codex P1). The
|
|
1869
|
-
// MAX(id) watermark captured here stays valid: arrivals during sleep get a
|
|
1870
|
-
// higher id and remain pending.
|
|
1871
|
-
//
|
|
1872
|
-
// Fail-soft (codex P2): a queue-read failure here must NOT abort core sleep
|
|
1873
|
-
// (consolidation / dedup / audit run regardless). On failure, skip graph
|
|
1874
|
-
// refresh this sleep (recovered next sleep) and surface a detail once
|
|
1875
|
-
// `result` exists (Phase 6).
|
|
1876
|
-
let dirtyTenants = [];
|
|
1877
|
-
if (!dryRun) {
|
|
1878
|
-
try {
|
|
1879
|
-
dirtyTenants = phases.loadPendingExtractionTenants(ctx.hippoRoot);
|
|
1880
|
-
}
|
|
1881
|
-
catch (snapErr) {
|
|
1882
|
-
// SAFETY: this is a best-effort log message only; property access on
|
|
1883
|
-
// any JS value is safe (undefined if absent), preserving the existing
|
|
1884
|
-
// lenient formatting even when something non-Error was thrown.
|
|
1885
|
-
graphSnapshotError = snapErr.message;
|
|
1886
|
-
}
|
|
1887
|
-
}
|
|
1888
|
-
// Phase 1: Consolidation.
|
|
1889
|
-
const consolidateResult = await phases.consolidate(ctx.hippoRoot, { dryRun });
|
|
1890
|
-
consolidationCount = consolidateResult.semanticCreated + consolidateResult.merged;
|
|
1891
|
-
result = {
|
|
1892
|
-
active: consolidateResult.decayed,
|
|
1893
|
-
removed: consolidateResult.removed,
|
|
1894
|
-
mergedEpisodic: consolidateResult.merged,
|
|
1895
|
-
newSemantic: consolidateResult.semanticCreated,
|
|
1896
|
-
dryRun,
|
|
1897
|
-
details: consolidateResult.details,
|
|
1898
|
-
};
|
|
1899
|
-
if (dryRun)
|
|
1900
|
-
return result;
|
|
1901
|
-
// Phase 2: Dedup (post-consolidate near-duplicate cleanup).
|
|
1902
|
-
const dedupResult = phases.deduplicateStore(ctx.hippoRoot);
|
|
1903
|
-
dedupCount = dedupResult.removed;
|
|
1904
|
-
if (dedupResult.removed > 0) {
|
|
1905
|
-
const semDups = dedupResult.pairs.filter((p) => p.keptLayer === 'semantic' && p.removedLayer === 'semantic').length;
|
|
1906
|
-
const epiDups = dedupResult.pairs.filter((p) => p.keptLayer === 'episodic' && p.removedLayer === 'episodic').length;
|
|
1907
|
-
const crossDups = dedupResult.pairs.filter((p) => p.keptLayer !== p.removedLayer).length;
|
|
1908
|
-
result.deduped = {
|
|
1909
|
-
removed: dedupResult.removed,
|
|
1910
|
-
semDups,
|
|
1911
|
-
epiDups,
|
|
1912
|
-
crossDups,
|
|
1913
|
-
};
|
|
1914
|
-
}
|
|
1915
|
-
// Phase 3: Quality audit (remove junk, report warnings).
|
|
1916
|
-
const allEntries = phases.loadAllEntries(ctx.hippoRoot);
|
|
1917
|
-
const auditOut = phases.auditMemories(allEntries);
|
|
1918
|
-
if (auditOut.issues.length > 0) {
|
|
1919
|
-
const errors = auditOut.issues.filter((i) => i.severity === 'error');
|
|
1920
|
-
const warnings = auditOut.issues.filter((i) => i.severity === 'warning');
|
|
1921
|
-
if (errors.length > 0) {
|
|
1922
|
-
for (const issue of errors) {
|
|
1923
|
-
phases.deleteEntry(ctx.hippoRoot, issue.memoryId);
|
|
1924
|
-
}
|
|
1925
|
-
}
|
|
1926
|
-
auditDeletedCount = errors.length;
|
|
1927
|
-
if (errors.length > 0 || warnings.length > 0) {
|
|
1928
|
-
result.audit = {
|
|
1929
|
-
errorsRemoved: errors.length,
|
|
1930
|
-
warningCount: warnings.length,
|
|
1931
|
-
};
|
|
1932
|
-
}
|
|
1933
|
-
}
|
|
1934
|
-
// Phase 4: Auto-share high-transfer-score memories to global.
|
|
1935
|
-
if (!opts.noShare) {
|
|
1936
|
-
const sleepConfig = phases.loadConfig(ctx.hippoRoot);
|
|
1937
|
-
if (sleepConfig.autoShareOnSleep) {
|
|
1938
|
-
// v1.25.0: surface the secret-veto skip count (v39 follow-up #2) so
|
|
1939
|
-
// the veto is observable instead of silent.
|
|
1940
|
-
// AT1: rejectedSkipped is autoShare's sibling counter for candidates
|
|
1941
|
-
// the global store's rejection tombstone refused (threaded the same
|
|
1942
|
-
// way as secretSkipped just below).
|
|
1943
|
-
const autoShareStats = { secretSkipped: 0, rejectedSkipped: 0 };
|
|
1944
|
-
const shared = phases.autoShare(ctx.hippoRoot, { minScore: 0.6, stats: autoShareStats });
|
|
1945
|
-
if (shared.length > 0) {
|
|
1946
|
-
result.shared = shared.length;
|
|
1947
|
-
}
|
|
1948
|
-
if (autoShareStats.secretSkipped > 0) {
|
|
1949
|
-
result.secretSkipped = autoShareStats.secretSkipped;
|
|
1950
|
-
}
|
|
1951
|
-
if (autoShareStats.rejectedSkipped > 0) {
|
|
1952
|
-
result.rejectedSkipped = autoShareStats.rejectedSkipped;
|
|
1953
|
-
}
|
|
1954
|
-
}
|
|
1955
|
-
}
|
|
1956
|
-
// Phase 5: Post-sleep ambient state summary.
|
|
1957
|
-
const postSleepConfig = phases.loadConfig(ctx.hippoRoot);
|
|
1958
|
-
if (postSleepConfig.ambient.enabled) {
|
|
1959
|
-
const postSleepEntries = phases.loadAllEntries(ctx.hippoRoot).filter((e) => !e.superseded_by);
|
|
1960
|
-
if (postSleepEntries.length > 0) {
|
|
1961
|
-
result.ambient = phases.computeAmbientState(postSleepEntries);
|
|
1962
|
-
ambientTotal = result.ambient.totalMemories;
|
|
1963
|
-
}
|
|
1964
|
-
}
|
|
1965
|
-
// Phase 6: Graph extraction drain (E3 sleep enqueue-hook). Rebuild the
|
|
1966
|
-
// entity/relation graph for every tenant marked dirty (by markGraphDirty)
|
|
1967
|
-
// since the last sleep, so `recall --hops` + cross-object `references` edges
|
|
1968
|
-
// run on fresh data without a manual `hippo graph extract`. Fully
|
|
1969
|
-
// fault-isolated: the consolidation work above has already committed, so a
|
|
1970
|
-
// failure here must never abort sleep; a per-tenant extract failure leaves
|
|
1971
|
-
// that tenant's queue items pending for the next sleep. (Skipped under
|
|
1972
|
-
// dryRun via the early return above.)
|
|
1973
|
-
try {
|
|
1974
|
-
if (graphSnapshotError) {
|
|
1975
|
-
// The dirty-tenant snapshot failed (codex P2 fail-soft). Core sleep
|
|
1976
|
-
// already succeeded; surface the skipped graph refresh as a detail.
|
|
1977
|
-
result.details = [
|
|
1978
|
-
...(result.details ?? []),
|
|
1979
|
-
`graph: dirty-tenant snapshot failed (skipped graph refresh): ${graphSnapshotError}`,
|
|
1980
|
-
];
|
|
1981
|
-
}
|
|
1982
|
-
let gTenants = 0;
|
|
1983
|
-
let gEntities = 0;
|
|
1984
|
-
let gRelations = 0;
|
|
1985
|
-
// dirtyTenants was snapshotted before the memory-deleting phases above.
|
|
1986
|
-
for (const { tenantId, maxPendingId } of dirtyTenants) {
|
|
1987
|
-
try {
|
|
1988
|
-
const ext = phases.extractGraph(ctx.hippoRoot, tenantId);
|
|
1989
|
-
// Count the rebuild as soon as it succeeds — it happened regardless of
|
|
1990
|
-
// the drain-mark below.
|
|
1991
|
-
gTenants += 1;
|
|
1992
|
-
gEntities += ext.entities;
|
|
1993
|
-
gRelations += ext.relations;
|
|
1994
|
-
// Watermark drain: mark processed only items enqueued before this
|
|
1995
|
-
// rebuild started (id <= maxPendingId). Arrivals during the rebuild
|
|
1996
|
-
// keep pending status and are caught next sleep; rows whose mirror was
|
|
1997
|
-
// cascade-deleted earlier this sleep are already gone (no-op).
|
|
1998
|
-
markPendingProcessedUpTo(ctx.hippoRoot, tenantId, maxPendingId);
|
|
1999
|
-
}
|
|
2000
|
-
catch (tenantErr) {
|
|
2001
|
-
// SAFETY: this is a best-effort log message only; property access
|
|
2002
|
-
// on any JS value is safe (undefined if absent), preserving the
|
|
2003
|
-
// existing lenient formatting even when something non-Error was thrown.
|
|
2004
|
-
result.details = [
|
|
2005
|
-
...(result.details ?? []),
|
|
2006
|
-
`graph: extract failed for a dirty tenant (left pending): ${tenantErr.message}`,
|
|
2007
|
-
];
|
|
2008
|
-
}
|
|
2009
|
-
}
|
|
2010
|
-
if (gTenants > 0) {
|
|
2011
|
-
result.graph = { tenants: gTenants, entities: gEntities, relations: gRelations };
|
|
2012
|
-
}
|
|
2013
|
-
}
|
|
2014
|
-
catch (graphErr) {
|
|
2015
|
-
// SAFETY: this is a best-effort log message only; property access on
|
|
2016
|
-
// any JS value is safe (undefined if absent), preserving the existing
|
|
2017
|
-
// lenient formatting even when something non-Error was thrown.
|
|
2018
|
-
result.details = [
|
|
2019
|
-
...(result.details ?? []),
|
|
2020
|
-
`graph: drain phase failed (skipped): ${graphErr.message}`,
|
|
2021
|
-
];
|
|
2022
|
-
}
|
|
2023
|
-
return result;
|
|
2024
|
-
}
|
|
2025
|
-
catch (err) {
|
|
2026
|
-
// SAFETY: phaseError is read via phaseError.message / (phaseError !==
|
|
2027
|
-
// null) below, both safe even if a non-Error was thrown; this mirrors
|
|
2028
|
-
// the existing lenient (err as Error) pattern used throughout this catch chain.
|
|
2029
|
-
phaseError = err;
|
|
2030
|
-
throw err;
|
|
2031
|
-
}
|
|
2032
|
-
finally {
|
|
2033
|
-
// v1.11.5: emit one 'consolidate' audit_log row per api.sleep invocation,
|
|
2034
|
-
// with phase counters in metadata. Closes the CLI/MCP parity gap that T6
|
|
2035
|
-
// fixed for cmdOutcome (Episode A follow-up). In finally so partial-failure
|
|
2036
|
-
// paths still emit; `partial: true` + errorMessage flag the failure.
|
|
2037
|
-
// Dedicated handle for this emit only (phase helpers above each open their
|
|
2038
|
-
// own handle via hippoRoot — SQLite single-writer makes parallel handles
|
|
2039
|
-
// safe for the read-heavy phases).
|
|
2040
|
-
//
|
|
2041
|
-
// TODO(v1.12.0 + A5 v2): the audit row is tagged with ctx.tenantId but
|
|
2042
|
-
// api.sleep is host-wide (cross-tenant dedup is intentional). When
|
|
2043
|
-
// /v1/sleep moves off loopback-only, either tag with a synthetic "host"
|
|
2044
|
-
// tenant or scope api.sleep per-tenant. Independent-review-critic flag,
|
|
2045
|
-
// v1.11.5 ship.
|
|
2046
|
-
//
|
|
2047
|
-
// Error preservation: if openHippoDb or appendAuditEvent throws here, we
|
|
2048
|
-
// do NOT let it replace the original phaseError (independent-review HIGH:
|
|
2049
|
-
// would mask the underlying consolidation failure). Audit emit failure
|
|
2050
|
-
// is logged to stderr but the original throw wins.
|
|
2051
|
-
try {
|
|
2052
|
-
const db = openHippoDb(ctx.hippoRoot);
|
|
2053
|
-
try {
|
|
2054
|
-
const sleepAuditMetadata = {
|
|
2055
|
-
consolidationCount,
|
|
2056
|
-
dedupCount,
|
|
2057
|
-
auditDeletedCount,
|
|
2058
|
-
ambientTotal,
|
|
2059
|
-
dryRun,
|
|
2060
|
-
noShare: opts.noShare ?? false,
|
|
2061
|
-
partial: phaseError !== null,
|
|
2062
|
-
triggeredByTenant: ctx.tenantId, // preserve for audit forensics
|
|
2063
|
-
};
|
|
2064
|
-
if (phaseError)
|
|
2065
|
-
sleepAuditMetadata.errorMessage = phaseError.message;
|
|
2066
|
-
appendAuditEvent(db, {
|
|
2067
|
-
tenantId: '__host__',
|
|
2068
|
-
actor: ctx.actor.subject,
|
|
2069
|
-
op: 'consolidate',
|
|
2070
|
-
metadata: { ...sleepAuditMetadata },
|
|
2071
|
-
});
|
|
2072
|
-
}
|
|
2073
|
-
finally {
|
|
2074
|
-
closeHippoDb(db);
|
|
2075
|
-
}
|
|
2076
|
-
}
|
|
2077
|
-
catch (auditErr) {
|
|
2078
|
-
// Audit emit failure must NOT mask the original phaseError. Log to
|
|
2079
|
-
// stderr so the secondary failure is observable but does not throw.
|
|
2080
|
-
// This guards the case where consolidation AND audit-emit fail in the
|
|
2081
|
-
// same invocation against the same DB (correlated: same disk, same
|
|
2082
|
-
// schema state) — losing the original error makes diagnosis much harder.
|
|
2083
|
-
// SAFETY: this is a best-effort log message only; property access on
|
|
2084
|
-
// any JS value is safe (undefined if absent), preserving the existing
|
|
2085
|
-
// lenient formatting even when something non-Error was thrown.
|
|
2086
|
-
// eslint-disable-next-line no-console
|
|
2087
|
-
console.error(`[hippo] api.sleep audit emit failed: ${auditErr.message}`);
|
|
2088
|
-
}
|
|
2089
|
-
}
|
|
2090
|
-
}
|
|
2091
|
-
export function outcomeForLastRecall(ctx, good) {
|
|
2092
|
-
const idx = loadIndex(ctx.hippoRoot);
|
|
2093
|
-
const ids = idx.last_retrieval_ids;
|
|
2094
|
-
if (ids.length === 0)
|
|
2095
|
-
return { applied: 0, ids: [] };
|
|
2096
|
-
// LC1 F1(d) structural fix (docs/plans/2026-08-02-lc1-recall-trace-persistence.md):
|
|
2097
|
-
// read the trace id from the SAME `loadIndex` snapshot already in hand
|
|
2098
|
-
// (idx.last_trace_id) — a single-snapshot read, not a second DB round
|
|
2099
|
-
// trip via a now-deleted readLastTraceId helper. The value is already
|
|
2100
|
-
// strict-parsed by buildIndexFromDb's parseLastTraceId (store.ts): every
|
|
2101
|
-
// consumer gets a clean positive-integer string or null, never a garbage
|
|
2102
|
-
// value that could reach outcome() and INSERT trace_id=0/NaN. null on a
|
|
2103
|
-
// fresh store / pre-v40 flow / api.recall-only usage — outcome() skips
|
|
2104
|
-
// linkage silently when traceId is undefined.
|
|
2105
|
-
const traceId = idx.last_trace_id !== null ? Number(idx.last_trace_id) : null;
|
|
2106
|
-
const { applied, appliedIds } = outcome(ctx, ids, good, traceId !== null ? { traceId } : undefined);
|
|
2107
|
-
return { applied, ids: appliedIds };
|
|
2108
|
-
}
|
|
2109
|
-
//# sourceMappingURL=api.js.map
|