hippo-memory 1.60.0 → 1.61.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 +1 -1
- package/dist/ablation.js +9 -27
- package/dist/agent-memories/apply.js +4 -1
- package/dist/agent-memories/claude-code.js +1 -1
- package/dist/agent-memories/codex.js +1 -1
- package/dist/agent-memories/gemini.js +1 -1
- package/dist/agent-memories/legacy.js +1 -1
- package/dist/agent-memories/source.js +1 -1
- package/dist/agent-memories/sync.js +8 -3
- package/dist/ambient-store.d.ts +14 -0
- package/dist/ambient-store.js +90 -0
- package/dist/ambient.d.ts +23 -0
- package/dist/ambient.js +72 -50
- package/dist/api/assemble.d.ts +93 -0
- package/dist/api/assemble.js +152 -0
- package/dist/api/audit.d.ts +17 -0
- package/dist/api/audit.js +23 -0
- package/dist/api/auth.d.ts +79 -0
- package/dist/api/auth.js +178 -0
- package/dist/api/context-types.d.ts +105 -0
- package/dist/api/context-types.js +3 -0
- package/dist/api/context.d.ts +31 -0
- package/dist/api/context.js +705 -0
- package/dist/api/dormant.d.ts +30 -0
- package/dist/api/dormant.js +140 -0
- package/dist/api/drill-down.d.ts +84 -0
- package/dist/api/drill-down.js +123 -0
- package/dist/api/forget.d.ts +57 -0
- package/dist/api/forget.js +87 -0
- package/dist/api/goals.d.ts +18 -0
- package/dist/api/goals.js +33 -0
- package/dist/api/learn.d.ts +31 -0
- package/dist/api/learn.js +88 -0
- package/dist/api/outcome.d.ts +61 -0
- package/dist/api/outcome.js +66 -0
- package/dist/api/promote.d.ts +54 -0
- package/dist/api/promote.js +203 -0
- package/dist/api/quarantine.d.ts +24 -0
- package/dist/api/quarantine.js +121 -0
- package/dist/api/recall-types.d.ts +390 -0
- package/dist/api/recall-types.js +3 -0
- package/dist/api/recall.d.ts +36 -0
- package/dist/api/recall.js +634 -0
- package/dist/api/remember.d.ts +35 -0
- package/dist/api/remember.js +43 -0
- package/dist/api/sleep.d.ts +136 -0
- package/dist/api/sleep.js +271 -0
- package/dist/api/tokens.d.ts +26 -0
- package/dist/api/tokens.js +60 -0
- package/dist/api/types.d.ts +56 -0
- package/dist/api/types.js +38 -0
- package/dist/api.d.ts +20 -1262
- package/dist/api.js +25 -2727
- package/dist/audit.d.ts +3 -0
- package/dist/audit.js +6 -3
- package/dist/auth.d.ts +45 -4
- package/dist/auth.js +125 -48
- package/dist/autolearn.js +2 -2
- package/dist/capture/command.d.ts +33 -0
- package/dist/capture/command.js +264 -0
- package/dist/capture/compact.d.ts +44 -0
- package/dist/capture/compact.js +354 -0
- package/dist/capture/extract.d.ts +21 -0
- package/dist/capture/extract.js +464 -0
- package/dist/capture/transcript.d.ts +40 -0
- package/dist/capture/transcript.js +193 -0
- package/dist/capture-error.js +2 -1
- package/dist/churn-git.js +4 -2
- package/dist/cli/audit.d.ts +3 -0
- package/dist/cli/audit.js +159 -0
- package/dist/cli/auth.d.ts +2 -0
- package/dist/cli/auth.js +171 -0
- package/dist/cli/briefs.d.ts +4 -0
- package/dist/cli/briefs.js +435 -0
- package/dist/cli/card.d.ts +3 -0
- package/dist/cli/card.js +333 -0
- package/dist/cli/context.d.ts +15 -0
- package/dist/cli/context.js +366 -0
- package/dist/cli/continuity.d.ts +6 -0
- package/dist/cli/continuity.js +445 -0
- package/dist/cli/curate.d.ts +19 -0
- package/dist/cli/curate.js +555 -0
- package/dist/cli/dag.d.ts +5 -0
- package/dist/cli/dag.js +177 -0
- package/dist/cli/decisions.d.ts +4 -0
- package/dist/cli/decisions.js +528 -0
- package/dist/cli/eval.d.ts +5 -0
- package/dist/cli/eval.js +213 -0
- package/dist/cli/explain.d.ts +4 -0
- package/dist/cli/explain.js +150 -0
- package/dist/cli/goals.d.ts +2 -0
- package/dist/cli/goals.js +196 -0
- package/dist/cli/hook-blocks.d.ts +18 -0
- package/dist/cli/hook-blocks.js +233 -0
- package/dist/cli/init.d.ts +2 -0
- package/dist/cli/init.js +305 -0
- package/dist/cli/maintenance.d.ts +4 -0
- package/dist/cli/maintenance.js +179 -0
- package/dist/cli/playbooks.d.ts +4 -0
- package/dist/cli/playbooks.js +556 -0
- package/dist/cli/projects.js +1 -1
- package/dist/cli/recall.d.ts +7 -0
- package/dist/cli/recall.js +597 -0
- package/dist/cli/remember.d.ts +5 -0
- package/dist/cli/remember.js +442 -0
- package/dist/cli/serve.d.ts +5 -0
- package/dist/cli/serve.js +40 -0
- package/dist/cli/session-hooks.d.ts +29 -0
- package/dist/cli/session-hooks.js +637 -0
- package/dist/cli/setup.d.ts +4 -0
- package/dist/cli/setup.js +376 -0
- package/dist/cli/shared.d.ts +15 -19
- package/dist/cli/shared.js +50 -341
- package/dist/cli/slack.d.ts +2 -0
- package/dist/cli/slack.js +171 -0
- package/dist/cli/status.d.ts +18 -0
- package/dist/cli/status.js +400 -0
- package/dist/cli/transfer.d.ts +10 -0
- package/dist/cli/transfer.js +438 -0
- package/dist/cli/usage.d.ts +85 -0
- package/dist/cli/usage.js +741 -0
- package/dist/cli.d.ts +71 -120
- package/dist/cli.js +170 -8469
- package/dist/client.js +15 -8
- package/dist/compaction-record.js +6 -4
- package/dist/connectors/github/backfill.js +94 -87
- package/dist/connectors/github/cli-impl.js +4 -3
- package/dist/connectors/github/dlq.js +67 -54
- package/dist/connectors/github/ingest.js +34 -35
- package/dist/connectors/github/tenant-routing.js +3 -2
- package/dist/connectors/github/webhook.js +135 -216
- package/dist/connectors/slack/dlq.js +49 -61
- package/dist/connectors/slack/ingest.js +55 -48
- package/dist/connectors/slack/tenant-routing.js +4 -3
- package/dist/connectors/slack/webhook.js +72 -74
- package/dist/consolidate/conflicts.d.ts +10 -0
- package/dist/consolidate/conflicts.js +178 -0
- package/dist/consolidate/decay.d.ts +12 -0
- package/dist/consolidate/decay.js +145 -0
- package/dist/consolidate/llm-passes.d.ts +3 -0
- package/dist/consolidate/llm-passes.js +141 -0
- package/dist/consolidate/merge.d.ts +7 -0
- package/dist/consolidate/merge.js +251 -0
- package/dist/consolidate/physics-pass.d.ts +3 -0
- package/dist/consolidate/physics-pass.js +60 -0
- package/dist/consolidate/run.d.ts +69 -0
- package/dist/consolidate/run.js +76 -0
- package/dist/consolidate/sleep.d.ts +18 -0
- package/dist/consolidate/sleep.js +209 -0
- package/dist/consolidate/traces.d.ts +4 -0
- package/dist/consolidate/traces.js +178 -0
- package/dist/context-auto.js +7 -11
- package/dist/context-render.d.ts +1 -1
- package/dist/context-render.js +1 -1
- package/dist/customer-notes.d.ts +3 -0
- package/dist/customer-notes.js +6 -4
- package/dist/dag.js +6 -5
- package/dist/dashboard-actions.d.ts +20 -0
- package/dist/dashboard-actions.js +88 -0
- package/dist/dashboard-params.d.ts +45 -0
- package/dist/dashboard-params.js +127 -0
- package/dist/dashboard-queries.d.ts +15 -0
- package/dist/dashboard-queries.js +355 -0
- package/dist/dashboard-snapshot.d.ts +138 -0
- package/dist/dashboard-snapshot.js +308 -0
- package/dist/dashboard-types.d.ts +163 -0
- package/dist/dashboard-types.js +3 -0
- package/dist/dashboard.d.ts +6 -7
- package/dist/dashboard.js +228 -202
- package/dist/db/busy.d.ts +5 -0
- package/dist/db/busy.js +24 -0
- package/dist/db/continuity.d.ts +5 -0
- package/dist/db/continuity.js +145 -0
- package/dist/db/meta.d.ts +8 -0
- package/dist/db/meta.js +35 -0
- package/dist/db/migrate.d.ts +9 -0
- package/dist/db/migrate.js +138 -0
- package/dist/db/migrations/index.d.ts +5 -0
- package/dist/db/migrations/index.js +109 -0
- package/dist/db/migrations/types.d.ts +14 -0
- package/dist/db/migrations/types.js +2 -0
- package/dist/db/migrations/v01.d.ts +3 -0
- package/dist/db/migrations/v01.js +38 -0
- package/dist/db/migrations/v02.d.ts +3 -0
- package/dist/db/migrations/v02.js +21 -0
- package/dist/db/migrations/v03.d.ts +3 -0
- package/dist/db/migrations/v03.js +22 -0
- package/dist/db/migrations/v04.d.ts +3 -0
- package/dist/db/migrations/v04.js +28 -0
- package/dist/db/migrations/v05.d.ts +3 -0
- package/dist/db/migrations/v05.js +21 -0
- package/dist/db/migrations/v06.d.ts +3 -0
- package/dist/db/migrations/v06.js +25 -0
- package/dist/db/migrations/v07.d.ts +3 -0
- package/dist/db/migrations/v07.js +13 -0
- package/dist/db/migrations/v08.d.ts +3 -0
- package/dist/db/migrations/v08.js +8 -0
- package/dist/db/migrations/v09.d.ts +3 -0
- package/dist/db/migrations/v09.js +13 -0
- package/dist/db/migrations/v10.d.ts +3 -0
- package/dist/db/migrations/v10.js +17 -0
- package/dist/db/migrations/v11.d.ts +3 -0
- package/dist/db/migrations/v11.js +15 -0
- package/dist/db/migrations/v12.d.ts +3 -0
- package/dist/db/migrations/v12.js +11 -0
- package/dist/db/migrations/v13.d.ts +3 -0
- package/dist/db/migrations/v13.js +15 -0
- package/dist/db/migrations/v14.d.ts +3 -0
- package/dist/db/migrations/v14.js +66 -0
- package/dist/db/migrations/v15.d.ts +3 -0
- package/dist/db/migrations/v15.js +42 -0
- package/dist/db/migrations/v16.d.ts +3 -0
- package/dist/db/migrations/v16.js +61 -0
- package/dist/db/migrations/v17.d.ts +3 -0
- package/dist/db/migrations/v17.js +46 -0
- package/dist/db/migrations/v18.d.ts +3 -0
- package/dist/db/migrations/v18.js +60 -0
- package/dist/db/migrations/v19.d.ts +3 -0
- package/dist/db/migrations/v19.js +28 -0
- package/dist/db/migrations/v20.d.ts +3 -0
- package/dist/db/migrations/v20.js +42 -0
- package/dist/db/migrations/v21.d.ts +3 -0
- package/dist/db/migrations/v21.js +16 -0
- package/dist/db/migrations/v22.d.ts +3 -0
- package/dist/db/migrations/v22.js +82 -0
- package/dist/db/migrations/v23.d.ts +3 -0
- package/dist/db/migrations/v23.js +48 -0
- package/dist/db/migrations/v24.d.ts +3 -0
- package/dist/db/migrations/v24.js +72 -0
- package/dist/db/migrations/v25.d.ts +3 -0
- package/dist/db/migrations/v25.js +46 -0
- package/dist/db/migrations/v26.d.ts +3 -0
- package/dist/db/migrations/v26.js +23 -0
- package/dist/db/migrations/v27.d.ts +3 -0
- package/dist/db/migrations/v27.js +57 -0
- package/dist/db/migrations/v28.d.ts +3 -0
- package/dist/db/migrations/v28.js +38 -0
- package/dist/db/migrations/v29.d.ts +3 -0
- package/dist/db/migrations/v29.js +78 -0
- package/dist/db/migrations/v30.d.ts +3 -0
- package/dist/db/migrations/v30.js +92 -0
- package/dist/db/migrations/v31.d.ts +3 -0
- package/dist/db/migrations/v31.js +74 -0
- package/dist/db/migrations/v32.d.ts +3 -0
- package/dist/db/migrations/v32.js +102 -0
- package/dist/db/migrations/v33.d.ts +3 -0
- package/dist/db/migrations/v33.js +104 -0
- package/dist/db/migrations/v34.d.ts +3 -0
- package/dist/db/migrations/v34.js +93 -0
- package/dist/db/migrations/v35.d.ts +3 -0
- package/dist/db/migrations/v35.js +98 -0
- package/dist/db/migrations/v36.d.ts +3 -0
- package/dist/db/migrations/v36.js +98 -0
- package/dist/db/migrations/v37.d.ts +3 -0
- package/dist/db/migrations/v37.js +219 -0
- package/dist/db/migrations/v38.d.ts +3 -0
- package/dist/db/migrations/v38.js +277 -0
- package/dist/db/migrations/v39.d.ts +3 -0
- package/dist/db/migrations/v39.js +59 -0
- package/dist/db/migrations/v40.d.ts +3 -0
- package/dist/db/migrations/v40.js +74 -0
- package/dist/db/migrations/v41.d.ts +3 -0
- package/dist/db/migrations/v41.js +43 -0
- package/dist/db/migrations/v42.d.ts +3 -0
- package/dist/db/migrations/v42.js +41 -0
- package/dist/db/migrations/v43.d.ts +3 -0
- package/dist/db/migrations/v43.js +67 -0
- package/dist/db/migrations/v44.d.ts +3 -0
- package/dist/db/migrations/v44.js +28 -0
- package/dist/db/migrations/v45.d.ts +3 -0
- package/dist/db/migrations/v45.js +30 -0
- package/dist/db/migrations/v46.d.ts +3 -0
- package/dist/db/migrations/v46.js +25 -0
- package/dist/db/migrations/v47.d.ts +3 -0
- package/dist/db/migrations/v47.js +17 -0
- package/dist/db/migrations/v48.d.ts +3 -0
- package/dist/db/migrations/v48.js +10 -0
- package/dist/db/migrations/v49.d.ts +3 -0
- package/dist/db/migrations/v49.js +31 -0
- package/dist/db/migrations/v50.d.ts +3 -0
- package/dist/db/migrations/v50.js +67 -0
- package/dist/db/migrations/v51.d.ts +3 -0
- package/dist/db/migrations/v51.js +14 -0
- package/dist/db/migrations/v52.d.ts +3 -0
- package/dist/db/migrations/v52.js +7 -0
- package/dist/db/open.d.ts +23 -0
- package/dist/db/open.js +146 -0
- package/dist/db/sqlite.d.ts +21 -0
- package/dist/db/sqlite.js +8 -0
- package/dist/db/tables.d.ts +7 -0
- package/dist/db/tables.js +38 -0
- package/dist/db.d.ts +6 -46
- package/dist/db.js +5 -3049
- package/dist/decisions.d.ts +4 -1
- package/dist/decisions.js +9 -7
- package/dist/dedupe.js +3 -2
- package/dist/delivery-recorder.js +4 -1
- package/dist/doctor.js +3 -3
- package/dist/embedding-provider.d.ts +1 -1
- package/dist/embedding-provider.js +4 -3
- package/dist/embeddings.d.ts +9 -52
- package/dist/embeddings.js +43 -297
- package/dist/env.d.ts +75 -0
- package/dist/env.js +119 -0
- package/dist/eval-suite.js +1 -1
- package/dist/eval.js +2 -2
- package/dist/extract.js +1 -1
- package/dist/gated-write.js +3 -1
- package/dist/goals.d.ts +3 -1
- package/dist/goals.js +18 -0
- package/dist/graph/read.d.ts +73 -0
- package/dist/graph/read.js +325 -0
- package/dist/graph/rows.d.ts +45 -0
- package/dist/graph/rows.js +51 -0
- package/dist/graph/types.d.ts +83 -0
- package/dist/graph/types.js +11 -0
- package/dist/graph/write.d.ts +93 -0
- package/dist/{graph.js → graph/write.js} +6 -384
- package/dist/graph-extract.js +2 -1
- package/dist/graph-recall.d.ts +1 -1
- package/dist/graph-recall.js +2 -2
- package/dist/graph-stream.js +1 -1
- package/dist/graph-view.d.ts +1 -1
- package/dist/graph-view.js +1 -1
- package/dist/half-life-migration.d.ts +1 -1
- package/dist/half-life-migration.js +2 -1
- package/dist/hooks/codex-session.d.ts +8 -0
- package/dist/hooks/codex-session.js +76 -0
- package/dist/hooks/codex-wrapper.d.ts +55 -0
- package/dist/hooks/codex-wrapper.js +288 -0
- package/dist/hooks/json-hooks.d.ts +63 -0
- package/dist/hooks/json-hooks.js +356 -0
- package/dist/hooks/opencode.d.ts +50 -0
- package/dist/hooks/opencode.js +202 -0
- package/dist/hooks/shared.d.ts +54 -0
- package/dist/hooks/shared.js +77 -0
- package/dist/http-retry.d.ts +2 -0
- package/dist/http-retry.js +4 -3
- package/dist/http-util.d.ts +3 -0
- package/dist/http-util.js +10 -0
- package/dist/{importers.d.ts → importers/core.d.ts} +13 -17
- package/dist/importers/core.js +141 -0
- package/dist/importers/markdown-parse.d.ts +41 -0
- package/dist/importers/markdown-parse.js +132 -0
- package/dist/importers/markdown.d.ts +3 -0
- package/dist/importers/markdown.js +92 -0
- package/dist/importers/sources.d.ts +7 -0
- package/dist/importers/sources.js +229 -0
- package/dist/importers/vault.d.ts +11 -0
- package/dist/importers/vault.js +352 -0
- package/dist/incidents.d.ts +3 -0
- package/dist/incidents.js +7 -5
- package/dist/index.d.ts +25 -6
- package/dist/index.js +23 -6
- package/dist/invalidation.js +2 -1
- package/dist/judgment.js +2 -1
- package/dist/keyset.d.ts +13 -0
- package/dist/keyset.js +8 -0
- package/dist/local-embedding.d.ts +13 -0
- package/dist/local-embedding.js +165 -0
- package/dist/log.d.ts +7 -0
- package/dist/log.js +19 -1
- package/dist/mcp/admin-tools.d.ts +8 -0
- package/dist/mcp/admin-tools.js +116 -0
- package/dist/mcp/format.d.ts +27 -0
- package/dist/mcp/format.js +135 -0
- package/dist/mcp/memory-tools.d.ts +5 -0
- package/dist/mcp/memory-tools.js +83 -0
- package/dist/mcp/protocol.d.ts +83 -0
- package/dist/mcp/protocol.js +55 -0
- package/dist/mcp/recall-tools.d.ts +6 -0
- package/dist/mcp/recall-tools.js +320 -0
- package/dist/mcp/request.d.ts +10 -0
- package/dist/mcp/request.js +163 -0
- package/dist/mcp/server.d.ts +4 -75
- package/dist/mcp/server.js +7 -1190
- package/dist/mcp/session-state.d.ts +11 -0
- package/dist/mcp/session-state.js +28 -0
- package/dist/mcp/stdio.d.ts +8 -0
- package/dist/mcp/stdio.js +79 -0
- package/dist/mcp/tools.d.ts +11 -0
- package/dist/mcp/tools.js +247 -0
- package/dist/memory.d.ts +3 -0
- package/dist/memory.js +27 -1
- package/dist/multihop.d.ts +1 -1
- package/dist/multihop.js +2 -1
- package/dist/owner-validation.js +2 -1
- package/dist/physics-state.js +10 -7
- package/dist/policies.d.ts +3 -0
- package/dist/policies.js +8 -6
- package/dist/postinstall.js +3 -2
- package/dist/predictions/planning-fallacy.d.ts +100 -0
- package/dist/predictions/planning-fallacy.js +190 -0
- package/dist/{predictions.d.ts → predictions/store.d.ts} +7 -102
- package/dist/predictions/store.js +434 -0
- package/dist/processes.d.ts +3 -0
- package/dist/processes.js +7 -5
- package/dist/project-briefs.d.ts +3 -0
- package/dist/project-briefs.js +7 -5
- package/dist/project-identity.js +3 -2
- package/dist/project-merge.js +3 -1
- package/dist/quarantine.d.ts +2 -1
- package/dist/quarantine.js +8 -5
- package/dist/raw-archive.js +1 -1
- package/dist/recall-history.js +3 -2
- package/dist/recall-pipeline.d.ts +2 -2
- package/dist/recall-pipeline.js +16 -8
- package/dist/recall-scope.js +1 -1
- package/dist/recall-trace.d.ts +1 -1
- package/dist/refine-llm.js +2 -1
- package/dist/reject-flow.js +6 -1
- package/dist/rerankers/clef.js +6 -5
- package/dist/rerankers/jev.d.ts +1 -1
- package/dist/rerankers/jev.js +4 -4
- package/dist/rerankers/llm.d.ts +4 -2
- package/dist/rerankers/llm.js +58 -38
- package/dist/rerankers/types.d.ts +1 -1
- package/dist/salience.js +1 -1
- package/dist/scheduler.d.ts +1 -0
- package/dist/scheduler.js +26 -4
- package/dist/scope.js +4 -3
- package/dist/search/as-of.d.ts +10 -0
- package/dist/search/as-of.js +22 -0
- package/dist/search/bm25-search.d.ts +14 -0
- package/dist/search/bm25-search.js +43 -0
- package/dist/search/bm25.d.ts +15 -0
- package/dist/search/bm25.js +54 -0
- package/dist/search/boosts.d.ts +54 -0
- package/dist/search/boosts.js +94 -0
- package/dist/search/breakdown.d.ts +7 -0
- package/dist/search/breakdown.js +20 -0
- package/dist/search/explain.d.ts +25 -0
- package/dist/search/explain.js +31 -0
- package/dist/search/finalize.d.ts +8 -0
- package/dist/search/finalize.js +52 -0
- package/dist/search/fusion.d.ts +27 -0
- package/dist/search/fusion.js +42 -0
- package/dist/search/hybrid-score.d.ts +20 -0
- package/dist/search/hybrid-score.js +73 -0
- package/dist/search/hybrid.d.ts +46 -0
- package/dist/search/hybrid.js +64 -0
- package/dist/search/physics-search.d.ts +29 -0
- package/dist/search/physics-search.js +162 -0
- package/dist/search/rerank.d.ts +10 -0
- package/dist/search/rerank.js +72 -0
- package/dist/search/temporal.d.ts +15 -0
- package/dist/search/temporal.js +45 -0
- package/dist/search/types.d.ts +91 -0
- package/dist/search/types.js +2 -0
- package/dist/search/vector.d.ts +30 -0
- package/dist/search/vector.js +71 -0
- package/dist/secret-detect.d.ts +2 -0
- package/dist/secret-detect.js +2 -1
- package/dist/server/auth.d.ts +52 -0
- package/dist/server/auth.js +221 -0
- package/dist/server/client-ip.d.ts +25 -0
- package/dist/server/client-ip.js +92 -0
- package/dist/server/cursor.d.ts +23 -0
- package/dist/server/cursor.js +58 -0
- package/dist/server/lifecycle.d.ts +7 -0
- package/dist/server/lifecycle.js +29 -0
- package/dist/server/mcp-http.d.ts +5 -0
- package/dist/server/mcp-http.js +199 -0
- package/dist/server/request.d.ts +33 -0
- package/dist/server/request.js +103 -0
- package/dist/server/routes/admin.d.ts +9 -0
- package/dist/server/routes/admin.js +158 -0
- package/dist/server/routes/customer-notes.d.ts +7 -0
- package/dist/server/routes/customer-notes.js +112 -0
- package/dist/server/routes/decisions.d.ts +7 -0
- package/dist/server/routes/decisions.js +133 -0
- package/dist/server/routes/incidents.d.ts +7 -0
- package/dist/server/routes/incidents.js +126 -0
- package/dist/server/routes/memories.d.ts +10 -0
- package/dist/server/routes/memories.js +179 -0
- package/dist/server/routes/policies.d.ts +8 -0
- package/dist/server/routes/policies.js +151 -0
- package/dist/server/routes/predictions.d.ts +7 -0
- package/dist/server/routes/predictions.js +164 -0
- package/dist/server/routes/processes.d.ts +7 -0
- package/dist/server/routes/processes.js +161 -0
- package/dist/server/routes/project-briefs.d.ts +8 -0
- package/dist/server/routes/project-briefs.js +136 -0
- package/dist/server/routes/recall.d.ts +8 -0
- package/dist/server/routes/recall.js +340 -0
- package/dist/server/routes/skills.d.ts +8 -0
- package/dist/server/routes/skills.js +150 -0
- package/dist/server/types.d.ts +56 -0
- package/dist/server/types.js +2 -0
- package/dist/server/validation.d.ts +14 -0
- package/dist/server/validation.js +91 -0
- package/dist/server.d.ts +7 -61
- package/dist/server.js +69 -2362
- package/dist/session-digest.d.ts +1 -1
- package/dist/session-digest.js +9 -2
- package/dist/shared.d.ts +3 -1
- package/dist/shared.js +26 -22
- package/dist/skills.d.ts +3 -0
- package/dist/skills.js +7 -5
- package/dist/stdin.js +2 -2
- package/dist/store/audit-event.d.ts +19 -0
- package/dist/store/audit-event.js +33 -0
- package/dist/store/candidates.d.ts +42 -0
- package/dist/store/candidates.js +152 -0
- package/dist/store/conflicts.d.ts +44 -0
- package/dist/store/conflicts.js +444 -0
- package/dist/store/delete-and-batch.d.ts +63 -0
- package/dist/store/delete-and-batch.js +310 -0
- package/dist/store/entry-reads.d.ts +92 -0
- package/dist/store/entry-reads.js +255 -0
- package/dist/store/entry-row.d.ts +67 -0
- package/dist/store/entry-row.js +208 -0
- package/dist/store/entry-writes.d.ts +46 -0
- package/dist/store/entry-writes.js +148 -0
- package/dist/store/handoffs.d.ts +26 -0
- package/dist/store/handoffs.js +184 -0
- package/dist/store/index-and-stats.d.ts +52 -0
- package/dist/store/index-and-stats.js +214 -0
- package/dist/store/markdown.d.ts +10 -0
- package/dist/store/markdown.js +108 -0
- package/dist/store/mirrors.d.ts +49 -0
- package/dist/store/mirrors.js +312 -0
- package/dist/store/open.d.ts +15 -0
- package/dist/store/open.js +223 -0
- package/dist/store/rows.d.ts +178 -0
- package/dist/store/rows.js +158 -0
- package/dist/store/search-rows.d.ts +86 -0
- package/dist/store/search-rows.js +254 -0
- package/dist/store/sessions.d.ts +83 -0
- package/dist/store/sessions.js +272 -0
- package/dist/store/summaries.d.ts +94 -0
- package/dist/store/summaries.js +377 -0
- package/dist/store/tenant-lookup.d.ts +12 -0
- package/dist/store/tenant-lookup.js +16 -0
- package/dist/store-cards.js +2 -1
- package/dist/summary-dirty.d.ts +4 -0
- package/dist/summary-dirty.js +32 -0
- package/dist/support-bundle.js +4 -3
- package/dist/tenant.js +2 -2
- package/dist/tokenize.d.ts +2 -0
- package/dist/tokenize.js +16 -0
- package/dist/transcript-tail.d.ts +7 -0
- package/dist/transcript-tail.js +48 -0
- package/dist/vector-store.d.ts +27 -0
- package/dist/vector-store.js +210 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.js +2 -2
- package/dist/working-memory.js +1 -1
- package/dist/yaml.js +36 -11
- package/dist-ui/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- package/dist-ui/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/dist-ui/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
- package/dist-ui/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- package/dist-ui/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
- package/dist-ui/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
- package/dist-ui/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
- package/dist-ui/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
- package/dist-ui/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
- package/dist-ui/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
- package/dist-ui/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
- package/dist-ui/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
- package/dist-ui/assets/index-DPN7cP19.js +33 -0
- package/dist-ui/assets/index-dFloKRVr.css +1 -0
- package/dist-ui/index.html +3 -25
- 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 +1 -1
- package/dist/capture.d.ts +0 -155
- package/dist/capture.js +0 -1295
- package/dist/consolidate.d.ts +0 -58
- package/dist/consolidate.js +0 -1124
- package/dist/graph.d.ts +0 -245
- package/dist/hooks.d.ts +0 -208
- package/dist/hooks.js +0 -1076
- package/dist/importers.js +0 -900
- package/dist/predictions.js +0 -620
- package/dist/search.d.ts +0 -320
- package/dist/search.js +0 -970
- package/dist/store.d.ts +0 -776
- package/dist/store.js +0 -3473
- package/dist-ui/assets/d3-BiWEKnn4.js +0 -1
- package/dist-ui/assets/index-BhT8RvO6.js +0 -61
- package/dist-ui/assets/index-RoXXJ5dq.css +0 -1
- package/dist-ui/assets/three-BDgTxR1l.js +0 -4112
package/dist/api.js
CHANGED
|
@@ -1,2731 +1,29 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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 { openHippoDb, closeHippoDb } from './db.js';
|
|
10
|
-
import { BadRequestError, ConflictError, ForbiddenError, NotFoundError } from './api-errors.js';
|
|
1
|
+
// Domain API layer: Context-taking functions that the CLI, the HTTP server and MCP all call, so the
|
|
2
|
+
// business logic lives in one place. The code lives in src/api/, one module per domain; this barrel
|
|
3
|
+
// keeps every import path that callers already use.
|
|
11
4
|
export { ApiError, BadRequestError, ConflictError, ForbiddenError, NotFoundError } from './api-errors.js';
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
import { rejectValue, unrejectValue, listRejectionsForTenant } from './reject-flow.js';
|
|
15
|
-
import { listDormantRows, readDormantSnapshot, deleteDormantRow, hasDormantRow, } from './dormant.js';
|
|
16
|
-
import { recordTokenUse, summarizeTokenUse } from './token-ledger.js';
|
|
17
|
-
import { detectInstruction } from './instruction-detect.js';
|
|
18
|
-
import { quarantineScopeFor, recordQuarantine, getQuarantineRow, listQuarantineRows, approveQuarantineRow, rejectQuarantineRow, } from './quarantine.js';
|
|
19
|
-
import { summarizeFailures } from './failure-log.js';
|
|
20
|
-
import { log } from './log.js';
|
|
21
|
-
import { formatHandoffEvidenceLine } from './handoff.js';
|
|
22
|
-
import { createMemory, createSuccessor, applyOutcome, calculateStrength, markRetrieved, CHURN_STALE_TAG, COMPACTION_MEMORY_TAG, } from './memory.js';
|
|
23
|
-
import { appendAuditEvent, reportAuditWriteFailure, auditQueryFields, queryAuditEvents, auditMemories, isContentWorthStoring, } from './audit.js';
|
|
24
|
-
import { promoteToGlobal, getGlobalRoot, autoShare, searchBothHybrid } from './shared.js';
|
|
25
|
-
import { writeRecallTrace, writeRecallTraceAtRoot, recordTraceOutcome } from './recall-trace.js';
|
|
26
|
-
import { evalNow } from './ablation.js';
|
|
27
|
-
import { archiveRawMemory } from './raw-archive.js';
|
|
28
|
-
import { createApiKey, listApiKeys, revokeApiKey, grantScope, ungrantScope, } from './auth.js';
|
|
29
|
-
import { applyGoalStackBoost } from './goals.js';
|
|
30
|
-
import { estimateTokens, hybridSearch, physicsSearch, churnStaleFactor } from './search.js';
|
|
31
|
-
import { compareEntryIdentity, compareScoredResults } from './compare.js';
|
|
32
|
-
import { dropHeldCopies, duplicateKey, storedTextKeys } from './same-text.js';
|
|
33
|
-
import { scopeMatch } from './scope.js';
|
|
34
|
-
import { consolidate } from './consolidate.js';
|
|
35
|
-
import { loadConfig } from './config.js';
|
|
36
|
-
import { resolveProjectIdentity, classifyOriginProject, isGlobalStoreRoot } from './project-identity.js';
|
|
37
|
-
import { promptTokens, contentTokens, gatePromptRecall, } from './prompt-recall.js';
|
|
38
|
-
import { detectSecret, vetSecrets } from './secret-detect.js';
|
|
39
|
-
import { isSessionDigestRow } from './session-digest.js';
|
|
40
|
-
import { deduplicateStore } from './dedupe.js';
|
|
41
|
-
import { computeAmbientState } from './ambient.js';
|
|
42
|
-
import { loadPendingExtractionTenants, markPendingProcessedUpTo } from './graph.js';
|
|
43
|
-
import { extractGraph } from './graph-extract.js';
|
|
44
|
-
import { computePlanningFallacyOutput, } from './predictions.js';
|
|
45
|
-
import { detectAnchoring, hashQueryText, biasHintEnabled, } from './recall-history.js';
|
|
46
|
-
import { detectAvailabilityBias } from './availability.js';
|
|
47
|
-
/**
|
|
48
|
-
* Helper for building process-local (admin-by-default) Actor values. v1.12.0
|
|
49
|
-
* factory used by CLI / MCP / connector Context constructors so the role
|
|
50
|
-
* boilerplate isn't repeated at every site. Bearer-authed callers (HTTP
|
|
51
|
-
* /v1/*) construct Actor directly from the api_keys row's role column via
|
|
52
|
-
* buildContextWithAuth in src/server.ts.
|
|
53
|
-
*/
|
|
54
|
-
export function adminActor(subject) {
|
|
55
|
-
return { subject, role: 'admin' };
|
|
56
|
-
}
|
|
57
|
-
/**
|
|
58
|
-
* Thrown by `api.recall` when a caller's options violate a recall contract
|
|
59
|
-
* that has been opted into via env. Carries a stable `code` field for HTTP /
|
|
60
|
-
* MCP / CLI render paths to discriminate without parsing the message.
|
|
61
|
-
*
|
|
62
|
-
* Codes:
|
|
63
|
-
* - 'fresh_tail_requires_session_id' — `freshTailCount > 0` AND no
|
|
64
|
-
* `freshTailSessionId` AND `HIPPO_REQUIRE_SESSION_SCOPED_FRESH_TAIL=1`.
|
|
65
|
-
* Default behaviour (env unset) returns tenant-wide rows; the env gate
|
|
66
|
-
* is opt-in so multi-session tenants can fail loud instead of silently
|
|
67
|
-
* surfacing cross-session rows tagged `isFreshTail=true`.
|
|
68
|
-
* - 'invalid_scorer_window' — `opts.scorerWindow` is set to a non-positive,
|
|
69
|
-
* non-integer, or non-finite value. Pre-v1.7.0 the value 0 routed
|
|
70
|
-
* through FTS/LIKE `LIMIT 0` and then fell through to an uncapped
|
|
71
|
-
* full-store fallback (codex v1.7.0 diff-pass P1). Validated upfront
|
|
72
|
-
* so the contract holds.
|
|
73
|
-
*/
|
|
74
|
-
export class RecallContractError extends BadRequestError {
|
|
75
|
-
code;
|
|
76
|
-
constructor(code, message) {
|
|
77
|
-
super(message);
|
|
78
|
-
this.name = 'RecallContractError';
|
|
79
|
-
this.code = code;
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
// v1.25.0: the recall-side scope predicates (PRIVATE_SCOPE_RE, isPrivateScope,
|
|
83
|
-
// passesScopeFilterForRecall) live in recall-scope.ts (leaf) so shared.ts can
|
|
84
|
-
// apply the same default-deny rule to searchBothHybrid's internal loads
|
|
85
|
-
// without an api.ts import cycle — same pattern as classifyOriginProject
|
|
86
|
-
// below. Imported here for this module's own call sites and re-exported for
|
|
87
|
-
// back-compat (`api.isPrivateScope`, test imports). NOTE: the import statement
|
|
88
|
-
// is required — a bare `export { x } from` re-export does not bind the local
|
|
89
|
-
// names this module's ~9 call sites use.
|
|
90
|
-
import { isPrivateScope, passesScopeFilterForRecall, assertScopeRequestAllowed, isRestrictedScope } from './recall-scope.js';
|
|
91
|
-
export { isPrivateScope, passesScopeFilterForRecall };
|
|
5
|
+
// The recall-side scope predicates live in recall-scope.ts (leaf) so shared.ts can use them without an import cycle.
|
|
6
|
+
export { isPrivateScope, passesScopeFilterForRecall } from './recall-scope.js';
|
|
92
7
|
export { passesCliRecallScopeFilter, ScopeForbiddenError } from './recall-scope.js';
|
|
93
|
-
//
|
|
94
|
-
// shared.ts can use it without an api.ts import cycle. Re-exported here for
|
|
95
|
-
// callers that already import the api surface.
|
|
8
|
+
// classifyOriginProject lives in project-identity.ts (leaf) for the same reason.
|
|
96
9
|
export { classifyOriginProject } from './project-identity.js';
|
|
97
|
-
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
return true;
|
|
117
|
-
return classifyOriginProject(e.origin_project, currentProjectName) !== 'cross-project';
|
|
118
|
-
}
|
|
119
|
-
/**
|
|
120
|
-
* v39 S4: the secret half of the ambient policy on its own, for callers
|
|
121
|
-
* that apply their own scope rule. A flagged row is only admitted inside its owning project;
|
|
122
|
-
* flagged rows with no project origin never ambient-inject.
|
|
123
|
-
*/
|
|
124
|
-
export function ambientSecretAdmit(e, currentProjectName) {
|
|
125
|
-
if (!detectSecret(e).flagged)
|
|
126
|
-
return true;
|
|
127
|
-
const origin = e.origin_project;
|
|
128
|
-
if (origin === undefined || origin === null || origin === '')
|
|
129
|
-
return false;
|
|
130
|
-
return origin === currentProjectName;
|
|
131
|
-
}
|
|
132
|
-
/** Most rows per store a no-query context reads; past it, ranking and ambientState see the strongest by decay. */
|
|
133
|
-
export const CONTEXT_CANDIDATE_CAP = 2000;
|
|
134
|
-
// The pinned-only branch needs pins and recent-N candidates, not the corpus; `recall` applies there only.
|
|
135
|
-
// Without `window` the whole store loads: a local-only query searches every local row.
|
|
136
|
-
function loadAmbientEntries(hippoRoot, tenantId, pinnedOnly, includeRecent, admit, recall, onQualityDrop, window) {
|
|
137
|
-
if (!pinnedOnly) {
|
|
138
|
-
const rows = window ? loadContextCandidates(hippoRoot, tenantId, window) : loadAllEntries(hippoRoot, tenantId);
|
|
139
|
-
return { entries: rows.filter(admit) };
|
|
140
|
-
}
|
|
141
|
-
// DF3's quality floor runs on the recent-N slice AFTER this load, so the load
|
|
142
|
-
// counts by it too, or it stops short of a store whose newest rows are junk.
|
|
143
|
-
const admitAmbient = (e) => {
|
|
144
|
-
if (!admit(e))
|
|
145
|
-
return false;
|
|
146
|
-
if (e.pinned || isContentWorthStoring(e.content))
|
|
147
|
-
return true;
|
|
148
|
-
onQualityDrop?.(e);
|
|
149
|
-
return false;
|
|
150
|
-
};
|
|
151
|
-
return loadAmbientCandidates(hippoRoot, tenantId, includeRecent, admitAmbient, recall);
|
|
152
|
-
}
|
|
153
|
-
// Share and promote copy a memory to the global store under a new id, so equal content is the only link.
|
|
154
|
-
// A pinned copy wins, then the stronger one after the ranking's own global discount; a tie keeps the local copy.
|
|
155
|
-
export function oneCopyPerMemory(local, global, now) {
|
|
156
|
-
const score = (e, isGlobal) => calculateStrength(e, now) * (isGlobal ? 1 / 1.2 : 1);
|
|
157
|
-
const best = new Map();
|
|
158
|
-
const offer = (entry, isGlobal) => {
|
|
159
|
-
const held = best.get(entry.content);
|
|
160
|
-
const wins = !held || (held.entry.pinned !== entry.pinned
|
|
161
|
-
? entry.pinned
|
|
162
|
-
: score(entry, isGlobal) > score(held.entry, held.isGlobal));
|
|
163
|
-
if (wins)
|
|
164
|
-
best.set(entry.content, { entry, isGlobal });
|
|
165
|
-
};
|
|
166
|
-
for (const e of local)
|
|
167
|
-
offer(e, false);
|
|
168
|
-
for (const e of global)
|
|
169
|
-
offer(e, true);
|
|
170
|
-
const kept = new Set([...best.values()].map((b) => b.entry));
|
|
171
|
-
return [local.filter((e) => kept.has(e)), global.filter((e) => kept.has(e))];
|
|
172
|
-
}
|
|
173
|
-
export function remember(ctx, opts) {
|
|
174
|
-
const vetted = vetSecrets(opts.content, opts.tags ?? [], opts.untrusted === true);
|
|
175
|
-
const detection = opts.untrusted ? detectInstruction(vetted.content) : { flagged: false, reason: null };
|
|
176
|
-
const requestedScope = opts.scope ?? null;
|
|
177
|
-
const entry = createMemory(vetted.content, {
|
|
178
|
-
kind: opts.kind ?? 'distilled',
|
|
179
|
-
scope: detection.flagged ? quarantineScopeFor(requestedScope) : requestedScope,
|
|
180
|
-
owner: opts.owner ?? null,
|
|
181
|
-
artifact_ref: opts.artifactRef ?? null,
|
|
182
|
-
tags: opts.tags,
|
|
183
|
-
tenantId: ctx.tenantId,
|
|
184
|
-
baseHalfLifeDays: loadConfig(ctx.hippoRoot).defaultHalfLifeDays,
|
|
185
|
-
});
|
|
186
|
-
// writeEntry threads ctx.actor.subject into its internal audit hook, so exactly
|
|
187
|
-
// one 'remember' event lands in the log with the supplied actor.
|
|
188
|
-
const afterWrite = detection.flagged
|
|
189
|
-
? (db, memoryId) => {
|
|
190
|
-
recordQuarantine(db, {
|
|
191
|
-
tenantId: ctx.tenantId,
|
|
192
|
-
memoryId,
|
|
193
|
-
originalScope: requestedScope,
|
|
194
|
-
reason: detection.reason ?? 'unknown',
|
|
195
|
-
actor: ctx.actor.subject,
|
|
196
|
-
});
|
|
197
|
-
opts.afterWrite?.(db, memoryId);
|
|
198
|
-
}
|
|
199
|
-
: opts.afterWrite;
|
|
200
|
-
writeEntry(ctx.hippoRoot, entry, { actor: ctx.actor.subject, afterWrite });
|
|
201
|
-
const result = { id: entry.id, kind: entry.kind, tenantId: ctx.tenantId };
|
|
202
|
-
if (detection.flagged)
|
|
203
|
-
result.quarantined = { reason: detection.reason ?? 'unknown' };
|
|
204
|
-
if (vetted.warnings.length > 0)
|
|
205
|
-
result.warnings = vetted.warnings;
|
|
206
|
-
return result;
|
|
207
|
-
}
|
|
208
|
-
/**
|
|
209
|
-
* Shared construction helper for `RecallSuppressionSummary`. Used by
|
|
210
|
-
* `api.recall`, `cmdRecall`, and the MCP `hippo_recall` handler so all three
|
|
211
|
-
* pipelines produce the same shape without duplicating field-construction
|
|
212
|
-
* logic. Pass-through identity today; kept as a helper so future field
|
|
213
|
-
* additions (B4 interference counter wiring, etc.) land at one site.
|
|
214
|
-
*/
|
|
215
|
-
export function buildSuppressionSummary(counts) {
|
|
216
|
-
return {
|
|
217
|
-
totalCandidates: counts.totalCandidates,
|
|
218
|
-
droppedPreRank: counts.droppedPreRank,
|
|
219
|
-
droppedByBudget: counts.droppedByBudget,
|
|
220
|
-
summarySubstitutionsAdded: counts.summarySubstitutionsAdded,
|
|
221
|
-
freshTailAdded: counts.freshTailAdded,
|
|
222
|
-
suppressedByInterference: counts.suppressedByInterference,
|
|
223
|
-
};
|
|
224
|
-
}
|
|
225
|
-
/**
|
|
226
|
-
* Domain-level recall. Loads BM25-ranked candidates from SQLite scoped to
|
|
227
|
-
* `ctx.tenantId` and keeps that order whatever `mode` says; `retrieve` is the
|
|
228
|
-
* mode-aware, strengthening variant the HTTP route uses.
|
|
229
|
-
*
|
|
230
|
-
* **api.recall does NOT mutate `index.last_retrieval_ids`** (v1.11.5 contract
|
|
231
|
-
* lock). The CLI `cmdRecall` (cli.ts) writes `last_retrieval_ids` because the
|
|
232
|
-
* CLI is interactive (user is about to run `hippo outcome --good`). SDK callers
|
|
233
|
-
* are programmatic: they either pass explicit ids to `api.outcome` or call
|
|
234
|
-
* `api.getContext` first for the context-then-outcome workflow (getContext
|
|
235
|
-
* DOES write `last_retrieval_ids`). Adding the side-effect here would change
|
|
236
|
-
* `api.recall` from a pure read into a read+write, breaking SDK callers who
|
|
237
|
-
* batch recall calls in a row. Locked by
|
|
238
|
-
* `tests/api-recall-no-side-effects.test.ts`.
|
|
239
|
-
*/
|
|
240
|
-
export function recall(ctx, opts) {
|
|
241
|
-
// A member key may not unlock a private or quarantined scope by naming it.
|
|
242
|
-
assertScopeRequestAllowed(ctx.actor, opts.scope);
|
|
243
|
-
const windowSize = recallWindowSize(opts);
|
|
244
|
-
return recallFrom(ctx, opts, windowSize, loadRecallSearchEntries(ctx.hippoRoot, opts.query, windowSize, ctx.tenantId, opts.scope, 'exact', false));
|
|
245
|
-
}
|
|
246
|
-
/** Mode-aware recall that strengthens each returned row; never writes last_retrieval_ids (v1.11.5 lock). */
|
|
247
|
-
export async function retrieve(ctx, opts) {
|
|
248
|
-
assertScopeRequestAllowed(ctx.actor, opts.scope);
|
|
249
|
-
const windowSize = recallWindowSize(opts);
|
|
250
|
-
if (opts.showRanked)
|
|
251
|
-
return retrieveFromStore(ctx, opts, windowSize, opts.showRanked);
|
|
252
|
-
let candidates = loadRecallSearchEntries(ctx.hippoRoot, opts.query, windowSize, ctx.tenantId, opts.scope, 'exact', false);
|
|
253
|
-
if (opts.mode === 'hybrid' || opts.mode === 'physics') {
|
|
254
|
-
const searchOpts = { budget: Infinity, hippoRoot: ctx.hippoRoot, scope: opts.scope ?? null };
|
|
255
|
-
const ranked = opts.mode === 'physics'
|
|
256
|
-
? await physicsSearch(opts.query, candidates, { ...searchOpts, physicsConfig: loadConfig(ctx.hippoRoot).physics })
|
|
257
|
-
: await hybridSearch(opts.query, candidates, searchOpts);
|
|
258
|
-
const rankedIds = new Set(ranked.map((r) => r.entry.id));
|
|
259
|
-
candidates = [...ranked.map((r) => r.entry), ...candidates.filter((e) => !rankedIds.has(e.id))];
|
|
260
|
-
}
|
|
261
|
-
const result = recallFrom(ctx, opts, windowSize, candidates);
|
|
262
|
-
strengthenRetrieved(ctx.hippoRoot, result.results.map((r) => r.id), ctx.tenantId);
|
|
263
|
-
return result;
|
|
264
|
-
}
|
|
265
|
-
/** `retrieve` under `showRanked`: physics when `mode` says so, hybrid otherwise, over every admitted row. */
|
|
266
|
-
async function retrieveFromStore(ctx, opts, windowSize, show) {
|
|
267
|
-
const store = loadAllEntries(ctx.hippoRoot, ctx.tenantId);
|
|
268
|
-
const pool = store.filter((e) => passesScopeFilterForRecall(e.scope ?? null, opts.scope));
|
|
269
|
-
// No scope option: the scope boost follows HIPPO_SCOPE and the skill env, as MCP recall always ranked.
|
|
270
|
-
const searchOpts = { budget: Infinity, hippoRoot: ctx.hippoRoot };
|
|
271
|
-
let ranked = opts.mode === 'physics'
|
|
272
|
-
? await physicsSearch(opts.query, pool, { ...searchOpts, physicsConfig: loadConfig(ctx.hippoRoot).physics })
|
|
273
|
-
: await hybridSearch(opts.query, pool, searchOpts);
|
|
274
|
-
if (opts.sessionId && !opts.goalTag) {
|
|
275
|
-
const db = openHippoDb(ctx.hippoRoot);
|
|
276
|
-
try {
|
|
277
|
-
ranked = applyGoalStackBoost(db, ranked, { sessionId: opts.sessionId, tenantId: ctx.tenantId, limit: ranked.length });
|
|
278
|
-
}
|
|
279
|
-
finally {
|
|
280
|
-
closeHippoDb(db);
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
const window = ranked.slice(0, windowSize).map((r) => r.entry);
|
|
284
|
-
const result = recallFrom(ctx, { ...opts, suppressRecallTrace: true }, windowSize, window);
|
|
285
|
-
const shown = show({ ranked, pool, droppedByScope: store.length - pool.length }, result);
|
|
286
|
-
strengthenRetrieved(ctx.hippoRoot, shown, ctx.tenantId);
|
|
287
|
-
if (!opts.suppressRecallTrace) {
|
|
288
|
-
const scores = new Map(ranked.map((r) => [r.entry.id, r.score]));
|
|
289
|
-
writeRecallTraceAtRoot(ctx.hippoRoot, {
|
|
290
|
-
tenantId: ctx.tenantId,
|
|
291
|
-
sessionId: opts.sessionId ?? null,
|
|
292
|
-
pipeline: 'mcp',
|
|
293
|
-
query: opts.query,
|
|
294
|
-
results: shown.map((id) => ({ memoryId: id, score: scores.get(id) ?? 0 })),
|
|
295
|
-
});
|
|
296
|
-
}
|
|
297
|
-
return result;
|
|
298
|
-
}
|
|
299
|
-
/** Contract preflight: throws before any store-touching work. */
|
|
300
|
-
function recallWindowSize(opts) {
|
|
301
|
-
// F5 (v1.6.5) preflight — codex P1: original guard fired AFTER
|
|
302
|
-
// loadSearchEntries (which runs initStore, migrating legacy state on first
|
|
303
|
-
// call). For a true contract preflight we want the throw before any
|
|
304
|
-
// store-touching work. Single check here; the consumer site at
|
|
305
|
-
// `if (freshTailCount > 0)` does NOT re-validate (would be a no-op).
|
|
306
|
-
const freshTailCountPreflight = opts.freshTailCount ?? 0;
|
|
307
|
-
if (freshTailCountPreflight > 0 &&
|
|
308
|
-
!opts.freshTailSessionId &&
|
|
309
|
-
process.env.HIPPO_REQUIRE_SESSION_SCOPED_FRESH_TAIL === '1') {
|
|
310
|
-
throw new RecallContractError('fresh_tail_requires_session_id', 'fresh-tail requires a session id when HIPPO_REQUIRE_SESSION_SCOPED_FRESH_TAIL=1; ' +
|
|
311
|
-
'pass opts.freshTailSessionId or unset the env to allow tenant-wide fresh-tail.');
|
|
312
|
-
}
|
|
313
|
-
// F3 (v1.7.0): scorerWindow opt-in. When undefined (default),
|
|
314
|
-
// loadSearchEntries uses its own store-internal default — this
|
|
315
|
-
// preserves every pre-v1.7.0 caller's behaviour bit-for-bit (codex
|
|
316
|
-
// mk2-pass P0-1: defaulting to `limit` would have shrunk the
|
|
317
|
-
// candidate pool and killed overflow summaries).
|
|
318
|
-
// DEFAULT_SEARCH_CANDIDATE_LIMIT is imported from store.ts so the two
|
|
319
|
-
// values cannot drift (codex diff-pass P1 #3).
|
|
320
|
-
// Validate the input — codex diff-pass P1 #1 caught that scorerWindow=0
|
|
321
|
-
// would route through FTS/LIKE LIMIT 0 and then fall through to an
|
|
322
|
-
// uncapped full-store fallback. Reject non-positive / non-finite values.
|
|
323
|
-
if (opts.scorerWindow !== undefined) {
|
|
324
|
-
if (!Number.isFinite(opts.scorerWindow) ||
|
|
325
|
-
!Number.isInteger(opts.scorerWindow) ||
|
|
326
|
-
opts.scorerWindow < 1) {
|
|
327
|
-
throw new RecallContractError('invalid_scorer_window', `scorerWindow must be a positive integer; got ${opts.scorerWindow}`);
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
return opts.scorerWindow ?? DEFAULT_SEARCH_CANDIDATE_LIMIT;
|
|
331
|
-
}
|
|
332
|
-
function recallFrom(ctx, opts, windowSize, all) {
|
|
333
|
-
const limit = opts.limit ?? 10;
|
|
334
|
-
// v1.7.1 — root-cause fix for the `unknown:legacy` leak. Scope predicate
|
|
335
|
-
// is now pushed into `loadSearchRows` SQL via `loadRecallSearchEntries`.
|
|
336
|
-
// - opts.scope undefined / '': SQL excludes `unknown:legacy`.
|
|
337
|
-
// - opts.scope non-empty: SQL exact-matches m.scope = opts.scope.
|
|
338
|
-
// Tenant predicate still runs first, so a tenant-mismatched scope cannot
|
|
339
|
-
// surface another tenant's row even when both share the same scope string.
|
|
340
|
-
//
|
|
341
|
-
// **CALLER CONTRACT:** any future recall-mode loader MUST go through
|
|
342
|
-
// `loadRecallSearchEntries` (or invoke the SQL scope predicate equivalently).
|
|
343
|
-
// Calling `loadSearchEntries` from this code path re-introduces the v1.6.5
|
|
344
|
-
// codex-flagged leak. See `passesScopeFilterForRecall` in this file for
|
|
345
|
-
// the canonical recall-side scope rule (kept in sync with the SQL clause
|
|
346
|
-
// in loadSearchRows).
|
|
347
|
-
//
|
|
348
|
-
// Also fixes a latent code smell: pre-v1.7.1 passed `opts.scorerWindow`
|
|
349
|
-
// (raw, possibly undefined) where `windowSize` was intended.
|
|
350
|
-
// v1.12.13 / C5 — WYSIATI counters. Declared BEFORE the load step so the
|
|
351
|
-
// assignments at the existing filter sites (load / scope-filter / limit-
|
|
352
|
-
// slice / substitution / fresh-tail) are after declaration. The return at
|
|
353
|
-
// end-of-function reads them via buildSuppressionSummary.
|
|
354
|
-
let totalCandidatesCount = 0;
|
|
355
|
-
let droppedPreRankCount = 0;
|
|
356
|
-
let droppedByBudgetCount = 0;
|
|
357
|
-
let summarySubstitutionsCount = 0;
|
|
358
|
-
let freshTailAddedCount = 0;
|
|
359
|
-
// v1.12.13 / C5 — WYSIATI totalCandidates counter (post tenant + SQL scope
|
|
360
|
-
// predicate, pre JS scope filter).
|
|
361
|
-
totalCandidatesCount = all.length;
|
|
362
|
-
const current = all.filter((e) => !e.superseded_by);
|
|
363
|
-
let entries;
|
|
364
|
-
if (opts.scope !== undefined && opts.scope !== '') {
|
|
365
|
-
// SQL already exact-matched in loadRecallSearchEntries; keep the JS
|
|
366
|
-
// filter as defense-in-depth so a future SQL-clause regression cannot
|
|
367
|
-
// silently surface cross-scope rows.
|
|
368
|
-
entries = current.filter((e) => e.scope === opts.scope);
|
|
369
|
-
}
|
|
370
|
-
else {
|
|
371
|
-
// SQL already excluded `unknown:legacy` AND (v1.25.0) pre-filtered
|
|
372
|
-
// ':private:' scopes with a conservative LIKE before the candidate
|
|
373
|
-
// window, so private rows can no longer starve admitted rows out of the
|
|
374
|
-
// LIMIT (codex review-stage P2). This JS filter stays as the exact
|
|
375
|
-
// anchored `<source>:private:*` rule (v1.2.1 generalization) and
|
|
376
|
-
// defense-in-depth: connector authors cannot silently surface private
|
|
377
|
-
// rows to no-scope callers even if the SQL clause regresses.
|
|
378
|
-
entries = current.filter((e) => !isRestrictedScope(e.scope ?? null));
|
|
379
|
-
}
|
|
380
|
-
// v1.12.13 / C5 — WYSIATI dropped_pre_rank counter (JS scope filter drops
|
|
381
|
-
// for api.recall; cmdRecall pipeline rolls --outcome/--layer/--as-of/etc.
|
|
382
|
-
// into the same field per the plan's Task 3 mapping table).
|
|
383
|
-
droppedPreRankCount = all.length - entries.length;
|
|
384
|
-
entries = entries
|
|
385
|
-
.map((e, i) => ({ e, s: (1 - i / entries.length) * churnStaleFactor(e) }))
|
|
386
|
-
.sort((a, b) => b.s - a.s)
|
|
387
|
-
.map((r) => r.e);
|
|
388
|
-
// BM25 ordering already comes from loadRecallSearchEntries; cap to `limit`.
|
|
389
|
-
// Score is a placeholder — the physics/hybrid scorers in src/search.ts
|
|
390
|
-
// produce richer breakdowns and will replace this when wired up.
|
|
391
|
-
let baseSlice = entries.slice(0, limit);
|
|
392
|
-
// v1.12.13 / C5 — WYSIATI dropped_by_budget counter (candidates loaded but
|
|
393
|
-
// excluded by the final limit slice).
|
|
394
|
-
droppedByBudgetCount = entries.length - baseSlice.length;
|
|
395
|
-
// v1.7.4 -- single db handle for the goal-stack boost AND the audit-event
|
|
396
|
-
// emit below (codex P1: do not open a second short-lived handle for the
|
|
397
|
-
// appendAuditEvent call). The handle is closed in the matching `finally`
|
|
398
|
-
// immediately above the continuity block.
|
|
399
|
-
const db = openHippoDb(ctx.hippoRoot);
|
|
400
|
-
// v1.7.4 -- declared outside the try so the return statement (which lives
|
|
401
|
-
// outside, after the continuity block) can read the final values.
|
|
402
|
-
let rankedOut = [];
|
|
403
|
-
let tokensOut = 0;
|
|
404
|
-
let totalOut = 0;
|
|
405
|
-
// v1.7.4 -- dlPFC goal-stack boost on the PRIMARY band only. Appendix paths
|
|
406
|
-
// (fresh-tail, summary substitutions) are appended AFTER and keep their
|
|
407
|
-
// semantically-special placement.
|
|
408
|
-
let baseScored = baseSlice.map((entry, idx) => ({
|
|
409
|
-
entry,
|
|
410
|
-
score: Math.max(0, 1 - idx / Math.max(1, limit)),
|
|
411
|
-
}));
|
|
412
|
-
// A7 recall-trace: separate side-channel accumulator, allocated ONLY under
|
|
413
|
-
// explain. applyGoalStackBoost writes goal-boost steps here keyed by entry
|
|
414
|
-
// id; the baseRanked map reads it. When !explain it stays undefined and is
|
|
415
|
-
// never passed → the helper's default-path math is byte-identical.
|
|
416
|
-
const explainTrace = opts.explain ? new Map() : undefined;
|
|
417
|
-
try {
|
|
418
|
-
if (opts.sessionId && !opts.goalTag) {
|
|
419
|
-
baseScored = applyGoalStackBoost(db, baseScored, {
|
|
420
|
-
sessionId: opts.sessionId,
|
|
421
|
-
tenantId: ctx.tenantId,
|
|
422
|
-
limit,
|
|
423
|
-
// trace is optional on applyGoalStackBoost; explicitly passing
|
|
424
|
-
// undefined when !explain is identical to omitting the key.
|
|
425
|
-
trace: explainTrace,
|
|
426
|
-
});
|
|
427
|
-
baseSlice = baseScored.map((r) => r.entry);
|
|
428
|
-
}
|
|
429
|
-
// v1.5.0 DAG-aware substitution (Phase 1, Task 2). When entries overflow the
|
|
430
|
-
// limit and ≥2 of them share a level-2 parent summary, append the parent
|
|
431
|
-
// summary so the user sees a compact pointer to the dropped detail. Capped
|
|
432
|
-
// at ceil(limit * 0.3) substitutions so a runaway DAG can't expand results.
|
|
433
|
-
// Each substituted summary is tenant-scoped via loadEntriesByIds and
|
|
434
|
-
// re-checked against the active scope filter (default-deny on private).
|
|
435
|
-
// Drill-down (Task 3) reverses substitution: caller passes substitutedFor[]
|
|
436
|
-
// ids back through `drillDown` to recover the children.
|
|
437
|
-
const summarizeOverflow = opts.summarizeOverflow ?? true;
|
|
438
|
-
let substituted = [];
|
|
439
|
-
if (summarizeOverflow && entries.length > limit) {
|
|
440
|
-
const overflow = entries.slice(limit);
|
|
441
|
-
const baseIds = new Set(baseSlice.map((e) => e.id));
|
|
442
|
-
const overflowByParent = new Map();
|
|
443
|
-
for (const e of overflow) {
|
|
444
|
-
const parentId = e.dag_parent_id;
|
|
445
|
-
if (!parentId)
|
|
446
|
-
continue;
|
|
447
|
-
if ((e.dag_level ?? 0) > 1)
|
|
448
|
-
continue;
|
|
449
|
-
const list = overflowByParent.get(parentId) ?? [];
|
|
450
|
-
list.push(e);
|
|
451
|
-
overflowByParent.set(parentId, list);
|
|
452
|
-
}
|
|
453
|
-
const eligibleParentIds = Array.from(overflowByParent.keys()).filter((pid) => (overflowByParent.get(pid)?.length ?? 0) >= 2 && !baseIds.has(pid));
|
|
454
|
-
if (eligibleParentIds.length > 0) {
|
|
455
|
-
const parents = loadEntriesByIds(ctx.hippoRoot, eligibleParentIds, ctx.tenantId);
|
|
456
|
-
const eligibleParents = parents.filter((p) => (p.dag_level ?? 0) === 2 && !p.superseded_by && passesScopeFilterForRecall(p.scope ?? null, opts.scope));
|
|
457
|
-
const maxSub = Math.max(1, Math.ceil(limit * 0.3));
|
|
458
|
-
// Order parents by overflow count descending so the most
|
|
459
|
-
// information-dense substitutions come first. Overflow count is the
|
|
460
|
-
// true primary key (unchanged); compareEntryIdentity is only a TAIL
|
|
461
|
-
// for the case two parents overflow the same number of children —
|
|
462
|
-
// without it that tie fell to SQLite scan order / loadEntriesByIds
|
|
463
|
-
// batch order (T2, deterministic tie keys).
|
|
464
|
-
eligibleParents.sort((a, b) => {
|
|
465
|
-
const ac = overflowByParent.get(a.id)?.length ?? 0;
|
|
466
|
-
const bc = overflowByParent.get(b.id)?.length ?? 0;
|
|
467
|
-
return bc !== ac ? bc - ac : compareEntryIdentity(a, b);
|
|
468
|
-
});
|
|
469
|
-
substituted = eligibleParents.slice(0, maxSub).map((p) => ({
|
|
470
|
-
entry: p,
|
|
471
|
-
childIds: (overflowByParent.get(p.id) ?? []).map((e) => e.id),
|
|
472
|
-
}));
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
if (!opts.keepHeldCopies) {
|
|
476
|
-
const shownIds = new Set(dropHeldCopies([...baseScored.map((r) => r.entry), ...substituted.map((s) => s.entry)], (e) => e).map((e) => e.id));
|
|
477
|
-
droppedPreRankCount += baseScored.filter((r) => !shownIds.has(r.entry.id)).length;
|
|
478
|
-
baseScored = baseScored.filter((r) => shownIds.has(r.entry.id));
|
|
479
|
-
baseSlice = baseScored.map((r) => r.entry);
|
|
480
|
-
substituted = substituted.filter((s) => shownIds.has(s.entry.id));
|
|
481
|
-
}
|
|
482
|
-
// v1.12.13 / C5 — WYSIATI summary_substitutions_added counter.
|
|
483
|
-
summarySubstitutionsCount = substituted.length;
|
|
484
|
-
// v1.7.4 -- baseScored carries the (possibly boosted) per-row scores. When
|
|
485
|
-
// the goal-stack boost did not run, scores are identical to the original
|
|
486
|
-
// positional placeholder; when it did run, scores reflect the boost AND the
|
|
487
|
-
// rows are in the boosted order (helper sort()).
|
|
488
|
-
const baseRanked = baseScored.map((r) => {
|
|
489
|
-
const item = {
|
|
490
|
-
id: r.entry.id,
|
|
491
|
-
content: r.entry.content,
|
|
492
|
-
score: r.score,
|
|
493
|
-
layer: r.entry.layer,
|
|
494
|
-
strength: r.entry.strength,
|
|
495
|
-
};
|
|
496
|
-
// A7 recall-trace: under explain, every api band carries rerankPipeline:'api';
|
|
497
|
-
// only baseRanked passes through the goal-boost helper, so only it can carry
|
|
498
|
-
// a step (and only for rows that actually matched an active goal).
|
|
499
|
-
if (opts.explain) {
|
|
500
|
-
item.rerankPipeline = 'api';
|
|
501
|
-
const step = explainTrace?.get(r.entry.id);
|
|
502
|
-
if (step)
|
|
503
|
-
item.rerankTrace = [step];
|
|
504
|
-
}
|
|
505
|
-
return item;
|
|
506
|
-
});
|
|
507
|
-
// Substituted summaries land at the end with score = 0.5 (mid-rank), so
|
|
508
|
-
// they don't outrank top-N strong matches but stay above lowest-rank
|
|
509
|
-
// leaves on the consumer side. Caller sorts/filters as it sees fit.
|
|
510
|
-
const summaryRanked = substituted.map((s) => {
|
|
511
|
-
const item = {
|
|
512
|
-
id: s.entry.id,
|
|
513
|
-
content: s.entry.content,
|
|
514
|
-
score: 0.5,
|
|
515
|
-
layer: s.entry.layer,
|
|
516
|
-
strength: s.entry.strength,
|
|
517
|
-
isSummary: true,
|
|
518
|
-
substitutedFor: s.childIds,
|
|
519
|
-
descendantCount: s.entry.descendant_count ?? s.childIds.length,
|
|
520
|
-
};
|
|
521
|
-
// A7 recall-trace: summary band runs no re-ranking, but under explain it
|
|
522
|
-
// still carries the pipeline marker (no steps). Absent when !explain.
|
|
523
|
-
if (opts.explain)
|
|
524
|
-
item.rerankPipeline = 'api';
|
|
525
|
-
return item;
|
|
526
|
-
});
|
|
527
|
-
// v1.5.2 fresh-tail. Surface the last N kind='raw' rows so an agent's
|
|
528
|
-
// "what did I just see" recall path always covers the recent window even
|
|
529
|
-
// when the query terms don't match. Tenant + scope filtered.
|
|
530
|
-
//
|
|
531
|
-
// Dual-membership semantics: `loadSearchEntries` returns all tenant-scoped
|
|
532
|
-
// rows scored by BM25 (even rows with no token overlap can surface at
|
|
533
|
-
// score≈0), so a row in the recent window often ALSO appears as a BM25
|
|
534
|
-
// hit. We don't duplicate. Instead:
|
|
535
|
-
// 1. Mark any baseRanked entry that's in the recent set with isFreshTail.
|
|
536
|
-
// 2. Prepend genuinely-new recent rows (not in BM25 hits or summaries).
|
|
537
|
-
// Net: every recent row carries `isFreshTail=true`, exactly once.
|
|
538
|
-
const freshTailCount = opts.freshTailCount ?? 0;
|
|
539
|
-
const freshRanked = [];
|
|
540
|
-
if (freshTailCount > 0) {
|
|
541
|
-
// F5 contract guard fires at recall() preflight (top of function).
|
|
542
|
-
// No re-check needed here — by the time we reach this block the
|
|
543
|
-
// env/session policy has already been validated.
|
|
544
|
-
const recent = loadFreshRawMemories(ctx.hippoRoot, freshTailCount, ctx.tenantId, opts.freshTailSessionId);
|
|
545
|
-
const recentScoped = recent.filter((m) => passesScopeFilterForRecall(m.scope ?? null, opts.scope));
|
|
546
|
-
const recentIdSet = new Set(recentScoped.map((m) => m.id));
|
|
547
|
-
for (const r of baseRanked) {
|
|
548
|
-
if (recentIdSet.has(r.id))
|
|
549
|
-
r.isFreshTail = true;
|
|
550
|
-
}
|
|
551
|
-
const seenIds = new Set([
|
|
552
|
-
...baseRanked.map((r) => r.id),
|
|
553
|
-
...summaryRanked.map((r) => r.id),
|
|
554
|
-
]);
|
|
555
|
-
const shownKeys = storedTextKeys(opts.keepHeldCopies ? [] : [...baseSlice, ...substituted.map((s) => s.entry)]);
|
|
556
|
-
for (const m of recentScoped) {
|
|
557
|
-
if (seenIds.has(m.id) || shownKeys.has(duplicateKey(m.content)))
|
|
558
|
-
continue;
|
|
559
|
-
shownKeys.add(duplicateKey(m.content));
|
|
560
|
-
const item = {
|
|
561
|
-
id: m.id,
|
|
562
|
-
content: m.content,
|
|
563
|
-
score: 1.0,
|
|
564
|
-
layer: m.layer,
|
|
565
|
-
strength: m.strength,
|
|
566
|
-
isFreshTail: true,
|
|
567
|
-
};
|
|
568
|
-
// A7 recall-trace: fresh-tail band runs no re-ranking; under explain
|
|
569
|
-
// it carries the pipeline marker (no steps). Absent when !explain.
|
|
570
|
-
if (opts.explain)
|
|
571
|
-
item.rerankPipeline = 'api';
|
|
572
|
-
freshRanked.push(item);
|
|
573
|
-
seenIds.add(m.id);
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
// v1.12.13 / C5 — WYSIATI fresh_tail_added counter. Captures the new rows
|
|
577
|
-
// prepended (NOT rows already in baseRanked that got tagged isFreshTail).
|
|
578
|
-
freshTailAddedCount = freshRanked.length;
|
|
579
|
-
rankedOut = [...freshRanked, ...baseRanked, ...summaryRanked];
|
|
580
|
-
tokensOut = rankedOut.reduce((acc, r) => acc + estimateTokens(r.content), 0);
|
|
581
|
-
totalOut = entries.length;
|
|
582
|
-
// TODO(a1-task-4): emit via the shared audit hook in store.ts so we don't
|
|
583
|
-
// double-emit. Recall does not currently write through writeEntry, so no
|
|
584
|
-
// duplicate exists today, but we keep the same shape for symmetry.
|
|
585
|
-
// v1.7.4: reuse the `db` handle opened above for the goal-stack boost --
|
|
586
|
-
// single open/close spans both side effects.
|
|
587
|
-
// GDPR Path A: store a sha256 hash (16 hex chars) of the query text
|
|
588
|
-
// instead of the truncated query itself. If a caller queries with content
|
|
589
|
-
// that matches an archived (RTBF) memory, the original text must not
|
|
590
|
-
// persist in audit_log. query_length is preserved for debugging
|
|
591
|
-
// long-prompt patterns and compliance metrics.
|
|
592
|
-
appendAuditEvent(db, {
|
|
593
|
-
tenantId: ctx.tenantId,
|
|
594
|
-
actor: ctx.actor.subject,
|
|
595
|
-
op: 'recall',
|
|
596
|
-
metadata: {
|
|
597
|
-
...auditQueryFields(opts.query),
|
|
598
|
-
results: rankedOut.length,
|
|
599
|
-
},
|
|
600
|
-
});
|
|
601
|
-
// LC1 (docs/plans/2026-08-02-lc1-recall-trace-persistence.md): trace the
|
|
602
|
-
// returned ids+ranks+scores next to the audit emit, on the SAME open
|
|
603
|
-
// handle. v1.11.5 contract lock holds — api.recall does NOT write
|
|
604
|
-
// last_trace_id (tests/api-recall-no-side-effects.test.ts); a trace INSERT
|
|
605
|
-
// is the same observability class as the audit row it sits beside, not
|
|
606
|
-
// retrieval state. F2 fix: suppressed when the caller traces its own,
|
|
607
|
-
// different result set (retrieve under showRanked traces the shown list as
|
|
608
|
-
// 'mcp'). Fail-soft internally; never throws.
|
|
609
|
-
if (!opts.suppressRecallTrace) {
|
|
610
|
-
writeRecallTrace(db, {
|
|
611
|
-
tenantId: ctx.tenantId,
|
|
612
|
-
sessionId: opts.sessionId ?? null,
|
|
613
|
-
pipeline: 'api',
|
|
614
|
-
query: opts.query,
|
|
615
|
-
explainMode: opts.explain === true,
|
|
616
|
-
results: rankedOut.map((r) => ({
|
|
617
|
-
memoryId: r.id,
|
|
618
|
-
score: r.score,
|
|
619
|
-
rerankSteps: r.rerankTrace,
|
|
620
|
-
})),
|
|
621
|
-
});
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
finally {
|
|
625
|
-
closeHippoDb(db);
|
|
626
|
-
}
|
|
627
|
-
let continuity;
|
|
628
|
-
let continuityTokens;
|
|
629
|
-
if (opts.includeContinuity) {
|
|
630
|
-
const snapshot = loadActiveTaskSnapshot(ctx.hippoRoot, ctx.tenantId);
|
|
631
|
-
// No active snapshot = no anchor = no handoff/events. Avoids resurrecting
|
|
632
|
-
// a stale handoff from a deleted/completed session.
|
|
633
|
-
const sessionId = snapshot?.session_id ?? undefined;
|
|
634
|
-
const sessionHandoff = sessionId
|
|
635
|
-
? loadLatestHandoff(ctx.hippoRoot, ctx.tenantId, sessionId)
|
|
636
|
-
: null;
|
|
637
|
-
const recentSessionEvents = sessionId
|
|
638
|
-
? listSessionEvents(ctx.hippoRoot, ctx.tenantId, { session_id: sessionId, limit: 5 })
|
|
639
|
-
: [];
|
|
640
|
-
// Scope filtering on continuity. Mirrors the memory-recall path:
|
|
641
|
-
// - opts.scope set: EXACT match required (no cross-scope leakage)
|
|
642
|
-
// - opts.scope unset: default-deny on ANY `<source>:private:*` AND on
|
|
643
|
-
// legacy 'unknown:legacy' rows quarantined by the v23 migration.
|
|
644
|
-
// Public and null scopes pass through.
|
|
645
|
-
// v1.1.0 wrongly wrote this as `opts.scope || isPublic`, which allowed
|
|
646
|
-
// ANY explicit scope to see ALL continuity rows. v1.2 closed the latent
|
|
647
|
-
// leak. v1.2.1 generalizes the private check from slack-only to any
|
|
648
|
-
// source so v1.3 GitHub (and future Jira/Linear/etc.) cannot leak.
|
|
649
|
-
const rowScope = (r) => r?.scope ?? null;
|
|
650
|
-
// v1.2: TaskSnapshot / SessionHandoff / SessionEvent now carry scope; the
|
|
651
|
-
// wrapper just normalizes null vs undefined. W1: was its own copy of
|
|
652
|
-
// passesScopeFilterForRecall (cloned 3x); calls the shared helper now.
|
|
653
|
-
const filteredSnapshot = snapshot && passesScopeFilterForRecall(rowScope(snapshot), opts.scope) ? snapshot : null;
|
|
654
|
-
const filteredHandoff = sessionHandoff && passesScopeFilterForRecall(rowScope(sessionHandoff), opts.scope) ? sessionHandoff : null;
|
|
655
|
-
const filteredEvents = recentSessionEvents.filter((e) => passesScopeFilterForRecall(rowScope(e), opts.scope));
|
|
656
|
-
continuity = {
|
|
657
|
-
activeSnapshot: filteredSnapshot,
|
|
658
|
-
sessionHandoff: filteredHandoff,
|
|
659
|
-
recentSessionEvents: filteredEvents,
|
|
660
|
-
};
|
|
661
|
-
const tokenize = (s) => s ? estimateTokens(s) : 0;
|
|
662
|
-
continuityTokens =
|
|
663
|
-
tokenize(filteredSnapshot?.task) +
|
|
664
|
-
tokenize(filteredSnapshot?.summary) +
|
|
665
|
-
tokenize(filteredSnapshot?.next_step) +
|
|
666
|
-
tokenize(filteredHandoff?.summary) +
|
|
667
|
-
tokenize(filteredHandoff?.nextAction) +
|
|
668
|
-
(filteredHandoff?.artifacts ?? []).reduce((acc, a) => acc + tokenize(a), 0) +
|
|
669
|
-
(filteredHandoff?.constraints ?? []).reduce((acc, c) => acc + tokenize(c), 0) +
|
|
670
|
-
tokenize(filteredHandoff?.evidence ? formatHandoffEvidenceLine(filteredHandoff.evidence) : null) +
|
|
671
|
-
tokenize(filteredHandoff?.outcome) +
|
|
672
|
-
tokenize(filteredHandoff?.targetRuntime) +
|
|
673
|
-
tokenize(filteredHandoff?.cardId) +
|
|
674
|
-
filteredEvents.reduce((acc, e) => acc + tokenize(e.content), 0);
|
|
675
|
-
}
|
|
676
|
-
// v0.32 / J3.2 — auto-injection of reference-class baserate when the
|
|
677
|
-
// query carries a forward-prediction phrase AND the closest matching
|
|
678
|
-
// class has closed historical data. Pipeline-invariant (queryText-
|
|
679
|
-
// derived), so MCP and CLI both read this as the single source of
|
|
680
|
-
// truth instead of recomputing (unlike suppressionSummary which IS
|
|
681
|
-
// per-pipeline). opts.actor threads through to the inner
|
|
682
|
-
// computePredictionBaserate call so MCP/HTTP-originated hints attribute
|
|
683
|
-
// correctly instead of defaulting to 'cli'. Disabled by HIPPO_AUTODEBIAS=off.
|
|
684
|
-
// The hint and the no-class-match / tiebreak watching variant are mutually exclusive; both go out as optional fields.
|
|
685
|
-
const planningFallacyOutput = computePlanningFallacyOutput(ctx.hippoRoot, ctx.tenantId, opts.query, { actor: ctx.actor.subject });
|
|
686
|
-
const planningFallacyHint = planningFallacyOutput.hint ?? null;
|
|
687
|
-
const planningFallacyWatching = planningFallacyOutput.watching ?? null;
|
|
688
|
-
// v0.33 / J1 (v1.13.2) — recall-recurrence anchoring detection.
|
|
689
|
-
// Uses opts.recallHistory (caller-supplied snapshot) + this pipeline's
|
|
690
|
-
// own top-1 from rankedOut[0]. PURE read — does NOT mutate the snapshot
|
|
691
|
-
// or any caller-side Map. Disabled by HIPPO_ANCHORING=off (which gates
|
|
692
|
-
// even the detectAnchoring call so disabled tenants pay zero work on
|
|
693
|
-
// this surface). On CLI-routed call paths opts.recallHistory is
|
|
694
|
-
// undefined because cmdRecall computes its own hint separately; the
|
|
695
|
-
// detect call returns null and api.recall's anchoringHint stays absent.
|
|
696
|
-
let anchoringHint = null;
|
|
697
|
-
let suppressedByInterferenceCount = 0;
|
|
698
|
-
if (biasHintEnabled('anchoring') && opts.recallHistory) {
|
|
699
|
-
const queryHash = hashQueryText(opts.query);
|
|
700
|
-
const topMemoryId = rankedOut[0]?.id ?? null;
|
|
701
|
-
anchoringHint = detectAnchoring(opts.recallHistory, queryHash, topMemoryId);
|
|
702
|
-
if (anchoringHint?.reason === 'memory_dominance') {
|
|
703
|
-
suppressedByInterferenceCount = 1;
|
|
704
|
-
// Emit audit op for the memory-dominance detection.
|
|
705
|
-
const db = openHippoDb(ctx.hippoRoot);
|
|
706
|
-
try {
|
|
707
|
-
appendAuditEvent(db, {
|
|
708
|
-
tenantId: ctx.tenantId,
|
|
709
|
-
actor: ctx.actor.subject,
|
|
710
|
-
op: 'recall_anchor_detected_memory_dominance',
|
|
711
|
-
targetId: anchoringHint.memoryId,
|
|
712
|
-
metadata: {
|
|
713
|
-
memory_id: anchoringHint.memoryId,
|
|
714
|
-
query_count: anchoringHint.queryCount ?? null,
|
|
715
|
-
},
|
|
716
|
-
});
|
|
717
|
-
}
|
|
718
|
-
finally {
|
|
719
|
-
closeHippoDb(db);
|
|
720
|
-
}
|
|
721
|
-
}
|
|
722
|
-
else if (anchoringHint?.reason === 'query_repeat') {
|
|
723
|
-
const db = openHippoDb(ctx.hippoRoot);
|
|
724
|
-
try {
|
|
725
|
-
appendAuditEvent(db, {
|
|
726
|
-
tenantId: ctx.tenantId,
|
|
727
|
-
actor: ctx.actor.subject,
|
|
728
|
-
op: 'recall_anchor_detected_query_repeat',
|
|
729
|
-
targetId: anchoringHint.memoryId,
|
|
730
|
-
metadata: { memory_id: anchoringHint.memoryId },
|
|
731
|
-
});
|
|
732
|
-
}
|
|
733
|
-
finally {
|
|
734
|
-
closeHippoDb(db);
|
|
735
|
-
}
|
|
736
|
-
}
|
|
737
|
-
}
|
|
738
|
-
// v1.13.x / J2 — availability/recency-bias detection. PURE read: compares
|
|
739
|
-
// the age distribution of the returned top-K (baseSlice, the post-goal-boost
|
|
740
|
-
// slice) against the matched candidate pool it was drawn from (entries, the
|
|
741
|
-
// scope/private-FILTERED candidate set baseSlice is sliced from — NOT `all`,
|
|
742
|
-
// which still holds private/cross-scope rows the caller is not eligible to see
|
|
743
|
-
// and that could never enter the top-K; counting them would leak hidden pool
|
|
744
|
-
// shape and inflate the signal). Soft warning only — does NOT filter, reorder,
|
|
745
|
-
// or suppress. Disabled by HIPPO_AVAILABILITY=off (gates even the detect call
|
|
746
|
-
// so disabled tenants pay zero work). Suppressed via opts.suppressAvailabilityHint
|
|
747
|
-
// when the caller computes its own per-pipeline hint (MCP), mirroring the J1
|
|
748
|
-
// opts.recallHistory gate above so we never double-emit the audit op. Audit
|
|
749
|
-
// emission is pipeline-local, mirroring the J1 block above.
|
|
750
|
-
let availabilityHint = null;
|
|
751
|
-
if (biasHintEnabled('availability') && !opts.suppressAvailabilityHint) {
|
|
752
|
-
availabilityHint = detectAvailabilityBias({
|
|
753
|
-
topK: baseSlice.map((e) => ({ id: e.id, created: e.created })),
|
|
754
|
-
pool: entries.map((e) => ({ id: e.id, created: e.created })),
|
|
755
|
-
});
|
|
756
|
-
if (availabilityHint) {
|
|
757
|
-
const db = openHippoDb(ctx.hippoRoot);
|
|
758
|
-
try {
|
|
759
|
-
appendAuditEvent(db, {
|
|
760
|
-
tenantId: ctx.tenantId,
|
|
761
|
-
actor: ctx.actor.subject,
|
|
762
|
-
op: 'recall_availability_detected',
|
|
763
|
-
metadata: {
|
|
764
|
-
recent_fraction: availabilityHint.recentFraction,
|
|
765
|
-
older_passed_over: availabilityHint.olderCandidatesPassedOver,
|
|
766
|
-
returned_count: availabilityHint.returnedCount,
|
|
767
|
-
},
|
|
768
|
-
});
|
|
769
|
-
}
|
|
770
|
-
finally {
|
|
771
|
-
closeHippoDb(db);
|
|
772
|
-
}
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
const result = {
|
|
776
|
-
results: rankedOut,
|
|
777
|
-
total: totalOut,
|
|
778
|
-
tokens: tokensOut,
|
|
779
|
-
continuity,
|
|
780
|
-
continuityTokens,
|
|
781
|
-
windowSize,
|
|
782
|
-
suppressionSummary: buildSuppressionSummary({
|
|
783
|
-
totalCandidates: totalCandidatesCount,
|
|
784
|
-
droppedPreRank: droppedPreRankCount,
|
|
785
|
-
droppedByBudget: droppedByBudgetCount,
|
|
786
|
-
summarySubstitutionsAdded: summarySubstitutionsCount,
|
|
787
|
-
freshTailAdded: freshTailAddedCount,
|
|
788
|
-
suppressedByInterference: suppressedByInterferenceCount,
|
|
789
|
-
}),
|
|
790
|
-
};
|
|
791
|
-
if (planningFallacyHint)
|
|
792
|
-
result.planningFallacyHint = planningFallacyHint;
|
|
793
|
-
if (planningFallacyWatching)
|
|
794
|
-
result.planningFallacyWatching = planningFallacyWatching;
|
|
795
|
-
if (anchoringHint)
|
|
796
|
-
result.anchoringHint = anchoringHint;
|
|
797
|
-
if (availabilityHint)
|
|
798
|
-
result.availabilityHint = availabilityHint;
|
|
799
|
-
return result;
|
|
800
|
-
}
|
|
801
|
-
/**
|
|
802
|
-
* Build a chronologically-ordered context window for a session. Adapts the
|
|
803
|
-
* lossless-claw context-engine pattern to Hippo's score-ranked memory store.
|
|
804
|
-
*
|
|
805
|
-
* Algorithm:
|
|
806
|
-
* 1. Load all kind='raw' rows for the session, tenant + scope filtered.
|
|
807
|
-
* 2. Split: newest `freshTailCount` are protected (fresh tail).
|
|
808
|
-
* 3. For older rows, when ≥2 share a level-2 parent, substitute the
|
|
809
|
-
* summary; everything else passes through as raw.
|
|
810
|
-
* 4. Hippo-additive eviction: when over-budget, drop the lowest-strength
|
|
811
|
-
* non-fresh-tail item first. Fresh-tail rows are never evicted.
|
|
812
|
-
*
|
|
813
|
-
* Strength-weighted eviction is the differentiator from lossless-claw,
|
|
814
|
-
* which evicts oldest-first. A high-strength older row (high retrieval
|
|
815
|
-
* count, slow decay) survives; a low-strength recent row (newer but
|
|
816
|
-
* unimportant) goes first.
|
|
817
|
-
*
|
|
818
|
-
* Returns `items: []` cleanly when:
|
|
819
|
-
* - sessionId is empty
|
|
820
|
-
* - no raws exist for the session
|
|
821
|
-
* - all rows fail the scope/tenant filter
|
|
822
|
-
*/
|
|
823
|
-
export function assemble(ctx, sessionId, opts = {}) {
|
|
824
|
-
assertScopeRequestAllowed(ctx.actor, opts.scope);
|
|
825
|
-
const budget = opts.budget ?? 4000;
|
|
826
|
-
const freshTailCount = opts.freshTailCount ?? 10;
|
|
827
|
-
const summarizeOlder = opts.summarizeOlder ?? true;
|
|
828
|
-
const rowCap = opts.rowCap ?? 5000;
|
|
829
|
-
if (!sessionId) {
|
|
830
|
-
return { sessionId, items: [], tokens: 0, totalRaw: 0, summarized: 0, evicted: 0, truncated: false };
|
|
831
|
-
}
|
|
832
|
-
const rows = loadSessionRawMemories(ctx.hippoRoot, sessionId, ctx.tenantId, rowCap);
|
|
833
|
-
const truncated = rows.length === rowCap;
|
|
834
|
-
// v1.6.3 senior-review P0-1: report the FULL post-filter row count even
|
|
835
|
-
// when the cap windows the loaded set. Pre-v1.6.3 used `scoped.length`
|
|
836
|
-
// which under-reported on long sessions and made consumers render
|
|
837
|
-
// wrong "session has N msgs" UX.
|
|
838
|
-
const scoped = rows.filter((r) => passesScopeFilterForRecall(r.scope ?? null, opts.scope));
|
|
839
|
-
let totalRaw;
|
|
840
|
-
if (truncated) {
|
|
841
|
-
// v1.6.3 codex P1 / senior P0: scope-aware unbounded COUNT. The helper
|
|
842
|
-
// SQL-encodes the same default-deny rule passesScopeFilterForRecall
|
|
843
|
-
// applies in TS, so a no-scope caller cannot infer private rows by
|
|
844
|
-
// comparing totalRaw to items.length on a truncated session.
|
|
845
|
-
totalRaw = countSessionRawMemories(ctx.hippoRoot, sessionId, ctx.tenantId, opts.scope);
|
|
846
|
-
}
|
|
847
|
-
else {
|
|
848
|
-
totalRaw = scoped.length;
|
|
849
|
-
}
|
|
850
|
-
if (scoped.length === 0) {
|
|
851
|
-
return { sessionId, items: [], tokens: 0, totalRaw, summarized: 0, evicted: 0, truncated };
|
|
852
|
-
}
|
|
853
|
-
// Split newest N into fresh tail; rest is older.
|
|
854
|
-
const tailStartIdx = Math.max(0, scoped.length - freshTailCount);
|
|
855
|
-
const olderRows = scoped.slice(0, tailStartIdx);
|
|
856
|
-
const tailRows = scoped.slice(tailStartIdx);
|
|
857
|
-
// Substitute parent summaries for older rows that share one.
|
|
858
|
-
const olderItems = [];
|
|
859
|
-
let summarized = 0;
|
|
860
|
-
if (summarizeOlder && olderRows.length > 0) {
|
|
861
|
-
const olderByParent = new Map();
|
|
862
|
-
for (const r of olderRows) {
|
|
863
|
-
if (!r.dag_parent_id)
|
|
864
|
-
continue;
|
|
865
|
-
const list = olderByParent.get(r.dag_parent_id) ?? [];
|
|
866
|
-
list.push(r);
|
|
867
|
-
olderByParent.set(r.dag_parent_id, list);
|
|
868
|
-
}
|
|
869
|
-
const eligibleParentIds = Array.from(olderByParent.keys()).filter((pid) => (olderByParent.get(pid)?.length ?? 0) >= 2);
|
|
870
|
-
const parents = eligibleParentIds.length > 0
|
|
871
|
-
? loadEntriesByIds(ctx.hippoRoot, eligibleParentIds, ctx.tenantId)
|
|
872
|
-
.filter((p) => (p.dag_level ?? 0) === 2 && !p.superseded_by)
|
|
873
|
-
.filter((p) => passesScopeFilterForRecall(p.scope ?? null, opts.scope))
|
|
874
|
-
: [];
|
|
875
|
-
const claimedRawIds = new Set();
|
|
876
|
-
for (const parent of parents) {
|
|
877
|
-
const claimed = (olderByParent.get(parent.id) ?? []).map((r) => r.id);
|
|
878
|
-
claimed.forEach((id) => claimedRawIds.add(id));
|
|
879
|
-
olderItems.push({
|
|
880
|
-
id: parent.id,
|
|
881
|
-
content: parent.content,
|
|
882
|
-
createdAt: parent.earliest_at ?? parent.created,
|
|
883
|
-
isSummary: true,
|
|
884
|
-
substitutedFor: claimed,
|
|
885
|
-
strength: parent.strength,
|
|
886
|
-
});
|
|
887
|
-
summarized += claimed.length;
|
|
888
|
-
}
|
|
889
|
-
for (const r of olderRows) {
|
|
890
|
-
if (claimedRawIds.has(r.id))
|
|
891
|
-
continue;
|
|
892
|
-
olderItems.push({
|
|
893
|
-
id: r.id,
|
|
894
|
-
content: r.content,
|
|
895
|
-
createdAt: r.created,
|
|
896
|
-
strength: r.strength,
|
|
897
|
-
});
|
|
898
|
-
}
|
|
899
|
-
}
|
|
900
|
-
else {
|
|
901
|
-
for (const r of olderRows) {
|
|
902
|
-
olderItems.push({
|
|
903
|
-
id: r.id,
|
|
904
|
-
content: r.content,
|
|
905
|
-
createdAt: r.created,
|
|
906
|
-
strength: r.strength,
|
|
907
|
-
});
|
|
908
|
-
}
|
|
909
|
-
}
|
|
910
|
-
const tailItems = tailRows.map((r) => ({
|
|
911
|
-
id: r.id,
|
|
912
|
-
content: r.content,
|
|
913
|
-
createdAt: r.created,
|
|
914
|
-
isFreshTail: true,
|
|
915
|
-
strength: r.strength,
|
|
916
|
-
}));
|
|
917
|
-
// F4 (v1.6.5): byte compare canonical UTC ISO timestamps. ~50× faster than
|
|
918
|
-
// localeCompare and chronological by virtue of the timestamp invariant
|
|
919
|
-
// documented in src/memory.ts above MemoryEntry.
|
|
920
|
-
const cmpIso = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
921
|
-
olderItems.sort((a, b) => cmpIso(a.createdAt, b.createdAt));
|
|
922
|
-
tailItems.sort((a, b) => cmpIso(a.createdAt, b.createdAt));
|
|
923
|
-
let items = [...olderItems, ...tailItems];
|
|
924
|
-
const itemCost = opts.cost?.item ?? ((it) => estimateTokens(it.content));
|
|
925
|
-
const room = budget - (opts.cost?.fixed(Math.max(budget, totalRaw)) ?? 0);
|
|
926
|
-
let tokens = items.reduce((acc, it) => acc + itemCost(it), 0);
|
|
927
|
-
let evicted = 0;
|
|
928
|
-
while (tokens > room && items.length > 0) {
|
|
929
|
-
let worstIdx = -1;
|
|
930
|
-
let worstStrength = Infinity;
|
|
931
|
-
for (let i = 0; i < items.length; i++) {
|
|
932
|
-
if (items[i].isFreshTail)
|
|
933
|
-
continue;
|
|
934
|
-
if (items[i].strength < worstStrength) {
|
|
935
|
-
worstStrength = items[i].strength;
|
|
936
|
-
worstIdx = i;
|
|
937
|
-
}
|
|
938
|
-
}
|
|
939
|
-
if (worstIdx === -1)
|
|
940
|
-
break;
|
|
941
|
-
const cost = itemCost(items[worstIdx]);
|
|
942
|
-
items = items.filter((_, i) => i !== worstIdx);
|
|
943
|
-
tokens -= cost;
|
|
944
|
-
evicted++;
|
|
945
|
-
}
|
|
946
|
-
return { sessionId, items, tokens, totalRaw, summarized, evicted, truncated };
|
|
947
|
-
}
|
|
948
|
-
/**
|
|
949
|
-
* Walk one step down the DAG from a level-2 (or higher) summary to its direct
|
|
950
|
-
* children. Companion to `recall(... summarizeOverflow: true)` — when recall
|
|
951
|
-
* surfaces a summary with `substitutedFor: [...]`, the caller drills into the
|
|
952
|
-
* summary id to recover the original detail.
|
|
953
|
-
*
|
|
954
|
-
* Tenant scope: only summaries owned by `ctx.tenantId` are reachable. The same
|
|
955
|
-
* scope filter that recall applies is enforced on the children — a level-2
|
|
956
|
-
* summary in `slack:public:CGEN` cannot leak `slack:private:*` children even
|
|
957
|
-
* if the underlying DAG accidentally linked across scopes.
|
|
958
|
-
*
|
|
959
|
-
* Returns a discriminated `DrillDownOutcome`: `DrillDownResult` on success,
|
|
960
|
-
* or `{failure: '...'}` for `not_found` (covers genuinely-missing AND wrong-
|
|
961
|
-
* tenant, intentionally indistinguishable), `not_drillable` (id is a leaf
|
|
962
|
-
* row), or `scope_blocked` (caller has no scope grant for the row's scope).
|
|
963
|
-
*
|
|
964
|
-
* Pre-v1.6.4 returned null for all four cases. JS callers migrate via
|
|
965
|
-
* `'failure' in result` checks; HTTP route maps `not_drillable` to 422.
|
|
966
|
-
*/
|
|
967
|
-
export function drillDown(ctx, summaryId, opts = {}) {
|
|
968
|
-
const limit = opts.limit ?? 50;
|
|
969
|
-
// v0.30 / E5: depth defaults 1 (backward compat); hard cap 10 levels
|
|
970
|
-
// prevents pathological deep trees. CLI/HTTP/MCP reject invalid values.
|
|
971
|
-
const depth = Math.max(1, Math.min(Math.trunc(opts.depth ?? 1), 10));
|
|
972
|
-
const summary = readEntry(ctx.hippoRoot, summaryId, ctx.tenantId);
|
|
973
|
-
// No unscoped cross-tenant probe here — readEntry's null return covers
|
|
974
|
-
// both "doesn't exist" and "exists in another tenant" by design.
|
|
975
|
-
// Distinguishing them via an unscoped lookup would leak existence to
|
|
976
|
-
// unauthorised tenants. The two cases collapse into not_found.
|
|
977
|
-
if (!summary)
|
|
978
|
-
return { failure: 'not_found' };
|
|
979
|
-
if ((summary.dag_level ?? 0) < 2)
|
|
980
|
-
return { failure: 'not_drillable' };
|
|
981
|
-
if (!passesScopeFilterForRecall(summary.scope ?? null, undefined)) {
|
|
982
|
-
// codex round 3 P1: collapse to not_found. A distinguishable
|
|
983
|
-
// "scope_blocked" tells a no-scope caller "this row exists, just
|
|
984
|
-
// not for you" — same existence-leak the HTTP 404 collapse was
|
|
985
|
-
// already preventing. Match the HTTP behaviour at the API level.
|
|
986
|
-
return { failure: 'not_found' };
|
|
987
|
-
}
|
|
988
|
-
// v0.30 / E5: BFS walk levels 1..depth with visited-Set dedup. Defensive
|
|
989
|
-
// against shared-child data anomalies (dag_parent_id has no uniqueness
|
|
990
|
-
// constraint, so a misconfigured tree could double-emit at depth > 1).
|
|
991
|
-
// Each level uses loadChildrenOf which is tenant-scoped via ctx.tenantId.
|
|
992
|
-
const collected = [];
|
|
993
|
-
const visited = new Set([summaryId]);
|
|
994
|
-
let frontier = [summaryId];
|
|
995
|
-
// independent-review MED #4 fold: track level-0 direct-children count
|
|
996
|
-
// separately so the descendantCount fallback (for legacy summaries with
|
|
997
|
-
// null descendant_count) reflects DIRECT children, not BFS-collected total.
|
|
998
|
-
let level0DirectCount = 0;
|
|
999
|
-
for (let level = 0; level < depth; level++) {
|
|
1000
|
-
const nextFrontier = [];
|
|
1001
|
-
for (const parentId of frontier) {
|
|
1002
|
-
const kids = loadChildrenOf(ctx.hippoRoot, parentId, ctx.tenantId);
|
|
1003
|
-
const eligibleKids = kids.filter((c) => passesScopeFilterForRecall(c.scope ?? null, undefined));
|
|
1004
|
-
for (const k of eligibleKids) {
|
|
1005
|
-
if (visited.has(k.id))
|
|
1006
|
-
continue;
|
|
1007
|
-
visited.add(k.id);
|
|
1008
|
-
collected.push(k);
|
|
1009
|
-
nextFrontier.push(k.id);
|
|
1010
|
-
if (level === 0)
|
|
1011
|
-
level0DirectCount++;
|
|
1012
|
-
}
|
|
1013
|
-
}
|
|
1014
|
-
if (nextFrontier.length === 0)
|
|
1015
|
-
break;
|
|
1016
|
-
frontier = nextFrontier;
|
|
1017
|
-
}
|
|
1018
|
-
const summaryOut = {
|
|
1019
|
-
id: summary.id,
|
|
1020
|
-
content: summary.content,
|
|
1021
|
-
// v0.30 / E5: the STORED direct-child count; the legacy fallback counts
|
|
1022
|
-
// level-0 children, never the BFS-depth-N total (independent-review MED #4).
|
|
1023
|
-
descendantCount: summary.descendant_count ?? level0DirectCount,
|
|
1024
|
-
earliestAt: summary.earliest_at ?? null,
|
|
1025
|
-
latestAt: summary.latest_at ?? null,
|
|
1026
|
-
};
|
|
1027
|
-
const all = collected.map((c) => ({
|
|
1028
|
-
id: c.id,
|
|
1029
|
-
content: c.content,
|
|
1030
|
-
layer: c.layer,
|
|
1031
|
-
dagLevel: c.dag_level ?? 0,
|
|
1032
|
-
created: c.created,
|
|
1033
|
-
}));
|
|
1034
|
-
// Apply global cumulative token budget + limit cap on collected.
|
|
1035
|
-
let children = all;
|
|
1036
|
-
let truncated = false;
|
|
1037
|
-
if (opts.budget !== undefined) {
|
|
1038
|
-
const out = [];
|
|
1039
|
-
let used = 0;
|
|
1040
|
-
const room = opts.budget - (opts.cost?.fixed(summaryOut, all.length) ?? 0);
|
|
1041
|
-
for (const c of all) {
|
|
1042
|
-
const t = opts.cost ? opts.cost.child(c) : estimateTokens(c.content);
|
|
1043
|
-
if (out.length > 0 && used + t > room) {
|
|
1044
|
-
truncated = true;
|
|
1045
|
-
break;
|
|
1046
|
-
}
|
|
1047
|
-
out.push(c);
|
|
1048
|
-
used += t;
|
|
1049
|
-
}
|
|
1050
|
-
children = out;
|
|
1051
|
-
}
|
|
1052
|
-
if (children.length > limit) {
|
|
1053
|
-
children = children.slice(0, limit);
|
|
1054
|
-
truncated = true;
|
|
1055
|
-
}
|
|
1056
|
-
return {
|
|
1057
|
-
summary: summaryOut,
|
|
1058
|
-
children,
|
|
1059
|
-
// v0.30 / E5: totalChildren = BFS-collected count (depth-aware). For
|
|
1060
|
-
// depth=1 this equals the eligible direct-children count (backward
|
|
1061
|
-
// compat). For depth>1 it is the cumulative count across levels.
|
|
1062
|
-
totalChildren: collected.length,
|
|
1063
|
-
truncated,
|
|
1064
|
-
};
|
|
1065
|
-
}
|
|
1066
|
-
export function outcome(ctx, ids, good, opts) {
|
|
1067
|
-
const appliedIds = [];
|
|
1068
|
-
const db = openHippoDb(ctx.hippoRoot);
|
|
1069
|
-
try {
|
|
1070
|
-
for (const id of ids) {
|
|
1071
|
-
const entry = readEntry(ctx.hippoRoot, id, ctx.tenantId);
|
|
1072
|
-
if (!entry)
|
|
1073
|
-
continue;
|
|
1074
|
-
let updated = applyOutcome(entry, good);
|
|
1075
|
-
if (good && updated.tags.includes(CHURN_STALE_TAG)) { // FE2: a good outcome reconfirms the entry
|
|
1076
|
-
updated = { ...updated, tags: updated.tags.filter((t) => t !== CHURN_STALE_TAG) };
|
|
1077
|
-
}
|
|
1078
|
-
writeEntry(ctx.hippoRoot, updated, { actor: ctx.actor.subject });
|
|
1079
|
-
appendAuditEvent(db, {
|
|
1080
|
-
tenantId: ctx.tenantId,
|
|
1081
|
-
actor: ctx.actor.subject,
|
|
1082
|
-
op: 'outcome',
|
|
1083
|
-
targetId: id,
|
|
1084
|
-
metadata: { good },
|
|
1085
|
-
});
|
|
1086
|
-
appliedIds.push(id);
|
|
1087
|
-
}
|
|
1088
|
-
// LC1: link the outcome to its trace, recording only the ids actually
|
|
1089
|
-
// credited (post tenant-filtering, matches appliedIds). Lives in its own
|
|
1090
|
-
// append-only table so audit_log pruning can never erase training data.
|
|
1091
|
-
if (opts?.traceId !== undefined && appliedIds.length > 0) {
|
|
1092
|
-
recordTraceOutcome(db, {
|
|
1093
|
-
traceId: opts.traceId,
|
|
1094
|
-
tenantId: ctx.tenantId,
|
|
1095
|
-
outcome: good ? 'positive' : 'negative',
|
|
1096
|
-
memoryIds: appliedIds,
|
|
1097
|
-
});
|
|
1098
|
-
}
|
|
1099
|
-
}
|
|
1100
|
-
finally {
|
|
1101
|
-
closeHippoDb(db);
|
|
1102
|
-
}
|
|
1103
|
-
return { applied: appliedIds.length, appliedIds };
|
|
1104
|
-
}
|
|
1105
|
-
export function forget(ctx, id) {
|
|
1106
|
-
const db = openHippoDb(ctx.hippoRoot);
|
|
1107
|
-
try {
|
|
1108
|
-
// SAFETY: row's shape matches the single `tenant_id` column named in
|
|
1109
|
-
// the SELECT above.
|
|
1110
|
-
const row = db
|
|
1111
|
-
.prepare(`SELECT tenant_id FROM memories WHERE id = ?`)
|
|
1112
|
-
.get(id);
|
|
1113
|
-
if (!row || row.tenant_id !== ctx.tenantId) {
|
|
1114
|
-
throw new NotFoundError(`memory not found: ${id}`);
|
|
1115
|
-
}
|
|
1116
|
-
}
|
|
1117
|
-
finally {
|
|
1118
|
-
closeHippoDb(db);
|
|
1119
|
-
}
|
|
1120
|
-
const removed = deleteEntry(ctx.hippoRoot, id, { actor: ctx.actor.subject });
|
|
1121
|
-
if (!removed) {
|
|
1122
|
-
throw new NotFoundError(`memory not found: ${id}`);
|
|
1123
|
-
}
|
|
1124
|
-
// Counted here, not in the CLI: both callers of this function (cmdForget and
|
|
1125
|
-
// the HTTP route) are the two paths of one user command, so neither can miss
|
|
1126
|
-
// it. api.remember cannot take the same move; see the server route.
|
|
1127
|
-
updateStats(ctx.hippoRoot, { forgotten: 1 });
|
|
1128
|
-
return { ok: true, id };
|
|
1129
|
-
}
|
|
1130
|
-
/**
|
|
1131
|
-
* Reject a value: tombstone its normalized digest so a matching write is
|
|
1132
|
-
* refused everywhere (remember/capture/import/sync) until `unreject`. Two
|
|
1133
|
-
* forms — pass exactly one:
|
|
1134
|
-
* - `memoryId`: reject the CURRENT content of an existing memory. Removes
|
|
1135
|
-
* that row and every other live row in the tenant whose normalized
|
|
1136
|
-
* digest matches (not just the id passed).
|
|
1137
|
-
* - `value`: pre-emptive form — tombstone content that may not currently
|
|
1138
|
-
* be stored (or is already gone). Zero removals.
|
|
1139
|
-
*
|
|
1140
|
-
* `reason` is required (the tombstone stores no content; reason is its
|
|
1141
|
-
* only human-readable identity). Throws if the memory id is not found in
|
|
1142
|
-
* `ctx.tenantId`, or if both/neither of `memoryId`/`value` are given.
|
|
1143
|
-
*/
|
|
1144
|
-
export function reject(ctx, opts) {
|
|
1145
|
-
if (opts.memoryId !== undefined) {
|
|
1146
|
-
// Tenant scope, same not-found-shaped denial as forget/promote above:
|
|
1147
|
-
// rejectValue itself also tenant-checks the id, but pre-checking here
|
|
1148
|
-
// keeps the error message consistent with the rest of this module.
|
|
1149
|
-
const db = openHippoDb(ctx.hippoRoot);
|
|
1150
|
-
try {
|
|
1151
|
-
// SAFETY: row's shape matches the single `tenant_id` column named in
|
|
1152
|
-
// the SELECT above.
|
|
1153
|
-
const row = db
|
|
1154
|
-
.prepare(`SELECT tenant_id FROM memories WHERE id = ?`)
|
|
1155
|
-
.get(opts.memoryId);
|
|
1156
|
-
if (!row || row.tenant_id !== ctx.tenantId) {
|
|
1157
|
-
throw new NotFoundError(`memory not found: ${opts.memoryId}`);
|
|
1158
|
-
}
|
|
1159
|
-
}
|
|
1160
|
-
finally {
|
|
1161
|
-
closeHippoDb(db);
|
|
1162
|
-
}
|
|
1163
|
-
}
|
|
1164
|
-
const result = rejectValue({
|
|
1165
|
-
hippoRoot: ctx.hippoRoot,
|
|
1166
|
-
tenantId: ctx.tenantId,
|
|
1167
|
-
actor: ctx.actor.subject,
|
|
1168
|
-
reason: opts.reason,
|
|
1169
|
-
memoryId: opts.memoryId,
|
|
1170
|
-
value: opts.value,
|
|
1171
|
-
});
|
|
1172
|
-
return { digest: result.digest, removedIds: result.removedIds };
|
|
1173
|
-
}
|
|
1174
|
-
/**
|
|
1175
|
-
* Delete a tombstone by exact digest or unambiguous prefix, restoring the
|
|
1176
|
-
* value's writability — the only v1 escape hatch (no per-write force flag).
|
|
1177
|
-
* Throws if `digestOrPrefix` matches no tombstone, is blank, or matches
|
|
1178
|
-
* more than one (use a longer prefix).
|
|
1179
|
-
*/
|
|
1180
|
-
export function unreject(ctx, digestOrPrefix) {
|
|
1181
|
-
const outcome = unrejectValue(ctx.hippoRoot, ctx.tenantId, digestOrPrefix, ctx.actor.subject);
|
|
1182
|
-
if (outcome.status === 'not_found') {
|
|
1183
|
-
throw new NotFoundError(`no rejected value matches: ${digestOrPrefix}`);
|
|
1184
|
-
}
|
|
1185
|
-
if (outcome.status === 'ambiguous') {
|
|
1186
|
-
throw new BadRequestError(`"${digestOrPrefix}" matches ${outcome.candidates.length} tombstones; use a longer prefix`);
|
|
1187
|
-
}
|
|
1188
|
-
return { ok: true, digest: outcome.digest };
|
|
1189
|
-
}
|
|
1190
|
-
/** List every rejected-value tombstone for `ctx.tenantId`, newest first. */
|
|
1191
|
-
export function listRejections(ctx) {
|
|
1192
|
-
return listRejectionsForTenant(ctx.hippoRoot, ctx.tenantId);
|
|
1193
|
-
}
|
|
1194
|
-
export function promote(ctx, id) {
|
|
1195
|
-
// Tenant scope: promoteToGlobal reads the entry from the local root via
|
|
1196
|
-
// readEntry without a tenant filter, so a Bearer for tenant A could
|
|
1197
|
-
// promote tenant B's row by guessing or leaking the id. Pre-check the
|
|
1198
|
-
// row's tenant_id and deny cross-tenant access with the same not-found
|
|
1199
|
-
// wording archiveRaw uses (no info leak about whether the id exists in
|
|
1200
|
-
// another tenant).
|
|
1201
|
-
const ownerDb = openHippoDb(ctx.hippoRoot);
|
|
1202
|
-
try {
|
|
1203
|
-
// SAFETY: row's shape matches the single `tenant_id` column named in
|
|
1204
|
-
// the SELECT above.
|
|
1205
|
-
const row = ownerDb
|
|
1206
|
-
.prepare(`SELECT tenant_id FROM memories WHERE id = ?`)
|
|
1207
|
-
.get(id);
|
|
1208
|
-
if (!row || row.tenant_id !== ctx.tenantId) {
|
|
1209
|
-
throw new NotFoundError(`memory not found: ${id}`);
|
|
1210
|
-
}
|
|
1211
|
-
}
|
|
1212
|
-
finally {
|
|
1213
|
-
closeHippoDb(ownerDb);
|
|
1214
|
-
}
|
|
1215
|
-
// promoteToGlobal threads ctx.actor.subject into the writeEntry call on the global
|
|
1216
|
-
// db, which emits a 'remember' audit row. We then add the user-facing
|
|
1217
|
-
// 'promote' event on the global db so the audit trail keeps the intent
|
|
1218
|
-
// distinct from the underlying upsert.
|
|
1219
|
-
const globalEntry = promoteToGlobal(ctx.hippoRoot, id, { actor: ctx.actor.subject, tenantId: ctx.tenantId });
|
|
1220
|
-
const db = openHippoDb(getGlobalRoot());
|
|
1221
|
-
try {
|
|
1222
|
-
appendAuditEvent(db, {
|
|
1223
|
-
tenantId: ctx.tenantId,
|
|
1224
|
-
actor: ctx.actor.subject,
|
|
1225
|
-
op: 'promote',
|
|
1226
|
-
targetId: globalEntry.id,
|
|
1227
|
-
metadata: { sourceId: id },
|
|
1228
|
-
});
|
|
1229
|
-
}
|
|
1230
|
-
finally {
|
|
1231
|
-
closeHippoDb(db);
|
|
1232
|
-
}
|
|
1233
|
-
return { ok: true, sourceId: id, globalId: globalEntry.id };
|
|
1234
|
-
}
|
|
1235
|
-
export function supersede(ctx, oldId, newContent) {
|
|
1236
|
-
// Read old (tenant-scoped). readEntry filters by tenantId, so a Bearer for
|
|
1237
|
-
// tenant A on tenant B's id throws "Memory not found" here without any
|
|
1238
|
-
// info leak.
|
|
1239
|
-
const old = readEntry(ctx.hippoRoot, oldId, ctx.tenantId);
|
|
1240
|
-
if (!old) {
|
|
1241
|
-
throw new NotFoundError(`Memory not found: ${oldId}`);
|
|
1242
|
-
}
|
|
1243
|
-
// Guard: not already superseded. The CAS UPDATE below race-safely closes
|
|
1244
|
-
// the window between this read and the write; this check just produces a
|
|
1245
|
-
// clearer error in the common single-writer case.
|
|
1246
|
-
if (old.superseded_by) {
|
|
1247
|
-
throw new ConflictError(`Memory ${oldId} is already superseded by ${old.superseded_by}. Supersede that one instead.`);
|
|
1248
|
-
}
|
|
1249
|
-
const newEntry = createSuccessor(old, newContent, {
|
|
1250
|
-
tenantId: ctx.tenantId,
|
|
1251
|
-
baseHalfLifeDays: loadConfig(ctx.hippoRoot).defaultHalfLifeDays,
|
|
1252
|
-
});
|
|
1253
|
-
// Race-safe transition: open a fresh db handle, BEGIN IMMEDIATE, run all
|
|
1254
|
-
// three steps (CAS on old + writeEntryDbOnly(new) + supersede audit row)
|
|
1255
|
-
// inside the same transaction. Two concurrent supersedes: exactly one CAS
|
|
1256
|
-
// wins (changes=1), the other gets changes=0 and throws CONFLICT. No
|
|
1257
|
-
// dangling-pointer window: the new memory's row commits atomically with
|
|
1258
|
-
// the old.superseded_by pointer.
|
|
1259
|
-
const db = openHippoDb(ctx.hippoRoot);
|
|
1260
|
-
try {
|
|
1261
|
-
db.exec('BEGIN IMMEDIATE');
|
|
1262
|
-
try {
|
|
1263
|
-
// 1. CAS update: only succeed if old.superseded_by IS NULL AND the
|
|
1264
|
-
// row still belongs to ctx.tenantId. Tenant filter is belt-and-
|
|
1265
|
-
// braces with the readEntry above — it costs nothing and closes
|
|
1266
|
-
// a hypothetical window where ownership changes between read and
|
|
1267
|
-
// update.
|
|
1268
|
-
const result = db.prepare(`
|
|
1269
|
-
UPDATE memories
|
|
1270
|
-
SET superseded_by = ?
|
|
1271
|
-
WHERE id = ? AND tenant_id = ? AND superseded_by IS NULL
|
|
1272
|
-
`).run(newEntry.id, oldId, ctx.tenantId);
|
|
1273
|
-
if ((result.changes ?? 0) === 0) {
|
|
1274
|
-
db.exec('ROLLBACK');
|
|
1275
|
-
throw new ConflictError(`Memory ${oldId} already superseded by another writer`);
|
|
1276
|
-
}
|
|
1277
|
-
// v0.30 / E2 — DAG live-coupling: OLD entry just transitioned to
|
|
1278
|
-
// superseded. Its parent (if any) needs rebuild. Lands strictly
|
|
1279
|
-
// between the rollback guard above and the writeEntryDbOnly(NEW)
|
|
1280
|
-
// below so a failed CAS hits throw before this hook. The NEW
|
|
1281
|
-
// entry's parent (typically same parent) is auto-marked by the
|
|
1282
|
-
// writeEntryDbOnly hook (same parent → idempotent, audits once).
|
|
1283
|
-
if (old.dag_parent_id) {
|
|
1284
|
-
markSummaryDirtyInTx(db, old.dag_parent_id, ctx.tenantId, ctx.actor.subject);
|
|
1285
|
-
}
|
|
1286
|
-
// 2. Write new memory inside same tx via writeEntryDbOnly (DB-only
|
|
1287
|
-
// path). This emits its OWN 'remember' audit row for the new
|
|
1288
|
-
// memory inside the SAVEPOINT — atomic with the row INSERT.
|
|
1289
|
-
writeEntryDbOnly(db, stampOriginProject(ctx.hippoRoot, newEntry), { actor: ctx.actor.subject });
|
|
1290
|
-
// 3. User-facing 'supersede' audit row inside the same tx so the
|
|
1291
|
-
// chain pointer + audit trail commit atomically.
|
|
1292
|
-
appendAuditEvent(db, {
|
|
1293
|
-
tenantId: ctx.tenantId,
|
|
1294
|
-
actor: ctx.actor.subject,
|
|
1295
|
-
op: 'supersede',
|
|
1296
|
-
targetId: oldId,
|
|
1297
|
-
metadata: { newId: newEntry.id },
|
|
1298
|
-
});
|
|
1299
|
-
db.exec('COMMIT');
|
|
1300
|
-
}
|
|
1301
|
-
catch (err) {
|
|
1302
|
-
try {
|
|
1303
|
-
db.exec('ROLLBACK');
|
|
1304
|
-
}
|
|
1305
|
-
catch { /* already rolled back */ }
|
|
1306
|
-
// AT1 (plan §3): refusal audit lands post-ROLLBACK, in a fresh
|
|
1307
|
-
// implicit transaction the aborted outer one cannot claw back — then
|
|
1308
|
-
// rethrow so the caller sees the refusal.
|
|
1309
|
-
if (err instanceof RejectedValueError) {
|
|
1310
|
-
auditRejectionRefusal(db, err, ctx.actor.subject);
|
|
1311
|
-
}
|
|
1312
|
-
throw err;
|
|
1313
|
-
}
|
|
1314
|
-
// Mirrors after COMMIT, while the db handle is still open. Same
|
|
1315
|
-
// invariant as the original writeEntry: a mirror failure leaves disk
|
|
1316
|
-
// MISSING the markdown for the new memory (rebuildIndex rewrites every
|
|
1317
|
-
// markdown mirror from the DB) but DOES NOT desync the DB or
|
|
1318
|
-
// roll back the supersede. Logged + swallowed, non-fatal.
|
|
1319
|
-
try {
|
|
1320
|
-
writeEntryMirrors(ctx.hippoRoot, newEntry);
|
|
1321
|
-
}
|
|
1322
|
-
catch (mirrorErr) {
|
|
1323
|
-
log.error(`supersede: mirror write failed (non-fatal, will self-heal): ${mirrorErr instanceof Error ? mirrorErr.message : String(mirrorErr)}`);
|
|
1324
|
-
}
|
|
1325
|
-
}
|
|
1326
|
-
finally {
|
|
1327
|
-
closeHippoDb(db);
|
|
1328
|
-
}
|
|
1329
|
-
return { ok: true, oldId, newId: newEntry.id };
|
|
1330
|
-
}
|
|
1331
|
-
export function archiveRaw(ctx, id, reason, opts = {}) {
|
|
1332
|
-
const db = openHippoDb(ctx.hippoRoot);
|
|
1333
|
-
let mirrorOk = false;
|
|
1334
|
-
try {
|
|
1335
|
-
// Tenant scope: archiveRawMemory looks up the row by id alone, so a
|
|
1336
|
-
// Bearer for tenant A could archive tenant B's raw row without this
|
|
1337
|
-
// pre-check. Deny cross-tenant access with the same not-found message
|
|
1338
|
-
// archiveRawMemory itself would throw on a missing row, so we don't
|
|
1339
|
-
// leak whether the id exists in another tenant.
|
|
1340
|
-
// SAFETY: row's shape matches the single `tenant_id` column named in
|
|
1341
|
-
// the SELECT above.
|
|
1342
|
-
const row = db
|
|
1343
|
-
.prepare(`SELECT tenant_id FROM memories WHERE id = ?`)
|
|
1344
|
-
.get(id);
|
|
1345
|
-
if (!row || row.tenant_id !== ctx.tenantId) {
|
|
1346
|
-
throw new NotFoundError(`memory not found: ${id}`);
|
|
1347
|
-
}
|
|
1348
|
-
archiveRawMemory(db, id, {
|
|
1349
|
-
reason,
|
|
1350
|
-
who: ctx.actor.subject,
|
|
1351
|
-
afterArchive: opts.afterArchive,
|
|
1352
|
-
});
|
|
1353
|
-
// archiveRawMemory deletes the memories row but leaves any legacy markdown
|
|
1354
|
-
// mirror in <root>/{buffer,episodic,semantic}/<id>.md untouched. If we left
|
|
1355
|
-
// the mirror in place, a subsequent initStore() on an empty memories table
|
|
1356
|
-
// would silently re-import the row via bootstrapLegacyStore — defeating the
|
|
1357
|
-
// archive (and the GDPR right-to-be-forgotten promise on raw rows). Mirror
|
|
1358
|
-
// forget() at src/store.ts:1046, which uses the same removeEntryMirrors call.
|
|
1359
|
-
// The DB transaction has already committed; if filesystem unlink fails here
|
|
1360
|
-
// we log and continue. The mirror reaper in openHippoDb will catch it on
|
|
1361
|
-
// next DB open: raw_archive.mirror_cleaned_at stays NULL until every layer
|
|
1362
|
-
// mirror for this id is gone, so the reaper genuinely retries.
|
|
1363
|
-
try {
|
|
1364
|
-
removeEntryMirrors(ctx.hippoRoot, id);
|
|
1365
|
-
mirrorOk = true;
|
|
1366
|
-
}
|
|
1367
|
-
catch (mirrorErr) {
|
|
1368
|
-
log.error(`archiveRaw: mirror cleanup failed for ${id} (will retry via reaper on next openHippoDb): ${mirrorErr instanceof Error ? mirrorErr.message : String(mirrorErr)}`);
|
|
1369
|
-
}
|
|
1370
|
-
if (mirrorOk) {
|
|
1371
|
-
// Stamp mirror_cleaned_at now so the next openHippoDb reaper SELECT
|
|
1372
|
-
// returns empty for this row. NULL stays untouched on failure -> retry.
|
|
1373
|
-
db.prepare(`UPDATE raw_archive SET mirror_cleaned_at = ? WHERE memory_id = ?`).run(new Date().toISOString(), id);
|
|
1374
|
-
}
|
|
1375
|
-
}
|
|
1376
|
-
finally {
|
|
1377
|
-
closeHippoDb(db);
|
|
1378
|
-
}
|
|
1379
|
-
// Counted here rather than in the CLI: the HTTP archive route calls this too,
|
|
1380
|
-
// so a routed archive would otherwise never reach the forgotten counter.
|
|
1381
|
-
updateStats(ctx.hippoRoot, { forgotten: 1 });
|
|
1382
|
-
// archiveRawMemory does not return the archive_at timestamp it wrote. We
|
|
1383
|
-
// emit a fresh ISO timestamp here for the API response. Within a millisecond
|
|
1384
|
-
// of the actual write, fine for a server response shape.
|
|
1385
|
-
return { ok: true, archivedAt: new Date().toISOString() };
|
|
1386
|
-
}
|
|
1387
|
-
/**
|
|
1388
|
-
* Mint a new API key. The new key is ALWAYS bound to `ctx.tenantId`. Callers
|
|
1389
|
-
* cannot override the tenant via the opts bag — a previous `tenantId` field
|
|
1390
|
-
* was removed because the HTTP layer would happily forward `body.tenantId`,
|
|
1391
|
-
* letting tenant A mint a key for tenant B. The HTTP route handler at
|
|
1392
|
-
* `src/server.ts` POST /v1/auth/keys mirrors this: it ignores any body
|
|
1393
|
-
* `tenantId` and uses the resolved Bearer's tenant exclusively.
|
|
1394
|
-
*
|
|
1395
|
-
* Only an admin actor can mint (ForbiddenError otherwise), and a key never
|
|
1396
|
-
* outranks its minter: a resolver admin is tenant-only, so it mints members.
|
|
1397
|
-
*/
|
|
1398
|
-
export function authCreate(ctx, opts) {
|
|
1399
|
-
if (ctx.actor.role !== 'admin') {
|
|
1400
|
-
throw new ForbiddenError('Only an admin key can create API keys');
|
|
1401
|
-
}
|
|
1402
|
-
if (ctx.actor.viaAuthResolver && opts.role === 'admin') {
|
|
1403
|
-
throw new ForbiddenError('A key minted through the auth resolver can only be a member key');
|
|
1404
|
-
}
|
|
1405
|
-
const db = openHippoDb(ctx.hippoRoot);
|
|
1406
|
-
try {
|
|
1407
|
-
const role = opts.role ?? (ctx.actor.viaAuthResolver ? 'member' : 'admin');
|
|
1408
|
-
const result = createApiKey(db, { tenantId: ctx.tenantId, label: opts.label, role });
|
|
1409
|
-
// v1.12.4: audit emit (closes the gap v1.12.3 CHANGELOG flagged as deferred).
|
|
1410
|
-
// Mirrors the auth_revoke pattern at authRevoke — same try/catch so audit
|
|
1411
|
-
// failure can't crash a successful mint. The plaintext is NEVER logged;
|
|
1412
|
-
// metadata carries label + role + the keyId (which is non-secret).
|
|
1413
|
-
try {
|
|
1414
|
-
appendAuditEvent(db, {
|
|
1415
|
-
tenantId: ctx.tenantId,
|
|
1416
|
-
actor: ctx.actor.subject,
|
|
1417
|
-
op: 'auth_create',
|
|
1418
|
-
targetId: result.keyId,
|
|
1419
|
-
metadata: {
|
|
1420
|
-
label: opts.label ?? null,
|
|
1421
|
-
role,
|
|
1422
|
-
},
|
|
1423
|
-
});
|
|
1424
|
-
}
|
|
1425
|
-
catch (error) {
|
|
1426
|
-
// Audit must not crash a successful mint.
|
|
1427
|
-
reportAuditWriteFailure('auth_create', String(error), result.keyId);
|
|
1428
|
-
}
|
|
1429
|
-
return { keyId: result.keyId, plaintext: result.plaintext, tenantId: ctx.tenantId, role };
|
|
1430
|
-
}
|
|
1431
|
-
finally {
|
|
1432
|
-
closeHippoDb(db);
|
|
1433
|
-
}
|
|
1434
|
-
}
|
|
1435
|
-
/**
|
|
1436
|
-
* List API keys visible to the calling tenant.
|
|
1437
|
-
*
|
|
1438
|
-
* Divergence from `cmdAuthList` in src/cli.ts: the CLI today returns ALL keys
|
|
1439
|
-
* regardless of tenant (single-tenant deployments). The API surface is tenant-
|
|
1440
|
-
* scoped because future multi-tenant deployments will share a hippoRoot, and
|
|
1441
|
-
* tenant A must not see tenant B's keys. Read-only — no audit emit (matches A5).
|
|
1442
|
-
*/
|
|
1443
|
-
export function authList(ctx, opts) {
|
|
1444
|
-
const db = openHippoDb(ctx.hippoRoot);
|
|
1445
|
-
try {
|
|
1446
|
-
const all = listApiKeys(db, opts);
|
|
1447
|
-
return all.filter((k) => k.tenantId === ctx.tenantId);
|
|
1448
|
-
}
|
|
1449
|
-
finally {
|
|
1450
|
-
closeHippoDb(db);
|
|
1451
|
-
}
|
|
1452
|
-
}
|
|
1453
|
-
export function authRevoke(ctx, keyId) {
|
|
1454
|
-
if (ctx.actor.role !== 'admin' && ctx.actor.subject !== `api_key:${keyId}`) {
|
|
1455
|
-
throw new ForbiddenError('A member key can revoke only itself');
|
|
1456
|
-
}
|
|
1457
|
-
const db = openHippoDb(ctx.hippoRoot);
|
|
1458
|
-
try {
|
|
1459
|
-
// SAFETY: row's shape matches the four columns named in the SELECT
|
|
1460
|
-
// above.
|
|
1461
|
-
const row = db
|
|
1462
|
-
.prepare(`SELECT key_id, tenant_id, revoked_at, role FROM api_keys WHERE key_id = ?`)
|
|
1463
|
-
.get(keyId);
|
|
1464
|
-
if (!row) {
|
|
1465
|
-
throw new NotFoundError(`Unknown key_id: ${keyId}`);
|
|
1466
|
-
}
|
|
1467
|
-
// Cross-tenant access denied: same message as missing key, no info leak.
|
|
1468
|
-
if (row.tenant_id !== ctx.tenantId) {
|
|
1469
|
-
throw new NotFoundError(`Unknown key_id: ${keyId}`);
|
|
1470
|
-
}
|
|
1471
|
-
if (ctx.actor.viaAuthResolver && row.role === 'admin') {
|
|
1472
|
-
throw new ForbiddenError('An auth resolver admin cannot revoke an admin key, which outranks it');
|
|
1473
|
-
}
|
|
1474
|
-
let revokedAt;
|
|
1475
|
-
let alreadyRevoked = false;
|
|
1476
|
-
if (row.revoked_at) {
|
|
1477
|
-
alreadyRevoked = true;
|
|
1478
|
-
revokedAt = row.revoked_at;
|
|
1479
|
-
}
|
|
1480
|
-
else {
|
|
1481
|
-
revokeApiKey(db, keyId);
|
|
1482
|
-
// SAFETY: updated's shape matches the single `revoked_at` column named
|
|
1483
|
-
// in the SELECT above.
|
|
1484
|
-
const updated = db
|
|
1485
|
-
.prepare(`SELECT revoked_at FROM api_keys WHERE key_id = ?`)
|
|
1486
|
-
.get(keyId);
|
|
1487
|
-
revokedAt = updated?.revoked_at ?? new Date().toISOString();
|
|
1488
|
-
}
|
|
1489
|
-
if (!alreadyRevoked) {
|
|
1490
|
-
try {
|
|
1491
|
-
appendAuditEvent(db, {
|
|
1492
|
-
tenantId: row.tenant_id, // M1: KEY's tenant, not ctx.tenantId.
|
|
1493
|
-
actor: ctx.actor.subject,
|
|
1494
|
-
op: 'auth_revoke',
|
|
1495
|
-
targetId: keyId,
|
|
1496
|
-
});
|
|
1497
|
-
}
|
|
1498
|
-
catch (error) {
|
|
1499
|
-
// Audit must not crash a successful revoke.
|
|
1500
|
-
reportAuditWriteFailure('auth_revoke', String(error), keyId);
|
|
1501
|
-
}
|
|
1502
|
-
}
|
|
1503
|
-
return { ok: true, revokedAt };
|
|
1504
|
-
}
|
|
1505
|
-
finally {
|
|
1506
|
-
closeHippoDb(db);
|
|
1507
|
-
}
|
|
1508
|
-
}
|
|
1509
|
-
/** Grant `keyId` read access to one restricted `scope` (ROADMAP Part VIII EI2). Admin only. */
|
|
1510
|
-
export function authGrant(ctx, keyId, scope) {
|
|
1511
|
-
return changeScopeGrant(ctx, keyId, scope, 'auth_grant');
|
|
1512
|
-
}
|
|
1513
|
-
/** Revoke `keyId`'s grant on `scope`. Same authorization and lookup rules as authGrant. */
|
|
1514
|
-
export function authUngrant(ctx, keyId, scope) {
|
|
1515
|
-
return changeScopeGrant(ctx, keyId, scope, 'auth_ungrant');
|
|
1516
|
-
}
|
|
1517
|
-
function changeScopeGrant(ctx, keyId, scope, op) {
|
|
1518
|
-
if (ctx.actor.role !== 'admin') {
|
|
1519
|
-
throw new ForbiddenError('Only an admin key can change scope grants');
|
|
1520
|
-
}
|
|
1521
|
-
const db = openHippoDb(ctx.hippoRoot);
|
|
1522
|
-
try {
|
|
1523
|
-
// SAFETY: row's shape matches the single tenant_id column in the SELECT.
|
|
1524
|
-
const row = db
|
|
1525
|
-
.prepare(`SELECT tenant_id, revoked_at FROM api_keys WHERE key_id = ?`)
|
|
1526
|
-
.get(keyId);
|
|
1527
|
-
if (!row || row.tenant_id !== ctx.tenantId) {
|
|
1528
|
-
throw new NotFoundError(`Unknown key_id: ${keyId}`);
|
|
1529
|
-
}
|
|
1530
|
-
if (op === 'auth_grant' && row.revoked_at) {
|
|
1531
|
-
throw new ConflictError(`${keyId} is revoked; a grant on it would never apply`);
|
|
1532
|
-
}
|
|
1533
|
-
if (!isRestrictedScope(scope)) {
|
|
1534
|
-
throw new BadRequestError(`${scope} is not a restricted scope; it is already readable by default`);
|
|
1535
|
-
}
|
|
1536
|
-
if (op === 'auth_grant')
|
|
1537
|
-
grantScope(db, keyId, scope);
|
|
1538
|
-
else
|
|
1539
|
-
ungrantScope(db, keyId, scope);
|
|
1540
|
-
try {
|
|
1541
|
-
appendAuditEvent(db, { tenantId: ctx.tenantId, actor: ctx.actor.subject, op, targetId: keyId, metadata: { scope } });
|
|
1542
|
-
}
|
|
1543
|
-
catch (err) {
|
|
1544
|
-
// Audit must not undo a grant change that already committed; surface it instead.
|
|
1545
|
-
reportAuditWriteFailure(op, String(err), keyId);
|
|
1546
|
-
}
|
|
1547
|
-
return { ok: true };
|
|
1548
|
-
}
|
|
1549
|
-
finally {
|
|
1550
|
-
closeHippoDb(db);
|
|
1551
|
-
}
|
|
1552
|
-
}
|
|
1553
|
-
/**
|
|
1554
|
-
* Read audit events scoped to `ctx.tenantId`. Read-only — no audit emit (matches
|
|
1555
|
-
* A5: cmdAuditList does not record a 'recall'-style read event).
|
|
1556
|
-
*/
|
|
1557
|
-
export function auditList(ctx, opts) {
|
|
1558
|
-
const db = openHippoDb(ctx.hippoRoot);
|
|
1559
|
-
try {
|
|
1560
|
-
return queryAuditEvents(db, {
|
|
1561
|
-
tenantId: ctx.tenantId,
|
|
1562
|
-
op: opts.op,
|
|
1563
|
-
since: opts.since,
|
|
1564
|
-
limit: opts.limit,
|
|
1565
|
-
});
|
|
1566
|
-
}
|
|
1567
|
-
finally {
|
|
1568
|
-
closeHippoDb(db);
|
|
1569
|
-
}
|
|
1570
|
-
}
|
|
1571
|
-
const finiteOr = (v, dflt, min) => Number.isFinite(v) && v >= min ? v : dflt;
|
|
1572
|
-
/**
|
|
1573
|
-
* Assemble a context bundle: recalled memories (pinned-only / strength-sorted
|
|
1574
|
-
* fallback / hybrid search) + active task snapshot + session handoff + recent
|
|
1575
|
-
* session events. Budget-bounded, tenant-scoped. Mutates `last_retrieval_ids`
|
|
1576
|
-
* + emits a 'recall' audit row for non-pinned, non-'*' queries.
|
|
1577
|
-
*
|
|
1578
|
-
* Behaves like the pre-extraction `cmdContext` data-loading + selection
|
|
1579
|
-
* pipeline. CLI presentation (markdown / json / additional-context rendering)
|
|
1580
|
-
* stays in `cli.ts`.
|
|
1581
|
-
*
|
|
1582
|
-
* Tenant scope: all `loadAllEntries` / snapshot / handoff / events reads use
|
|
1583
|
-
* `ctx.tenantId`. Cross-tenant rows are filtered out.
|
|
1584
|
-
*
|
|
1585
|
-
* Returns an empty result (`entries: []`, snapshot/handoff/events undefined)
|
|
1586
|
-
* when there's nothing to surface (no memories AND no snapshot AND no handoff
|
|
1587
|
-
* AND no recent events).
|
|
1588
|
-
*/
|
|
1589
|
-
export async function getContext(ctx, opts = {}) {
|
|
1590
|
-
const pinnedOnly = opts.pinnedOnly === true;
|
|
1591
|
-
const budget = opts.budget ?? 1500;
|
|
1592
|
-
const limit = opts.limit ?? Number.POSITIVE_INFINITY;
|
|
1593
|
-
const includeRecent = opts.includeRecent ?? 0;
|
|
1594
|
-
const activeScope = opts.scope ?? '';
|
|
1595
|
-
assertScopeRequestAllowed(ctx.actor, opts.exactScope);
|
|
1596
|
-
const exactScope = opts.exactScope || undefined;
|
|
1597
|
-
if (budget <= 0) {
|
|
1598
|
-
return { entries: [], tokens: 0 };
|
|
1599
|
-
}
|
|
1600
|
-
// Global memories do not establish a project boundary for task state.
|
|
1601
|
-
const hasLocal = isInitialized(ctx.hippoRoot);
|
|
1602
|
-
const query = (opts.q ?? '').trim() || '*';
|
|
1603
|
-
const globalRoot = getGlobalRoot();
|
|
1604
|
-
const hasGlobal = isInitialized(globalRoot);
|
|
1605
|
-
const primaryIsGlobal = isGlobalStoreRoot(ctx.hippoRoot);
|
|
1606
|
-
const hasLocalTaskState = hasLocal && !primaryIsGlobal;
|
|
1607
|
-
// v39 memory scope isolation (docs/plans/2026-07-01-memory-scope-isolation.md).
|
|
1608
|
-
// S2: envelope-filter parity with api.recall; opts.scope is only the tag boost, opts.exactScope the envelope request.
|
|
1609
|
-
// S3: origin partition - other-project memories are excluded unless the
|
|
1610
|
-
// caller explicitly asks for them (crossProject) or isolation is disabled.
|
|
1611
|
-
const config = loadConfig(ctx.hippoRoot);
|
|
1612
|
-
const isolationEnabled = config.contextProjectIsolation !== false;
|
|
1613
|
-
const currentProjectName = opts.currentProject ?? resolveProjectIdentity(process.cwd()).name;
|
|
1614
|
-
const includeCrossProject = opts.crossProject === true || !isolationEnabled;
|
|
1615
|
-
// Z1: decided before the ambient loads so the FTS candidate query below (pinned-only
|
|
1616
|
-
// branch) can piggyback on that connection instead of opening its own.
|
|
1617
|
-
const promptRecallPending = pinnedOnly && Boolean(opts.prompt?.trim()) && config.pinnedInject.promptRecall === true;
|
|
1618
|
-
const promptRecallTerms = promptRecallPending && config.pinnedInject.enabled
|
|
1619
|
-
? Array.from(promptTokens(opts.prompt ?? ''))
|
|
1620
|
-
: [];
|
|
1621
|
-
const recallRequest = promptRecallTerms.length > 0
|
|
1622
|
-
? { terms: promptRecallTerms, limit: Math.floor(finiteOr(config.pinnedInject.promptRecallCandidates, 100, 1)) }
|
|
1623
|
-
: undefined;
|
|
1624
|
-
const cost = opts.cost;
|
|
1625
|
-
const price = (entry, isGlobal, promptRecall) => cost
|
|
1626
|
-
? cost.entry({ entry, isGlobal, promptRecall, origin: entry.origin_project ?? null, category: classifyOriginProject(entry.origin_project, currentProjectName) })
|
|
1627
|
-
: estimateTokens(entry.content);
|
|
1628
|
-
const blockBudget = pinnedOnly && opts.budget === undefined ? config.pinnedInject.budget : budget;
|
|
1629
|
-
const obs = opts.deliveryObserver;
|
|
1630
|
-
obs?.facts({ projectName: currentProjectName, budgetTokens: blockBudget, promptRecall: promptRecallPending });
|
|
1631
|
-
if (pinnedOnly && !config.pinnedInject.enabled)
|
|
1632
|
-
obs?.disabled();
|
|
1633
|
-
let left = cost
|
|
1634
|
-
? Math.max(0, blockBudget - cost.fixed(blockBudget, { cross: includeCrossProject, promptRecall: promptRecallPending, ambient: !pinnedOnly && config.ambient.enabled }))
|
|
1635
|
-
: blockBudget;
|
|
1636
|
-
// Sections print ahead of the memories, so they are paid first; one that does not fit is dropped, as an oversize entry is.
|
|
1637
|
-
const pays = (tokens) => {
|
|
1638
|
-
if (tokens > left)
|
|
1639
|
-
return false;
|
|
1640
|
-
left -= tokens;
|
|
1641
|
-
return true;
|
|
1642
|
-
};
|
|
1643
|
-
// DF1 T2: bounded read — an orphaned snapshot (no later pre-compact
|
|
1644
|
-
// superseded it, no session-end closed it) must age out of this ambient
|
|
1645
|
-
// surface instead of injecting into every future prompt forever. Owner
|
|
1646
|
-
// reads (opts.currentSessionId matches the snapshot's session_id) stay
|
|
1647
|
-
// unbounded; see loadFreshActiveTaskSnapshot's own doc comment for the
|
|
1648
|
-
// exact null/empty-id matching rules.
|
|
1649
|
-
const rowScope = (r) => r?.scope ?? null;
|
|
1650
|
-
const rawActiveSnapshot = hasLocalTaskState
|
|
1651
|
-
? loadFreshActiveTaskSnapshot(ctx.hippoRoot, ctx.tenantId, {
|
|
1652
|
-
sessionId: opts.currentSessionId,
|
|
1653
|
-
})
|
|
1654
|
-
: null;
|
|
1655
|
-
// W1: the same envelope rule ambientAdmitEntry applies to memory rows.
|
|
1656
|
-
const activeSnapshot = rawActiveSnapshot && passesScopeFilterForRecall(rowScope(rawActiveSnapshot), exactScope)
|
|
1657
|
-
? rawActiveSnapshot
|
|
1658
|
-
: null;
|
|
1659
|
-
// Key on the RAW snapshot: a scope-hidden active session must not fall through to another session's ambient handoff.
|
|
1660
|
-
const rawSessionHandoff = !hasLocalTaskState
|
|
1661
|
-
? null
|
|
1662
|
-
: rawActiveSnapshot?.session_id
|
|
1663
|
-
? loadLatestHandoff(ctx.hippoRoot, ctx.tenantId, rawActiveSnapshot.session_id)
|
|
1664
|
-
: loadLatestHandoff(ctx.hippoRoot, ctx.tenantId, undefined, {
|
|
1665
|
-
unfinishedOnly: true,
|
|
1666
|
-
maxAgeMs: SNAPSHOT_AMBIENT_MAX_AGE_MS,
|
|
1667
|
-
// codex P2: admit scope in SQL so a newer denied row can't hide an older eligible one before LIMIT 1.
|
|
1668
|
-
scopeFilter: 'default-deny',
|
|
1669
|
-
});
|
|
1670
|
-
const sessionHandoff = rawSessionHandoff && passesScopeFilterForRecall(rowScope(rawSessionHandoff), exactScope)
|
|
1671
|
-
? rawSessionHandoff
|
|
1672
|
-
: null;
|
|
1673
|
-
// Raw session id here too: each event is admitted on its own scope, same as recall and the CLI.
|
|
1674
|
-
const recentSessionEvents = hasLocalTaskState && rawActiveSnapshot?.session_id
|
|
1675
|
-
? listSessionEvents(ctx.hippoRoot, ctx.tenantId, {
|
|
1676
|
-
session_id: rawActiveSnapshot.session_id,
|
|
1677
|
-
limit: 5,
|
|
1678
|
-
}).filter((e) => passesScopeFilterForRecall(rowScope(e), exactScope))
|
|
1679
|
-
: [];
|
|
1680
|
-
const shownSnapshot = activeSnapshot && (!cost || pays(cost.snapshot(activeSnapshot))) ? activeSnapshot : null;
|
|
1681
|
-
const shownHandoff = sessionHandoff && (!cost || pays(cost.handoff(sessionHandoff))) ? sessionHandoff : null;
|
|
1682
|
-
const shownEvents = recentSessionEvents.length > 0 && (!cost || pays(cost.trail(recentSessionEvents))) ? recentSessionEvents : [];
|
|
1683
|
-
obs?.sections(Number(shownSnapshot !== null) + Number(shownHandoff !== null) + Number(shownEvents.length > 0), Number(activeSnapshot !== shownSnapshot) + Number(sessionHandoff !== shownHandoff) + Number(recentSessionEvents.length !== shownEvents.length));
|
|
1684
|
-
const transcriptHandoffSession = shownHandoff?.evidence?.derivedFrom === 'transcript' ? shownHandoff.sessionId : null;
|
|
1685
|
-
let digestHiddenForHandoff = false;
|
|
1686
|
-
const ambientAdmit = (e) => {
|
|
1687
|
-
// A printed handoff already carries the session's closing message, which its digest would print a second time.
|
|
1688
|
-
if (transcriptHandoffSession !== null && e.source_session_id === transcriptHandoffSession && isSessionDigestRow(e)) {
|
|
1689
|
-
digestHiddenForHandoff = true;
|
|
1690
|
-
return false;
|
|
1691
|
-
}
|
|
1692
|
-
return ambientAdmitEntry(e, currentProjectName, includeCrossProject, exactScope);
|
|
1693
|
-
};
|
|
1694
|
-
const ownSessionId = opts.currentSessionId || '';
|
|
1695
|
-
// Inside admit, not after the load, so the loader's window widens past a session's own items.
|
|
1696
|
-
const isOwnCompactionItem = (e) => ownSessionId !== '' &&
|
|
1697
|
-
e.source_session_id === ownSessionId &&
|
|
1698
|
-
e.tags.includes(COMPACTION_MEMORY_TAG);
|
|
1699
|
-
// Superseded rows never inject; which rows reach ambientAdmitEntry matters because it regex-scans content for secrets.
|
|
1700
|
-
const admit = (e) => !e.superseded_by && !isOwnCompactionItem(e) && ambientAdmit(e);
|
|
1701
|
-
const loadAdmit = obs ? obs.watchAdmit(admit) : admit;
|
|
1702
|
-
const qualityDrop = (isGlobal) => obs && !promptRecallPending ? (e) => obs.qualityDropped(e, isGlobal) : undefined;
|
|
1703
|
-
// The window's predicates are ones admit applies anyway, so below the cap the admitted rows are unchanged.
|
|
1704
|
-
const searchesLocalRows = query !== '*' && !(hasGlobal && !primaryIsGlobal);
|
|
1705
|
-
const window = pinnedOnly || searchesLocalRows
|
|
1706
|
-
? undefined
|
|
1707
|
-
: {
|
|
1708
|
-
exactScope,
|
|
1709
|
-
project: includeCrossProject || currentProjectName === '' ? undefined : currentProjectName,
|
|
1710
|
-
cap: CONTEXT_CANDIDATE_CAP,
|
|
1711
|
-
now: evalNow(),
|
|
1712
|
-
};
|
|
1713
|
-
// Tenant-scoped loads (v1.11.1 lesson: NEVER resolveTenantId({}) here).
|
|
1714
|
-
const localLoad = hasLocal
|
|
1715
|
-
? loadAmbientEntries(ctx.hippoRoot, ctx.tenantId, pinnedOnly, includeRecent, loadAdmit, recallRequest, qualityDrop(primaryIsGlobal), window)
|
|
1716
|
-
: { entries: [] };
|
|
1717
|
-
const globalLoad = hasGlobal && !primaryIsGlobal
|
|
1718
|
-
? loadAmbientEntries(globalRoot, ctx.tenantId, pinnedOnly, includeRecent, loadAdmit, recallRequest, qualityDrop(true), window)
|
|
1719
|
-
: { entries: [] };
|
|
1720
|
-
let localEntries = localLoad.entries;
|
|
1721
|
-
let globalEntries = globalLoad.entries;
|
|
1722
|
-
// Computed after markRetrieved runs, so avgStrength reflects post-retrieval strengths.
|
|
1723
|
-
let ambientState;
|
|
1724
|
-
if (!promptRecallPending &&
|
|
1725
|
-
localEntries.length === 0 &&
|
|
1726
|
-
globalEntries.length === 0 &&
|
|
1727
|
-
!shownSnapshot &&
|
|
1728
|
-
!shownHandoff &&
|
|
1729
|
-
shownEvents.length === 0) {
|
|
1730
|
-
return { entries: [], tokens: 0 };
|
|
1731
|
-
}
|
|
1732
|
-
let selectedItems = [];
|
|
1733
|
-
let totalTokens = 0;
|
|
1734
|
-
if (pinnedOnly) {
|
|
1735
|
-
// loadConfig is safe even when local isn't initialised — returns defaults.
|
|
1736
|
-
const pinnedCfg = loadConfig(ctx.hippoRoot);
|
|
1737
|
-
if (!pinnedCfg.pinnedInject.enabled) {
|
|
1738
|
-
return { entries: [], tokens: 0 };
|
|
1739
|
-
}
|
|
1740
|
-
// Effective budget: explicit opts.budget wins over config, less what the sections took.
|
|
1741
|
-
const effBudget = left;
|
|
1742
|
-
const nowP = evalNow(); // honors HIPPO_FAKE_NOW (eval-only; see ablation.ts)
|
|
1743
|
-
obs?.offer(localEntries, primaryIsGlobal);
|
|
1744
|
-
obs?.offer(globalEntries, true);
|
|
1745
|
-
const [localPool, globalPool] = oneCopyPerMemory(localEntries, globalEntries, nowP);
|
|
1746
|
-
obs?.dropMissing([...localEntries, ...globalEntries], [...localPool, ...globalPool], 'load', 'duplicate');
|
|
1747
|
-
const selectedIds = new Set();
|
|
1748
|
-
let usedP = 0;
|
|
1749
|
-
// Pinned entries are explicit user intent, the recent-N list an automatic
|
|
1750
|
-
// backfill. Both loops share ONE budget and the recent loop runs first, so
|
|
1751
|
-
// pins are ranked here and reserve their share before it can spend.
|
|
1752
|
-
const pinnedLocal = localPool.filter((e) => e.pinned);
|
|
1753
|
-
const pinnedGlobal = globalPool.filter((e) => e.pinned);
|
|
1754
|
-
const rankedPinned = [
|
|
1755
|
-
...pinnedLocal.map((e) => ({ entry: e, isGlobal: primaryIsGlobal })),
|
|
1756
|
-
...pinnedGlobal.map((e) => ({ entry: e, isGlobal: true })),
|
|
1757
|
-
]
|
|
1758
|
-
.map(({ entry, isGlobal }) => {
|
|
1759
|
-
const scopeSig = scopeMatch(entry.tags, activeScope);
|
|
1760
|
-
const sBst = scopeSig === 1 ? 1.5 : scopeSig === -1 ? 0.5 : 1.0;
|
|
1761
|
-
return {
|
|
1762
|
-
entry,
|
|
1763
|
-
score: calculateStrength(entry, nowP) * (isGlobal ? 1 / 1.2 : 1) * sBst,
|
|
1764
|
-
tokens: price(entry, isGlobal),
|
|
1765
|
-
isGlobal,
|
|
1766
|
-
};
|
|
1767
|
-
})
|
|
1768
|
-
.sort(compareScoredResults);
|
|
1769
|
-
// Mirror the pinned admission loop's own `continue`-not-`break`
|
|
1770
|
-
// semantics (further down) so the reserve equals what that loop will
|
|
1771
|
-
// actually admit -- a big pin near the front should not block smaller
|
|
1772
|
-
// pins behind it from reserving their share too.
|
|
1773
|
-
// Dedupe by id: `syncGlobalToLocal` copies global rows into the local
|
|
1774
|
-
// store preserving `entry.id`, so a synced pin appears in BOTH
|
|
1775
|
-
// `pinnedLocal` and `pinnedGlobal` and would otherwise reserve its cost
|
|
1776
|
-
// twice. The admission loop already dedupes via `selectedIds`; the
|
|
1777
|
-
// reserve has to mirror that or it silently starves recents of budget a
|
|
1778
|
-
// single returned pin never needed.
|
|
1779
|
-
let pinnedReserve = 0;
|
|
1780
|
-
const reservedIds = new Set();
|
|
1781
|
-
for (const r of rankedPinned) {
|
|
1782
|
-
if (reservedIds.has(r.entry.id))
|
|
1783
|
-
continue;
|
|
1784
|
-
if (pinnedReserve + r.tokens <= effBudget) {
|
|
1785
|
-
pinnedReserve += r.tokens;
|
|
1786
|
-
reservedIds.add(r.entry.id);
|
|
1787
|
-
}
|
|
1788
|
-
}
|
|
1789
|
-
// Known, accepted tradeoff: a pin that also lands in the recent-N slice
|
|
1790
|
-
// is counted once in `pinnedReserve` (here) AND admitted again by the
|
|
1791
|
-
// recent loop below, so a little budget goes unused (`recentBudget` is
|
|
1792
|
-
// more conservative than it needs to be in that case). That only
|
|
1793
|
-
// under-fills recents slightly -- it never displaces a pin -- so it is
|
|
1794
|
-
// the safe direction and is not worth extra bookkeeping to recover.
|
|
1795
|
-
const recentBudget = Math.max(0, effBudget - pinnedReserve);
|
|
1796
|
-
// Z1: gate the backfill on the prompt instead of recency (docs/plans/2026-09-26-z1-prompt-recall.md).
|
|
1797
|
-
const promptRecallOn = promptRecallPending;
|
|
1798
|
-
if (promptRecallOn) {
|
|
1799
|
-
const rawMetric = pinnedCfg.pinnedInject.promptRecallMetric;
|
|
1800
|
-
const metric = rawMetric === 'cosine' ? 'cosine' : 'jaccard';
|
|
1801
|
-
const gate = {
|
|
1802
|
-
metric,
|
|
1803
|
-
threshold: finiteOr(pinnedCfg.pinnedInject.promptRecallThreshold, 0.04, 0),
|
|
1804
|
-
minShared: finiteOr(pinnedCfg.pinnedInject.promptRecallMinShared, 2, 0),
|
|
1805
|
-
maxItems: finiteOr(pinnedCfg.pinnedInject.promptRecallMaxItems, 5, 1),
|
|
1806
|
-
};
|
|
1807
|
-
const p = promptTokens(opts.prompt ?? '');
|
|
1808
|
-
if (p.size > 0) {
|
|
1809
|
-
// Candidates came off the ambient load's own connection (recallRequest above), not a fresh open.
|
|
1810
|
-
// A candidate carrying a pin's text would inject that memory a second time.
|
|
1811
|
-
const pinnedText = new Set(rankedPinned.map((r) => r.entry.content));
|
|
1812
|
-
const ineligibleReason = (e) => !admit(e) ? 'scope'
|
|
1813
|
-
: e.pinned ? 'pinned'
|
|
1814
|
-
: !isContentWorthStoring(e.content) ? 'quality'
|
|
1815
|
-
: pinnedText.has(e.content) ? 'duplicate'
|
|
1816
|
-
: null;
|
|
1817
|
-
const eligible = (e) => {
|
|
1818
|
-
const why = ineligibleReason(e);
|
|
1819
|
-
if (why !== null && why !== 'pinned')
|
|
1820
|
-
obs?.reject(e, 'eligible', why);
|
|
1821
|
-
return why === null;
|
|
1822
|
-
};
|
|
1823
|
-
obs?.offer(localLoad.recall ?? [], primaryIsGlobal, 'prompt-recall');
|
|
1824
|
-
obs?.offer(globalLoad.recall ?? [], true, 'prompt-recall');
|
|
1825
|
-
const localEligible = (localLoad.recall ?? []).filter(eligible);
|
|
1826
|
-
const globalEligible = (globalLoad.recall ?? []).filter(eligible);
|
|
1827
|
-
const [localCandidates, globalCandidates] = oneCopyPerMemory(localEligible, globalEligible, nowP);
|
|
1828
|
-
obs?.dropMissing([...localEligible, ...globalEligible], [...localCandidates, ...globalCandidates], 'eligible', 'duplicate');
|
|
1829
|
-
const seenCandidateIds = new Set();
|
|
1830
|
-
const candidateItems = [];
|
|
1831
|
-
// Local wins the id collision (a global row synced into the local store).
|
|
1832
|
-
for (const e of localCandidates) {
|
|
1833
|
-
if (seenCandidateIds.has(e.id))
|
|
1834
|
-
continue;
|
|
1835
|
-
seenCandidateIds.add(e.id);
|
|
1836
|
-
candidateItems.push({ id: e.id, tokens: contentTokens(e.content), entry: e, isGlobal: primaryIsGlobal });
|
|
1837
|
-
}
|
|
1838
|
-
for (const e of globalCandidates) {
|
|
1839
|
-
if (seenCandidateIds.has(e.id))
|
|
1840
|
-
continue;
|
|
1841
|
-
seenCandidateIds.add(e.id);
|
|
1842
|
-
candidateItems.push({ id: e.id, tokens: contentTokens(e.content), entry: e, isGlobal: true });
|
|
1843
|
-
}
|
|
1844
|
-
const gated = gatePromptRecall(p, candidateItems, gate);
|
|
1845
|
-
obs?.gated(p, candidateItems, gate, gated);
|
|
1846
|
-
for (const g of gated) {
|
|
1847
|
-
if (selectedIds.has(g.item.id))
|
|
1848
|
-
continue;
|
|
1849
|
-
const tokens = price(g.item.entry, g.item.isGlobal, true);
|
|
1850
|
-
if (usedP + tokens > recentBudget) {
|
|
1851
|
-
obs?.reject(g.item.entry, 'budget', 'budget', g.score, tokens);
|
|
1852
|
-
continue;
|
|
1853
|
-
}
|
|
1854
|
-
selectedItems.push({ entry: g.item.entry, score: g.score, tokens, isGlobal: g.item.isGlobal, promptRecall: true });
|
|
1855
|
-
selectedIds.add(g.item.id);
|
|
1856
|
-
usedP += tokens;
|
|
1857
|
-
}
|
|
1858
|
-
}
|
|
1859
|
-
}
|
|
1860
|
-
else if (includeRecent > 0) {
|
|
1861
|
-
const recent = [
|
|
1862
|
-
...localPool.map((entry) => ({ entry, isGlobal: primaryIsGlobal })),
|
|
1863
|
-
...globalPool.map((entry) => ({ entry, isGlobal: true })),
|
|
1864
|
-
]
|
|
1865
|
-
// T2 (src/compare.ts) note: this already carries an explicit
|
|
1866
|
-
// per-instance tiebreak (created desc -> id localeCompare) and is
|
|
1867
|
-
// deliberately left as-is rather than routed through
|
|
1868
|
-
// compareEntryIdentity. `created` reflects ingest order, so it is
|
|
1869
|
-
// cross-ingest stable at ms granularity; the residual is honest,
|
|
1870
|
-
// not silently ignored — rows created in the same millisecond fall
|
|
1871
|
-
// to `id.localeCompare`, which is per-instance random (id is
|
|
1872
|
-
// crypto.randomUUID()), so this listing is per-instance-
|
|
1873
|
-
// deterministic but NOT cross-ingest-stable under same-ms
|
|
1874
|
-
// collisions.
|
|
1875
|
-
.sort((a, b) => {
|
|
1876
|
-
const byCreated = Date.parse(b.entry.created) - Date.parse(a.entry.created);
|
|
1877
|
-
return byCreated !== 0 ? byCreated : b.entry.id.localeCompare(a.entry.id);
|
|
1878
|
-
})
|
|
1879
|
-
// DF3 (docs/plans/2026-08-23-df3-include-recent-quality-floor.md):
|
|
1880
|
-
// filter before slice, not after — the caller asked for N recent
|
|
1881
|
-
// *useful* entries, so a junk row must be skipped and backfilled
|
|
1882
|
-
// past, not counted against the N. Skip-only: no mutation, no audit
|
|
1883
|
-
// row, nothing becomes unrecoverable.
|
|
1884
|
-
//
|
|
1885
|
-
// `entry.pinned ||` bypass IS needed here (codex review finding,
|
|
1886
|
-
// corrects the earlier claim in this comment that it wasn't): under
|
|
1887
|
-
// budget pressure, a pinned entry that fails the heuristic gets
|
|
1888
|
-
// dropped from this recent slice, and an unpinned entry backfills
|
|
1889
|
-
// into its slot and consumes `usedP` in the loop below. By the time
|
|
1890
|
-
// the pinned block runs (further down), the budget it needed is
|
|
1891
|
-
// already spent, so it hits `continue` and the pinned entry is
|
|
1892
|
-
// omitted entirely — the pinned block is NOT a safety net once the
|
|
1893
|
-
// recent loop has already spent the shared budget.
|
|
1894
|
-
.filter(({ entry }) => entry.pinned || isContentWorthStoring(entry.content))
|
|
1895
|
-
.slice(0, includeRecent)
|
|
1896
|
-
.map(({ entry, isGlobal }) => ({
|
|
1897
|
-
entry,
|
|
1898
|
-
score: calculateStrength(entry, nowP) * (isGlobal ? 1 / 1.2 : 1),
|
|
1899
|
-
tokens: price(entry, isGlobal),
|
|
1900
|
-
isGlobal,
|
|
1901
|
-
}));
|
|
1902
|
-
for (const r of recent) {
|
|
1903
|
-
if (selectedIds.has(r.entry.id))
|
|
1904
|
-
continue;
|
|
1905
|
-
if (usedP + r.tokens > recentBudget) {
|
|
1906
|
-
obs?.reject(r.entry, 'budget', 'budget', r.score, r.tokens);
|
|
1907
|
-
continue;
|
|
1908
|
-
}
|
|
1909
|
-
selectedItems.push(r);
|
|
1910
|
-
selectedIds.add(r.entry.id);
|
|
1911
|
-
usedP += r.tokens;
|
|
1912
|
-
}
|
|
1913
|
-
}
|
|
1914
|
-
if (pinnedLocal.length === 0 &&
|
|
1915
|
-
pinnedGlobal.length === 0 &&
|
|
1916
|
-
selectedItems.length === 0 &&
|
|
1917
|
-
!digestHiddenForHandoff) {
|
|
1918
|
-
return { entries: [], tokens: 0 };
|
|
1919
|
-
}
|
|
1920
|
-
for (const r of rankedPinned) {
|
|
1921
|
-
if (selectedIds.has(r.entry.id))
|
|
1922
|
-
continue;
|
|
1923
|
-
if (usedP + r.tokens > effBudget) {
|
|
1924
|
-
obs?.reject(r.entry, 'budget', 'budget', r.score, r.tokens);
|
|
1925
|
-
continue;
|
|
1926
|
-
}
|
|
1927
|
-
selectedItems.push(r);
|
|
1928
|
-
selectedIds.add(r.entry.id);
|
|
1929
|
-
usedP += r.tokens;
|
|
1930
|
-
}
|
|
1931
|
-
totalTokens = usedP;
|
|
1932
|
-
}
|
|
1933
|
-
else if (query === '*') {
|
|
1934
|
-
// No query: return strongest memories by strength, up to budget.
|
|
1935
|
-
const now = evalNow(); // honors HIPPO_FAKE_NOW (eval-only; see ablation.ts)
|
|
1936
|
-
const [localPool, globalPool] = oneCopyPerMemory(localEntries, globalEntries, now);
|
|
1937
|
-
const localRanked = localPool
|
|
1938
|
-
.map((e) => ({
|
|
1939
|
-
entry: e,
|
|
1940
|
-
score: calculateStrength(e, now),
|
|
1941
|
-
tokens: price(e, primaryIsGlobal),
|
|
1942
|
-
isGlobal: primaryIsGlobal,
|
|
1943
|
-
}))
|
|
1944
|
-
.sort(compareScoredResults);
|
|
1945
|
-
const globalRanked = globalPool
|
|
1946
|
-
.map((e) => ({
|
|
1947
|
-
entry: e,
|
|
1948
|
-
score: calculateStrength(e, now) * (1 / 1.2),
|
|
1949
|
-
tokens: price(e, true),
|
|
1950
|
-
isGlobal: true,
|
|
1951
|
-
}))
|
|
1952
|
-
.sort(compareScoredResults);
|
|
1953
|
-
const combined = [...localRanked, ...globalRanked].sort(compareScoredResults);
|
|
1954
|
-
let used = 0;
|
|
1955
|
-
for (const r of combined) {
|
|
1956
|
-
if (used + r.tokens > left)
|
|
1957
|
-
continue;
|
|
1958
|
-
selectedItems.push(r);
|
|
1959
|
-
used += r.tokens;
|
|
1960
|
-
}
|
|
1961
|
-
totalTokens = used;
|
|
1962
|
-
}
|
|
1963
|
-
else {
|
|
1964
|
-
// Real query: hybrid search (global + local) or physics+hybrid (local only).
|
|
1965
|
-
let results;
|
|
1966
|
-
const minResults = cost ? 0 : undefined; // a priced block skips an oversize top hit too, so the budget bounds it
|
|
1967
|
-
if (hasGlobal && !primaryIsGlobal) {
|
|
1968
|
-
// searchBothHybrid loads from the store roots itself, so the ambient
|
|
1969
|
-
// filter above never saw its candidates. Admission runs INSIDE the
|
|
1970
|
-
// search via the opt-in entryFilter, BEFORE ranking, cross-store
|
|
1971
|
-
// content-dedupe, and budgeting - a post-filter instead would let an
|
|
1972
|
-
// excluded row saturate the budget (codex rounds 1+3) or shadow its
|
|
1973
|
-
// admitted duplicate in the dedupe pass (codex round 4). Recall paths
|
|
1974
|
-
// never set entryFilter, so their behavior is unchanged.
|
|
1975
|
-
const localIndex = loadIndex(ctx.hippoRoot);
|
|
1976
|
-
const isGlobalHit = (e) => !localIndex.entries[e.id];
|
|
1977
|
-
const merged = await searchBothHybrid(query, ctx.hippoRoot, globalRoot, {
|
|
1978
|
-
budget: left,
|
|
1979
|
-
minResults,
|
|
1980
|
-
cost: cost && ((r) => price(r.entry, isGlobalHit(r.entry))),
|
|
1981
|
-
scope: activeScope,
|
|
1982
|
-
tenantId: ctx.tenantId,
|
|
1983
|
-
entryFilter: ambientAdmit,
|
|
1984
|
-
});
|
|
1985
|
-
results = merged.map((r) => ({
|
|
1986
|
-
entry: r.entry,
|
|
1987
|
-
score: r.score,
|
|
1988
|
-
tokens: price(r.entry, isGlobalHit(r.entry)),
|
|
1989
|
-
isGlobal: isGlobalHit(r.entry),
|
|
1990
|
-
}));
|
|
1991
|
-
}
|
|
1992
|
-
else {
|
|
1993
|
-
const ctxConfig = loadConfig(ctx.hippoRoot);
|
|
1994
|
-
const usePhysicsCtx = ctxConfig.physics?.enabled !== false;
|
|
1995
|
-
const localCost = cost && ((r) => price(r.entry, primaryIsGlobal));
|
|
1996
|
-
const ctxResults = usePhysicsCtx
|
|
1997
|
-
? await physicsSearch(query, localEntries, {
|
|
1998
|
-
budget: left,
|
|
1999
|
-
minResults,
|
|
2000
|
-
cost: localCost,
|
|
2001
|
-
hippoRoot: ctx.hippoRoot,
|
|
2002
|
-
physicsConfig: ctxConfig.physics,
|
|
2003
|
-
scope: activeScope,
|
|
2004
|
-
})
|
|
2005
|
-
: await hybridSearch(query, localEntries, {
|
|
2006
|
-
budget: left,
|
|
2007
|
-
minResults,
|
|
2008
|
-
cost: localCost,
|
|
2009
|
-
hippoRoot: ctx.hippoRoot,
|
|
2010
|
-
scope: activeScope,
|
|
2011
|
-
});
|
|
2012
|
-
results = ctxResults.map((r) => ({
|
|
2013
|
-
entry: r.entry,
|
|
2014
|
-
score: r.score,
|
|
2015
|
-
tokens: price(r.entry, primaryIsGlobal),
|
|
2016
|
-
isGlobal: primaryIsGlobal,
|
|
2017
|
-
}));
|
|
2018
|
-
}
|
|
2019
|
-
selectedItems = results;
|
|
2020
|
-
totalTokens = results.reduce((sum, r) => sum + r.tokens, 0);
|
|
2021
|
-
// A5 H4: emit recall audit row for context-mode searches (matches the
|
|
2022
|
-
// 'recall' op emitted by api.recall for parity). pinnedOnly + '*' fallback
|
|
2023
|
-
// never hit the search engines, so they don't emit (matches cmdContext).
|
|
2024
|
-
const ctxRecallMetadata = {
|
|
2025
|
-
...auditQueryFields(query),
|
|
2026
|
-
results: selectedItems.length,
|
|
2027
|
-
mode: 'context',
|
|
2028
|
-
};
|
|
2029
|
-
if (hasLocal) {
|
|
2030
|
-
const localDb = openHippoDb(ctx.hippoRoot);
|
|
2031
|
-
try {
|
|
2032
|
-
appendAuditEvent(localDb, {
|
|
2033
|
-
tenantId: ctx.tenantId,
|
|
2034
|
-
actor: ctx.actor.subject,
|
|
2035
|
-
op: 'recall',
|
|
2036
|
-
metadata: ctxRecallMetadata,
|
|
2037
|
-
});
|
|
2038
|
-
}
|
|
2039
|
-
finally {
|
|
2040
|
-
closeHippoDb(localDb);
|
|
2041
|
-
}
|
|
2042
|
-
}
|
|
2043
|
-
if (hasGlobal && !primaryIsGlobal) {
|
|
2044
|
-
const globalDb = openHippoDb(globalRoot);
|
|
2045
|
-
try {
|
|
2046
|
-
appendAuditEvent(globalDb, {
|
|
2047
|
-
tenantId: ctx.tenantId,
|
|
2048
|
-
actor: ctx.actor.subject,
|
|
2049
|
-
op: 'recall',
|
|
2050
|
-
metadata: ctxRecallMetadata,
|
|
2051
|
-
});
|
|
2052
|
-
}
|
|
2053
|
-
finally {
|
|
2054
|
-
closeHippoDb(globalDb);
|
|
2055
|
-
}
|
|
2056
|
-
}
|
|
2057
|
-
}
|
|
2058
|
-
if (limit < selectedItems.length) {
|
|
2059
|
-
const cut = selectedItems.slice(0, limit);
|
|
2060
|
-
obs?.dropMissing(selectedItems.map((r) => r.entry), cut.map((r) => r.entry), 'limit', 'limit');
|
|
2061
|
-
selectedItems = cut;
|
|
2062
|
-
}
|
|
2063
|
-
const heldDropped = dropHeldCopies(selectedItems, (r) => r.entry); // after the last cut, so a merged row that was cut hides nothing
|
|
2064
|
-
obs?.dropMissing(selectedItems.map((r) => r.entry), heldDropped.map((r) => r.entry), 'limit', 'duplicate');
|
|
2065
|
-
selectedItems = heldDropped;
|
|
2066
|
-
totalTokens = selectedItems.reduce((sum, r) => sum + r.tokens, 0);
|
|
2067
|
-
// v39: annotate every returned entry with its origin and how it relates to
|
|
2068
|
-
// the active project, so renderers can demarcate cross-project inclusions.
|
|
2069
|
-
selectedItems = selectedItems.map((r) => ({
|
|
2070
|
-
...r,
|
|
2071
|
-
origin: r.entry.origin_project ?? null,
|
|
2072
|
-
category: classifyOriginProject(r.entry.origin_project, currentProjectName),
|
|
2073
|
-
}));
|
|
2074
|
-
obs?.selected(selectedItems);
|
|
2075
|
-
if (selectedItems.length === 0 &&
|
|
2076
|
-
!shownSnapshot &&
|
|
2077
|
-
!shownHandoff &&
|
|
2078
|
-
shownEvents.length === 0) {
|
|
2079
|
-
// LC1 F5 fix: this bare early-return used to skip tracing entirely — a
|
|
2080
|
-
// query that found nothing is exactly the coverage-gap signal Track LC
|
|
2081
|
-
// needs. Write an empty trace (result_count 0, no result rows) so it
|
|
2082
|
-
// lands in the training corpus. Never touches localIndex/
|
|
2083
|
-
// last_retrieval_ids/last_trace_id — by construction it can't desync
|
|
2084
|
-
// (mirrors the CLI zero-result path). Skipped under pinnedOnly (hot
|
|
2085
|
-
// path stays read-only, same reason it skips markRetrieved). Fail-soft
|
|
2086
|
-
// internally; never throws.
|
|
2087
|
-
if (!pinnedOnly) {
|
|
2088
|
-
// No snapshot in this branch, so the caller's own id is the only session to stamp.
|
|
2089
|
-
writeRecallTraceAtRoot(ctx.hippoRoot, {
|
|
2090
|
-
tenantId: ctx.tenantId,
|
|
2091
|
-
sessionId: opts.currentSessionId || null,
|
|
2092
|
-
pipeline: 'context',
|
|
2093
|
-
query,
|
|
2094
|
-
explainMode: false,
|
|
2095
|
-
results: [],
|
|
2096
|
-
});
|
|
2097
|
-
}
|
|
2098
|
-
return { entries: [], tokens: 0 };
|
|
2099
|
-
}
|
|
2100
|
-
// pinnedOnly is the UserPromptSubmit hot path — read-only so pinned
|
|
2101
|
-
// memories don't inflate retrieval_count or extend half_life by 2 days per
|
|
2102
|
-
// turn over a long session.
|
|
2103
|
-
if (!pinnedOnly) {
|
|
2104
|
-
const toUpdate = selectedItems.map((s) => s.entry);
|
|
2105
|
-
const updatedEntries = markRetrieved(toUpdate);
|
|
2106
|
-
const localIndex = loadIndex(ctx.hippoRoot);
|
|
2107
|
-
const retrievedIds = updatedEntries.map((u) => u.id);
|
|
2108
|
-
const strengthenedHere = strengthenRetrieved(ctx.hippoRoot, retrievedIds);
|
|
2109
|
-
if (hasGlobal)
|
|
2110
|
-
strengthenRetrieved(globalRoot, retrievedIds.filter((id) => !strengthenedHere.has(id)));
|
|
2111
|
-
localIndex.last_retrieval_ids = retrievedIds;
|
|
2112
|
-
// LC1 F1 structural fix (docs/plans/2026-08-02-lc1-recall-trace-persistence.md):
|
|
2113
|
-
// write the trace FIRST — post-limit, post-annotation `selectedItems`
|
|
2114
|
-
// actually returned, on a fresh short-lived connection (the audit
|
|
2115
|
-
// handles above ~2410 are already closed by this point, matching this
|
|
2116
|
-
// block's own per-call-handle convention: writeEntry, saveIndex) — then
|
|
2117
|
-
// fold the resulting id into `localIndex` so the SAME `saveIndex` call
|
|
2118
|
-
// below persists last_retrieval_ids + last_trace_id atomically.
|
|
2119
|
-
// LOCKSTEP INVARIANT: last_trace_id must only ever advance together
|
|
2120
|
-
// with last_retrieval_ids; a two-connection stamp-then-clear design
|
|
2121
|
-
// could desync them on a crash between writes. A failed trace write
|
|
2122
|
-
// (traceId null) sets last_trace_id to null rather than leaving the
|
|
2123
|
-
// OLD id pointing at ids that are about to be overwritten. Fail-soft
|
|
2124
|
-
// internally; never throws.
|
|
2125
|
-
const traceId = writeRecallTraceAtRoot(ctx.hippoRoot, {
|
|
2126
|
-
tenantId: ctx.tenantId,
|
|
2127
|
-
sessionId: opts.currentSessionId || activeSnapshot?.session_id || null,
|
|
2128
|
-
pipeline: 'context',
|
|
2129
|
-
query,
|
|
2130
|
-
explainMode: false,
|
|
2131
|
-
results: selectedItems.map((s) => ({
|
|
2132
|
-
memoryId: s.entry.id,
|
|
2133
|
-
score: s.score,
|
|
2134
|
-
})),
|
|
2135
|
-
});
|
|
2136
|
-
localIndex.last_trace_id = traceId !== null ? String(traceId) : null;
|
|
2137
|
-
saveIndex(ctx.hippoRoot, localIndex);
|
|
2138
|
-
updateStats(ctx.hippoRoot, { recalled: selectedItems.length });
|
|
2139
|
-
// Replace selectedItems entries with markRetrieved-updated copies so
|
|
2140
|
-
// the returned ContextResult reflects post-recall state.
|
|
2141
|
-
selectedItems = selectedItems.map((s) => ({
|
|
2142
|
-
...s,
|
|
2143
|
-
entry: updatedEntries.find((u) => u.id === s.entry.id) ?? s.entry,
|
|
2144
|
-
}));
|
|
2145
|
-
// Overlay by id (no re-read) so avgStrength reflects post-retrieval strength.
|
|
2146
|
-
if (config.ambient.enabled) {
|
|
2147
|
-
const updatedById = new Map(updatedEntries.map((u) => [u.id, u]));
|
|
2148
|
-
const overlaid = [...localEntries, ...globalEntries].map((e) => updatedById.get(e.id) ?? e);
|
|
2149
|
-
if (overlaid.length > 0) {
|
|
2150
|
-
ambientState = computeAmbientState(overlaid);
|
|
2151
|
-
}
|
|
2152
|
-
}
|
|
2153
|
-
}
|
|
2154
|
-
return {
|
|
2155
|
-
entries: selectedItems,
|
|
2156
|
-
tokens: totalTokens,
|
|
2157
|
-
activeSnapshot: shownSnapshot ?? undefined,
|
|
2158
|
-
sessionHandoff: shownHandoff ?? undefined,
|
|
2159
|
-
recentEvents: shownEvents.length > 0 ? shownEvents : undefined,
|
|
2160
|
-
ambientState,
|
|
2161
|
-
};
|
|
2162
|
-
}
|
|
2163
|
-
/**
|
|
2164
|
-
* Record memory text handed to an agent in the token ledger (ROADMAP TE0).
|
|
2165
|
-
* Best-effort: never throws, because a ledger failure must not fail the
|
|
2166
|
-
* recall or context call that produced the text.
|
|
2167
|
-
*/
|
|
2168
|
-
export function recordTokens(ctx, surface, use) {
|
|
2169
|
-
try {
|
|
2170
|
-
const db = openHippoDb(ctx.hippoRoot);
|
|
2171
|
-
try {
|
|
2172
|
-
recordTokenUse(db, {
|
|
2173
|
-
tenantId: ctx.tenantId,
|
|
2174
|
-
sessionId: use.sessionId ?? null,
|
|
2175
|
-
surface,
|
|
2176
|
-
event: 'inject',
|
|
2177
|
-
items: use.items,
|
|
2178
|
-
tokens: use.tokens,
|
|
2179
|
-
});
|
|
2180
|
-
}
|
|
2181
|
-
finally {
|
|
2182
|
-
closeHippoDb(db);
|
|
2183
|
-
}
|
|
2184
|
-
}
|
|
2185
|
-
catch {
|
|
2186
|
-
// Ledger is best-effort.
|
|
2187
|
-
}
|
|
2188
|
-
}
|
|
2189
|
-
/**
|
|
2190
|
-
* Token ledger totals for the tenant over the last `days` days (default 30):
|
|
2191
|
-
* tokens sent, skipped as unchanged and re-read by later model calls, per
|
|
2192
|
-
* surface, with session counts and mean tokens per session.
|
|
2193
|
-
*/
|
|
2194
|
-
export function tokenSummary(ctx, opts = {}) {
|
|
2195
|
-
const db = openHippoDb(ctx.hippoRoot);
|
|
2196
|
-
try {
|
|
2197
|
-
return summarizeTokenUse(db, ctx.tenantId, reportWindowStart(opts.days));
|
|
2198
|
-
}
|
|
2199
|
-
finally {
|
|
2200
|
-
closeHippoDb(db);
|
|
2201
|
-
}
|
|
2202
|
-
}
|
|
2203
|
-
/** Failed tool calls by outcome, and repeats across sessions, over the last `days` days (default 30); ROADMAP CD13. */
|
|
2204
|
-
export function failureSummary(ctx, opts = {}) {
|
|
2205
|
-
const db = openHippoDb(ctx.hippoRoot);
|
|
2206
|
-
try {
|
|
2207
|
-
return summarizeFailures(db, ctx.tenantId, reportWindowStart(opts.days));
|
|
2208
|
-
}
|
|
2209
|
-
finally {
|
|
2210
|
-
closeHippoDb(db);
|
|
2211
|
-
}
|
|
2212
|
-
}
|
|
2213
|
-
function reportWindowStart(days) {
|
|
2214
|
-
const span = days !== undefined && Number.isFinite(days) && days > 0 ? days : 30;
|
|
2215
|
-
return new Date(Date.now() - span * 86_400_000).toISOString();
|
|
2216
|
-
}
|
|
2217
|
-
/**
|
|
2218
|
-
* A tenant's dormant memories (src/dormant.ts): what sleep moved out of
|
|
2219
|
-
* active memory instead of deleting, when `dormant.enabled` is on. Newest
|
|
2220
|
-
* first; `opts.query` keeps rows containing every term (case-insensitive).
|
|
2221
|
-
*/
|
|
2222
|
-
export function listDormant(ctx, opts = {}) {
|
|
2223
|
-
const db = openHippoDb(ctx.hippoRoot);
|
|
2224
|
-
try {
|
|
2225
|
-
return listDormantRows(db, ctx.tenantId, opts);
|
|
2226
|
-
}
|
|
2227
|
-
finally {
|
|
2228
|
-
closeHippoDb(db);
|
|
2229
|
-
}
|
|
2230
|
-
}
|
|
2231
|
-
/**
|
|
2232
|
-
* Bring a dormant memory back into active memory. It returns as if just
|
|
2233
|
-
* recalled: `last_retrieved` is now, so it gets a full half-life before it
|
|
2234
|
-
* can fade again. Every other field is the snapshot taken when it went
|
|
2235
|
-
* dormant.
|
|
2236
|
-
*
|
|
2237
|
-
* Throws when the tenant has no dormant memory with that id (another
|
|
2238
|
-
* tenant's id reads the same way), when a live memory already holds the id,
|
|
2239
|
-
* and RejectedValueError when the value has been rejected since. On any
|
|
2240
|
-
* throw the dormant copy stays where it is.
|
|
2241
|
-
*/
|
|
2242
|
-
export function restoreDormant(ctx, id) {
|
|
2243
|
-
const db = openHippoDb(ctx.hippoRoot);
|
|
2244
|
-
try {
|
|
2245
|
-
let restored;
|
|
2246
|
-
db.exec('BEGIN IMMEDIATE');
|
|
2247
|
-
try {
|
|
2248
|
-
const dormant = readDormantSnapshot(db, ctx.tenantId, id);
|
|
2249
|
-
if (!dormant) {
|
|
2250
|
-
throw new NotFoundError(`dormant memory not found: ${id}`);
|
|
2251
|
-
}
|
|
2252
|
-
if (db.prepare(`SELECT 1 FROM memories WHERE id = ?`).get(id) !== undefined) {
|
|
2253
|
-
throw new ConflictError(`memory ${id} is already active; forget it before restoring its dormant copy`);
|
|
2254
|
-
}
|
|
2255
|
-
const now = new Date();
|
|
2256
|
-
// Dormant rows are long-lived, so a snapshot can predate a field added
|
|
2257
|
-
// later: createMemory supplies a default for anything it lacks, then
|
|
2258
|
-
// the snapshot overrides every field it does carry, content included.
|
|
2259
|
-
// (The placeholder only satisfies createMemory's minimum length, so a
|
|
2260
|
-
// legacy row shorter than 3 chars can still be restored.)
|
|
2261
|
-
const revived = {
|
|
2262
|
-
...createMemory('dormant snapshot defaults', { baseHalfLifeDays: loadConfig(ctx.hippoRoot).defaultHalfLifeDays }),
|
|
2263
|
-
...dormant.entry,
|
|
2264
|
-
last_retrieved: now.toISOString(),
|
|
2265
|
-
};
|
|
2266
|
-
restored = stampOriginProject(ctx.hippoRoot, { ...revived, strength: calculateStrength(revived, now) });
|
|
2267
|
-
writeEntryDbOnly(db, restored, { actor: ctx.actor.subject });
|
|
2268
|
-
deleteDormantRow(db, ctx.tenantId, id);
|
|
2269
|
-
// A restore is a labelled "forgot it, then needed it" event: the
|
|
2270
|
-
// signal a learned lifecycle (ROADMAP LC3) trains on. Same transaction
|
|
2271
|
-
// as the restore, so the label exists exactly when the restore does.
|
|
2272
|
-
appendAuditEvent(db, {
|
|
2273
|
-
tenantId: ctx.tenantId,
|
|
2274
|
-
actor: ctx.actor.subject,
|
|
2275
|
-
op: 'dormant_restore',
|
|
2276
|
-
targetId: id,
|
|
2277
|
-
metadata: {
|
|
2278
|
-
reason: dormant.reason,
|
|
2279
|
-
strengthAtDormancy: dormant.strength,
|
|
2280
|
-
dormantAt: dormant.dormantAt,
|
|
2281
|
-
daysDormant: Math.max(0, (now.getTime() - Date.parse(dormant.dormantAt)) / (24 * 60 * 60 * 1000)),
|
|
2282
|
-
},
|
|
2283
|
-
});
|
|
2284
|
-
db.exec('COMMIT');
|
|
2285
|
-
}
|
|
2286
|
-
catch (err) {
|
|
2287
|
-
try {
|
|
2288
|
-
db.exec('ROLLBACK');
|
|
2289
|
-
}
|
|
2290
|
-
catch { /* already rolled back */ }
|
|
2291
|
-
if (err instanceof RejectedValueError) {
|
|
2292
|
-
auditRejectionRefusal(db, err, ctx.actor.subject);
|
|
2293
|
-
}
|
|
2294
|
-
throw err;
|
|
2295
|
-
}
|
|
2296
|
-
writeEntryMirrors(ctx.hippoRoot, restored);
|
|
2297
|
-
return restored;
|
|
2298
|
-
}
|
|
2299
|
-
finally {
|
|
2300
|
-
closeHippoDb(db);
|
|
2301
|
-
}
|
|
2302
|
-
}
|
|
2303
|
-
/**
|
|
2304
|
-
* Permanently delete a dormant memory: the explicit "forget it for good"
|
|
2305
|
-
* that dormant storage leaves to the user. Throws when the tenant has no
|
|
2306
|
-
* dormant memory with that id.
|
|
2307
|
-
*/
|
|
2308
|
-
export function forgetDormant(ctx, id) {
|
|
2309
|
-
const db = openHippoDb(ctx.hippoRoot);
|
|
2310
|
-
try {
|
|
2311
|
-
if (!deleteDormantRow(db, ctx.tenantId, id)) {
|
|
2312
|
-
throw new NotFoundError(`dormant memory not found: ${id}`);
|
|
2313
|
-
}
|
|
2314
|
-
try {
|
|
2315
|
-
appendAuditEvent(db, {
|
|
2316
|
-
tenantId: ctx.tenantId,
|
|
2317
|
-
actor: ctx.actor.subject,
|
|
2318
|
-
op: 'forget',
|
|
2319
|
-
targetId: id,
|
|
2320
|
-
metadata: { dormant: true },
|
|
2321
|
-
});
|
|
2322
|
-
}
|
|
2323
|
-
catch (error) {
|
|
2324
|
-
// Best-effort, like every other forget audit row: the delete stands.
|
|
2325
|
-
reportAuditWriteFailure('forget', String(error), id);
|
|
2326
|
-
}
|
|
2327
|
-
}
|
|
2328
|
-
finally {
|
|
2329
|
-
closeHippoDb(db);
|
|
2330
|
-
}
|
|
2331
|
-
// Counted like every other permanent removal (forget, archiveRaw).
|
|
2332
|
-
updateStats(ctx.hippoRoot, { forgotten: 1 });
|
|
2333
|
-
}
|
|
2334
|
-
/** Whether the tenant holds a dormant memory with this id (for "not found" hints). */
|
|
2335
|
-
export function isDormant(ctx, id) {
|
|
2336
|
-
const db = openHippoDb(ctx.hippoRoot);
|
|
2337
|
-
try {
|
|
2338
|
-
return hasDormantRow(db, ctx.tenantId, id);
|
|
2339
|
-
}
|
|
2340
|
-
finally {
|
|
2341
|
-
closeHippoDb(db);
|
|
2342
|
-
}
|
|
2343
|
-
}
|
|
2344
|
-
const QUARANTINE_PREVIEW_CHARS = 200;
|
|
2345
|
-
/** A tenant's quarantined memories, newest first. Default `status` is 'pending' (the review queue). */
|
|
2346
|
-
export function quarantineList(ctx, opts = {}) {
|
|
2347
|
-
const db = openHippoDb(ctx.hippoRoot);
|
|
2348
|
-
try {
|
|
2349
|
-
const rows = listQuarantineRows(db, ctx.tenantId, opts.status ?? 'pending', opts.limit);
|
|
2350
|
-
return rows.map((row) => {
|
|
2351
|
-
const entry = readEntry(ctx.hippoRoot, row.memoryId, ctx.tenantId);
|
|
2352
|
-
return {
|
|
2353
|
-
id: row.memoryId,
|
|
2354
|
-
originalScope: row.originalScope,
|
|
2355
|
-
reason: row.reason,
|
|
2356
|
-
status: row.status,
|
|
2357
|
-
quarantinedAt: row.quarantinedAt,
|
|
2358
|
-
decidedAt: row.decidedAt,
|
|
2359
|
-
decidedBy: row.decidedBy,
|
|
2360
|
-
contentPreview: entry ? entry.content.slice(0, QUARANTINE_PREVIEW_CHARS) : '',
|
|
2361
|
-
};
|
|
2362
|
-
});
|
|
2363
|
-
}
|
|
2364
|
-
finally {
|
|
2365
|
-
closeHippoDb(db);
|
|
2366
|
-
}
|
|
2367
|
-
}
|
|
2368
|
-
function loadPendingQuarantineRow(db, tenantId, id) {
|
|
2369
|
-
const row = getQuarantineRow(db, tenantId, id);
|
|
2370
|
-
if (!row)
|
|
2371
|
-
throw new NotFoundError(`not quarantined: ${id}`);
|
|
2372
|
-
if (row.status !== 'pending')
|
|
2373
|
-
throw new ConflictError(`${id} is already ${row.status}`);
|
|
2374
|
-
return row;
|
|
2375
|
-
}
|
|
2376
|
-
/** Release a quarantined memory to its original scope. Admin only; the scope guard refuses a row moved since (mirrors restoreDormant). */
|
|
2377
|
-
export function quarantineApprove(ctx, id) {
|
|
2378
|
-
if (ctx.actor.role !== 'admin') {
|
|
2379
|
-
throw new ForbiddenError('Only an admin key can approve a quarantined memory');
|
|
2380
|
-
}
|
|
2381
|
-
const db = openHippoDb(ctx.hippoRoot);
|
|
2382
|
-
try {
|
|
2383
|
-
db.exec('BEGIN IMMEDIATE');
|
|
2384
|
-
try {
|
|
2385
|
-
const row = loadPendingQuarantineRow(db, ctx.tenantId, id);
|
|
2386
|
-
const quarantineScope = quarantineScopeFor(row.originalScope);
|
|
2387
|
-
const updated = db
|
|
2388
|
-
.prepare(`UPDATE memories SET scope = ? WHERE id = ? AND tenant_id = ? AND scope = ?`)
|
|
2389
|
-
.run(row.originalScope, id, ctx.tenantId, quarantineScope);
|
|
2390
|
-
if (Number(updated.changes ?? 0) !== 1) {
|
|
2391
|
-
throw new ConflictError(`memory ${id} scope changed since quarantine; refusing to approve`);
|
|
2392
|
-
}
|
|
2393
|
-
approveQuarantineRow(db, ctx.tenantId, id, ctx.actor.subject);
|
|
2394
|
-
appendAuditEvent(db, {
|
|
2395
|
-
tenantId: ctx.tenantId,
|
|
2396
|
-
actor: ctx.actor.subject,
|
|
2397
|
-
op: 'quarantine_approve',
|
|
2398
|
-
targetId: id,
|
|
2399
|
-
metadata: { originalScope: row.originalScope },
|
|
2400
|
-
});
|
|
2401
|
-
db.exec('COMMIT');
|
|
2402
|
-
}
|
|
2403
|
-
catch (err) {
|
|
2404
|
-
try {
|
|
2405
|
-
db.exec('ROLLBACK');
|
|
2406
|
-
}
|
|
2407
|
-
catch { /* already rolled back */ }
|
|
2408
|
-
throw err;
|
|
2409
|
-
}
|
|
2410
|
-
}
|
|
2411
|
-
finally {
|
|
2412
|
-
closeHippoDb(db);
|
|
2413
|
-
}
|
|
2414
|
-
// Post-commit, best-effort: a failed rewrite leaves the mirror showing the quarantine scope (fail-closed).
|
|
2415
|
-
try {
|
|
2416
|
-
const restored = readEntry(ctx.hippoRoot, id, ctx.tenantId);
|
|
2417
|
-
if (restored)
|
|
2418
|
-
writeEntryMirrors(ctx.hippoRoot, restored);
|
|
2419
|
-
}
|
|
2420
|
-
catch (err) {
|
|
2421
|
-
log.error(`quarantine: mirror rewrite failed for ${id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2422
|
-
}
|
|
2423
|
-
}
|
|
2424
|
-
/** Keep a quarantined memory hidden for good. Admin only; the raw row is untouched (append-only). */
|
|
2425
|
-
export function quarantineReject(ctx, id) {
|
|
2426
|
-
if (ctx.actor.role !== 'admin') {
|
|
2427
|
-
throw new ForbiddenError('Only an admin key can reject a quarantined memory');
|
|
2428
|
-
}
|
|
2429
|
-
const db = openHippoDb(ctx.hippoRoot);
|
|
2430
|
-
try {
|
|
2431
|
-
db.exec('BEGIN IMMEDIATE');
|
|
2432
|
-
try {
|
|
2433
|
-
loadPendingQuarantineRow(db, ctx.tenantId, id);
|
|
2434
|
-
rejectQuarantineRow(db, ctx.tenantId, id, ctx.actor.subject);
|
|
2435
|
-
appendAuditEvent(db, {
|
|
2436
|
-
tenantId: ctx.tenantId,
|
|
2437
|
-
actor: ctx.actor.subject,
|
|
2438
|
-
op: 'quarantine_reject',
|
|
2439
|
-
targetId: id,
|
|
2440
|
-
metadata: {},
|
|
2441
|
-
});
|
|
2442
|
-
db.exec('COMMIT');
|
|
2443
|
-
}
|
|
2444
|
-
catch (err) {
|
|
2445
|
-
try {
|
|
2446
|
-
db.exec('ROLLBACK');
|
|
2447
|
-
}
|
|
2448
|
-
catch { /* already rolled back */ }
|
|
2449
|
-
throw err;
|
|
2450
|
-
}
|
|
2451
|
-
}
|
|
2452
|
-
finally {
|
|
2453
|
-
closeHippoDb(db);
|
|
2454
|
-
}
|
|
2455
|
-
}
|
|
2456
|
-
const DEFAULT_SLEEP_PHASES = {
|
|
2457
|
-
consolidate,
|
|
2458
|
-
deduplicateStore,
|
|
2459
|
-
auditMemories,
|
|
2460
|
-
autoShare,
|
|
2461
|
-
loadAllEntries,
|
|
2462
|
-
deleteEntry,
|
|
2463
|
-
computeAmbientState,
|
|
2464
|
-
loadConfig,
|
|
2465
|
-
loadPendingExtractionTenants,
|
|
2466
|
-
extractGraph,
|
|
2467
|
-
};
|
|
2468
|
-
export async function sleep(ctx, opts = {}) {
|
|
2469
|
-
const dryRun = Boolean(opts.dryRun);
|
|
2470
|
-
// v1.12.2: resolve phase dependencies, allowing test-only `__phases`
|
|
2471
|
-
// override to inject deterministic throws for mid-phase failure coverage.
|
|
2472
|
-
const phases = { ...DEFAULT_SLEEP_PHASES, ...(opts.__phases ?? {}) };
|
|
2473
|
-
// v1.11.5: phase counters for the consolidate audit emit (in finally).
|
|
2474
|
-
// Accumulated as each phase completes so partial-failure paths still report
|
|
2475
|
-
// accurate "what got done before the failure" data.
|
|
2476
|
-
let consolidationCount = 0;
|
|
2477
|
-
let dedupCount = 0;
|
|
2478
|
-
let auditDeletedCount = 0;
|
|
2479
|
-
let ambientTotal = 0;
|
|
2480
|
-
let phaseError = null;
|
|
2481
|
-
let graphSnapshotError = null;
|
|
2482
|
-
let result = null;
|
|
2483
|
-
try {
|
|
2484
|
-
// Snapshot dirty tenants BEFORE any memory-deleting phase (consolidate /
|
|
2485
|
-
// dedup / audit). The graph_extraction_queue rows are FK'd to mirror
|
|
2486
|
-
// memories with ON DELETE CASCADE, so a phase that deletes a queued mirror
|
|
2487
|
-
// (e.g. dedup removing a near-duplicate superseding decision) would drop the
|
|
2488
|
-
// tenant from a drain-time load and leave its graph stale (codex P1). The
|
|
2489
|
-
// MAX(id) watermark captured here stays valid: arrivals during sleep get a
|
|
2490
|
-
// higher id and remain pending.
|
|
2491
|
-
//
|
|
2492
|
-
// Fail-soft (codex P2): a queue-read failure here must NOT abort core sleep
|
|
2493
|
-
// (consolidation / dedup / audit run regardless). On failure, skip graph
|
|
2494
|
-
// refresh this sleep (recovered next sleep) and surface a detail once
|
|
2495
|
-
// `result` exists (Phase 6).
|
|
2496
|
-
let dirtyTenants = [];
|
|
2497
|
-
if (!dryRun) {
|
|
2498
|
-
try {
|
|
2499
|
-
dirtyTenants = phases.loadPendingExtractionTenants(ctx.hippoRoot);
|
|
2500
|
-
}
|
|
2501
|
-
catch (snapErr) {
|
|
2502
|
-
// SAFETY: this is a best-effort log message only; property access on
|
|
2503
|
-
// any JS value is safe (undefined if absent), preserving the existing
|
|
2504
|
-
// lenient formatting even when something non-Error was thrown.
|
|
2505
|
-
graphSnapshotError = snapErr.message;
|
|
2506
|
-
}
|
|
2507
|
-
}
|
|
2508
|
-
// Phase 1: Consolidation.
|
|
2509
|
-
const consolidateResult = await phases.consolidate(ctx.hippoRoot, { dryRun });
|
|
2510
|
-
consolidationCount = consolidateResult.semanticCreated + consolidateResult.merged;
|
|
2511
|
-
result = {
|
|
2512
|
-
active: consolidateResult.decayed,
|
|
2513
|
-
removed: consolidateResult.removed,
|
|
2514
|
-
mergedEpisodic: consolidateResult.merged,
|
|
2515
|
-
newSemantic: consolidateResult.semanticCreated,
|
|
2516
|
-
dryRun,
|
|
2517
|
-
details: consolidateResult.details,
|
|
2518
|
-
};
|
|
2519
|
-
// Set only when non-zero, so a store without dormant memories gets a
|
|
2520
|
-
// byte-identical result (HTTP /v1/sleep, the CLI render snapshot).
|
|
2521
|
-
if (consolidateResult.dormant > 0) {
|
|
2522
|
-
result.dormant = consolidateResult.dormant;
|
|
2523
|
-
}
|
|
2524
|
-
if (consolidateResult.dormantExpired > 0) {
|
|
2525
|
-
result.dormantExpired = consolidateResult.dormantExpired;
|
|
2526
|
-
}
|
|
2527
|
-
// Phase 2: Dedup (post-consolidate near-duplicate cleanup).
|
|
2528
|
-
const dedupResult = phases.deduplicateStore(ctx.hippoRoot, { dryRun, actor: ctx.actor.subject });
|
|
2529
|
-
dedupCount = dedupResult.removed;
|
|
2530
|
-
if (dedupResult.removed > 0) {
|
|
2531
|
-
const semDups = dedupResult.pairs.filter((p) => p.keptLayer === 'semantic' && p.removedLayer === 'semantic').length;
|
|
2532
|
-
const epiDups = dedupResult.pairs.filter((p) => p.keptLayer === 'episodic' && p.removedLayer === 'episodic').length;
|
|
2533
|
-
const crossDups = dedupResult.pairs.filter((p) => p.keptLayer !== p.removedLayer).length;
|
|
2534
|
-
result.deduped = {
|
|
2535
|
-
removed: dedupResult.removed,
|
|
2536
|
-
semDups,
|
|
2537
|
-
epiDups,
|
|
2538
|
-
crossDups,
|
|
2539
|
-
};
|
|
2540
|
-
}
|
|
2541
|
-
// Phase 3: Quality audit (remove junk, report warnings; a dry run skips rows earlier phases would remove).
|
|
2542
|
-
const planned = new Set(dryRun ? [...(consolidateResult.removedIds ?? []), ...dedupResult.pairs.map((p) => p.removed)] : []);
|
|
2543
|
-
const allEntries = phases.loadAllEntries(ctx.hippoRoot).filter((e) => !planned.has(e.id));
|
|
2544
|
-
const auditOut = phases.auditMemories(allEntries, memoriesBackingObjects(ctx.hippoRoot));
|
|
2545
|
-
if (auditOut.issues.length > 0) {
|
|
2546
|
-
const errors = auditOut.issues.filter((i) => i.severity === 'error');
|
|
2547
|
-
const warnings = auditOut.issues.filter((i) => i.severity === 'warning');
|
|
2548
|
-
let removed = 0;
|
|
2549
|
-
for (const issue of errors) {
|
|
2550
|
-
const reason = `sleep-audit: ${issue.reason}`;
|
|
2551
|
-
if (dryRun || phases.deleteEntry(ctx.hippoRoot, issue.memoryId, { actor: ctx.actor.subject, reason, automatic: true }))
|
|
2552
|
-
removed++;
|
|
2553
|
-
}
|
|
2554
|
-
auditDeletedCount = removed;
|
|
2555
|
-
if (removed > 0 || warnings.length > 0) {
|
|
2556
|
-
result.audit = {
|
|
2557
|
-
errorsRemoved: removed,
|
|
2558
|
-
warningCount: warnings.length,
|
|
2559
|
-
};
|
|
2560
|
-
}
|
|
2561
|
-
}
|
|
2562
|
-
if (dryRun)
|
|
2563
|
-
return result;
|
|
2564
|
-
// Phase 4: Auto-share high-transfer-score memories to global.
|
|
2565
|
-
if (!opts.noShare) {
|
|
2566
|
-
const sleepConfig = phases.loadConfig(ctx.hippoRoot);
|
|
2567
|
-
if (sleepConfig.autoShareOnSleep) {
|
|
2568
|
-
// v1.25.0: surface the secret-veto skip count (v39 follow-up #2) so
|
|
2569
|
-
// the veto is observable instead of silent.
|
|
2570
|
-
// AT1: rejectedSkipped is autoShare's sibling counter for candidates
|
|
2571
|
-
// the global store's rejection tombstone refused (threaded the same
|
|
2572
|
-
// way as secretSkipped just below).
|
|
2573
|
-
const autoShareStats = { secretSkipped: 0, rejectedSkipped: 0 };
|
|
2574
|
-
const shared = phases.autoShare(ctx.hippoRoot, { minScore: 0.6, stats: autoShareStats });
|
|
2575
|
-
if (shared.length > 0) {
|
|
2576
|
-
result.shared = shared.length;
|
|
2577
|
-
}
|
|
2578
|
-
if (autoShareStats.secretSkipped > 0) {
|
|
2579
|
-
result.secretSkipped = autoShareStats.secretSkipped;
|
|
2580
|
-
}
|
|
2581
|
-
if (autoShareStats.rejectedSkipped > 0) {
|
|
2582
|
-
result.rejectedSkipped = autoShareStats.rejectedSkipped;
|
|
2583
|
-
}
|
|
2584
|
-
}
|
|
2585
|
-
}
|
|
2586
|
-
// Phase 5: Post-sleep ambient state summary.
|
|
2587
|
-
const postSleepConfig = phases.loadConfig(ctx.hippoRoot);
|
|
2588
|
-
if (postSleepConfig.ambient.enabled) {
|
|
2589
|
-
const postSleepEntries = phases.loadAllEntries(ctx.hippoRoot).filter((e) => !e.superseded_by);
|
|
2590
|
-
if (postSleepEntries.length > 0) {
|
|
2591
|
-
result.ambient = phases.computeAmbientState(postSleepEntries);
|
|
2592
|
-
ambientTotal = result.ambient.totalMemories;
|
|
2593
|
-
}
|
|
2594
|
-
}
|
|
2595
|
-
// Phase 6: Graph extraction drain (E3 sleep enqueue-hook). Rebuild the
|
|
2596
|
-
// entity/relation graph for every tenant marked dirty (by markGraphDirty)
|
|
2597
|
-
// since the last sleep, so `recall --hops` + cross-object `references` edges
|
|
2598
|
-
// run on fresh data without a manual `hippo graph extract`. Fully
|
|
2599
|
-
// fault-isolated: the consolidation work above has already committed, so a
|
|
2600
|
-
// failure here must never abort sleep; a per-tenant extract failure leaves
|
|
2601
|
-
// that tenant's queue items pending for the next sleep. (Skipped under
|
|
2602
|
-
// dryRun via the early return above.)
|
|
2603
|
-
try {
|
|
2604
|
-
if (graphSnapshotError) {
|
|
2605
|
-
// The dirty-tenant snapshot failed (codex P2 fail-soft). Core sleep
|
|
2606
|
-
// already succeeded; surface the skipped graph refresh as a detail.
|
|
2607
|
-
result.details = [
|
|
2608
|
-
...(result.details ?? []),
|
|
2609
|
-
`graph: dirty-tenant snapshot failed (skipped graph refresh): ${graphSnapshotError}`,
|
|
2610
|
-
];
|
|
2611
|
-
}
|
|
2612
|
-
let gTenants = 0;
|
|
2613
|
-
let gEntities = 0;
|
|
2614
|
-
let gRelations = 0;
|
|
2615
|
-
// dirtyTenants was snapshotted before the memory-deleting phases above.
|
|
2616
|
-
for (const { tenantId, maxPendingId } of dirtyTenants) {
|
|
2617
|
-
try {
|
|
2618
|
-
const ext = phases.extractGraph(ctx.hippoRoot, tenantId);
|
|
2619
|
-
// Count the rebuild as soon as it succeeds — it happened regardless of
|
|
2620
|
-
// the drain-mark below.
|
|
2621
|
-
gTenants += 1;
|
|
2622
|
-
gEntities += ext.entities;
|
|
2623
|
-
gRelations += ext.relations;
|
|
2624
|
-
// Watermark drain: mark processed only items enqueued before this
|
|
2625
|
-
// rebuild started (id <= maxPendingId). Arrivals during the rebuild
|
|
2626
|
-
// keep pending status and are caught next sleep; rows whose mirror was
|
|
2627
|
-
// cascade-deleted earlier this sleep are already gone (no-op).
|
|
2628
|
-
markPendingProcessedUpTo(ctx.hippoRoot, tenantId, maxPendingId);
|
|
2629
|
-
}
|
|
2630
|
-
catch (tenantErr) {
|
|
2631
|
-
// SAFETY: this is a best-effort log message only; property access
|
|
2632
|
-
// on any JS value is safe (undefined if absent), preserving the
|
|
2633
|
-
// existing lenient formatting even when something non-Error was thrown.
|
|
2634
|
-
result.details = [
|
|
2635
|
-
...(result.details ?? []),
|
|
2636
|
-
`graph: extract failed for a dirty tenant (left pending): ${tenantErr.message}`,
|
|
2637
|
-
];
|
|
2638
|
-
}
|
|
2639
|
-
}
|
|
2640
|
-
if (gTenants > 0) {
|
|
2641
|
-
result.graph = { tenants: gTenants, entities: gEntities, relations: gRelations };
|
|
2642
|
-
}
|
|
2643
|
-
}
|
|
2644
|
-
catch (graphErr) {
|
|
2645
|
-
// SAFETY: this is a best-effort log message only; property access on
|
|
2646
|
-
// any JS value is safe (undefined if absent), preserving the existing
|
|
2647
|
-
// lenient formatting even when something non-Error was thrown.
|
|
2648
|
-
result.details = [
|
|
2649
|
-
...(result.details ?? []),
|
|
2650
|
-
`graph: drain phase failed (skipped): ${graphErr.message}`,
|
|
2651
|
-
];
|
|
2652
|
-
}
|
|
2653
|
-
return result;
|
|
2654
|
-
}
|
|
2655
|
-
catch (err) {
|
|
2656
|
-
// SAFETY: phaseError is read via phaseError.message / (phaseError !==
|
|
2657
|
-
// null) below, both safe even if a non-Error was thrown; this mirrors
|
|
2658
|
-
// the existing lenient (err as Error) pattern used throughout this catch chain.
|
|
2659
|
-
phaseError = err;
|
|
2660
|
-
throw err;
|
|
2661
|
-
}
|
|
2662
|
-
finally {
|
|
2663
|
-
// v1.11.5: emit one 'consolidate' audit_log row per api.sleep invocation,
|
|
2664
|
-
// with phase counters in metadata. Closes the CLI/MCP parity gap that T6
|
|
2665
|
-
// fixed for cmdOutcome (Episode A follow-up). In finally so partial-failure
|
|
2666
|
-
// paths still emit; `partial: true` + errorMessage flag the failure.
|
|
2667
|
-
// Dedicated handle for this emit only (phase helpers above each open their
|
|
2668
|
-
// own handle via hippoRoot — SQLite single-writer makes parallel handles
|
|
2669
|
-
// safe for the read-heavy phases).
|
|
2670
|
-
//
|
|
2671
|
-
// TODO(v1.12.0 + A5 v2): the audit row is tagged with ctx.tenantId but
|
|
2672
|
-
// api.sleep is host-wide (cross-tenant dedup is intentional). When
|
|
2673
|
-
// /v1/sleep moves off loopback-only, either tag with a synthetic "host"
|
|
2674
|
-
// tenant or scope api.sleep per-tenant. Independent-review-critic flag,
|
|
2675
|
-
// v1.11.5 ship.
|
|
2676
|
-
//
|
|
2677
|
-
// Error preservation: if openHippoDb or appendAuditEvent throws here, we
|
|
2678
|
-
// do NOT let it replace the original phaseError (independent-review HIGH:
|
|
2679
|
-
// would mask the underlying consolidation failure). Audit emit failure
|
|
2680
|
-
// is logged to stderr but the original throw wins.
|
|
2681
|
-
try {
|
|
2682
|
-
const db = openHippoDb(ctx.hippoRoot);
|
|
2683
|
-
try {
|
|
2684
|
-
const sleepAuditMetadata = {
|
|
2685
|
-
consolidationCount,
|
|
2686
|
-
dedupCount,
|
|
2687
|
-
auditDeletedCount,
|
|
2688
|
-
ambientTotal,
|
|
2689
|
-
dryRun,
|
|
2690
|
-
noShare: opts.noShare ?? false,
|
|
2691
|
-
partial: phaseError !== null,
|
|
2692
|
-
triggeredByTenant: ctx.tenantId, // preserve for audit forensics
|
|
2693
|
-
};
|
|
2694
|
-
if (phaseError)
|
|
2695
|
-
sleepAuditMetadata.errorMessage = phaseError.message;
|
|
2696
|
-
appendAuditEvent(db, {
|
|
2697
|
-
tenantId: '__host__',
|
|
2698
|
-
actor: ctx.actor.subject,
|
|
2699
|
-
op: 'consolidate',
|
|
2700
|
-
metadata: { ...sleepAuditMetadata },
|
|
2701
|
-
});
|
|
2702
|
-
}
|
|
2703
|
-
finally {
|
|
2704
|
-
closeHippoDb(db);
|
|
2705
|
-
}
|
|
2706
|
-
}
|
|
2707
|
-
catch (auditErr) {
|
|
2708
|
-
// Logged, never thrown: a second failure must not mask the original phaseError.
|
|
2709
|
-
reportAuditWriteFailure('consolidate', String(auditErr));
|
|
2710
|
-
}
|
|
2711
|
-
}
|
|
2712
|
-
}
|
|
2713
|
-
export function outcomeForLastRecall(ctx, good) {
|
|
2714
|
-
const idx = loadIndex(ctx.hippoRoot);
|
|
2715
|
-
const ids = idx.last_retrieval_ids;
|
|
2716
|
-
if (ids.length === 0)
|
|
2717
|
-
return { applied: 0, ids: [] };
|
|
2718
|
-
// LC1 F1(d) structural fix (docs/plans/2026-08-02-lc1-recall-trace-persistence.md):
|
|
2719
|
-
// read the trace id from the SAME `loadIndex` snapshot already in hand
|
|
2720
|
-
// (idx.last_trace_id) — a single-snapshot read, not a second DB round
|
|
2721
|
-
// trip via a now-deleted readLastTraceId helper. The value is already
|
|
2722
|
-
// strict-parsed by buildIndexFromDb's parseLastTraceId (store.ts): every
|
|
2723
|
-
// consumer gets a clean positive-integer string or null, never a garbage
|
|
2724
|
-
// value that could reach outcome() and INSERT trace_id=0/NaN. null on a
|
|
2725
|
-
// fresh store / pre-v40 flow / api.recall-only usage — outcome() skips
|
|
2726
|
-
// linkage silently when traceId is undefined.
|
|
2727
|
-
const traceId = idx.last_trace_id !== null ? Number(idx.last_trace_id) : null;
|
|
2728
|
-
const { applied, appliedIds } = outcome(ctx, ids, good, traceId !== null ? { traceId } : undefined);
|
|
2729
|
-
return { applied, ids: appliedIds };
|
|
2730
|
-
}
|
|
10
|
+
export * from './api/types.js';
|
|
11
|
+
export * from './api/remember.js';
|
|
12
|
+
export * from './api/recall-types.js';
|
|
13
|
+
export * from './api/recall.js';
|
|
14
|
+
export * from './api/assemble.js';
|
|
15
|
+
export * from './api/drill-down.js';
|
|
16
|
+
export * from './api/outcome.js';
|
|
17
|
+
export * from './api/forget.js';
|
|
18
|
+
export * from './api/promote.js';
|
|
19
|
+
export * from './api/auth.js';
|
|
20
|
+
export * from './api/audit.js';
|
|
21
|
+
export * from './api/context-types.js';
|
|
22
|
+
export * from './api/context.js';
|
|
23
|
+
export * from './api/tokens.js';
|
|
24
|
+
export * from './api/dormant.js';
|
|
25
|
+
export * from './api/quarantine.js';
|
|
26
|
+
export * from './api/sleep.js';
|
|
27
|
+
export * from './api/goals.js';
|
|
28
|
+
export * from './api/learn.js';
|
|
2731
29
|
//# sourceMappingURL=api.js.map
|