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/store.js
DELETED
|
@@ -1,3473 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Storage layer for Hippo.
|
|
3
|
-
*
|
|
4
|
-
* SQLite is the source of truth.
|
|
5
|
-
* Markdown + JSON files remain as human-readable compatibility mirrors.
|
|
6
|
-
*/
|
|
7
|
-
import * as fs from 'fs';
|
|
8
|
-
import * as path from 'path';
|
|
9
|
-
import { Layer, AUTO_DELETABLE_SQL, DEFAULT_HALF_LIFE_DAYS, markRetrieved } from './memory.js';
|
|
10
|
-
import { dumpFrontmatter, parseFrontmatter } from './yaml.js';
|
|
11
|
-
import { openHippoDb, closeHippoDb, getMeta, setMeta, isFtsAvailable, pruneConsolidationRuns, getHippoDbPath, } from './db.js';
|
|
12
|
-
import { rowToSessionHandoff, isHandoffOutcome } from './handoff.js';
|
|
13
|
-
import { tokenize } from './tokenize.js';
|
|
14
|
-
import { RECALL_DEFAULT_DENY_SCOPES } from './recall-scope.js';
|
|
15
|
-
import { assertTenantId } from './tenant.js';
|
|
16
|
-
import { isRecallBoostAblated } from './ablation.js';
|
|
17
|
-
import { rarestPromptTerms, RAREST_TERM_COUNT } from './prompt-recall.js';
|
|
18
|
-
import { appendAuditEvent, reportAuditWriteFailure } from './audit.js';
|
|
19
|
-
import { resolveTenantId } from './tenant.js';
|
|
20
|
-
import { redactSecretsStrict } from './secret-detect.js';
|
|
21
|
-
import { deriveOriginProject, originFromSource, findHippoStoreDir, realpathOrResolve } from './project-identity.js';
|
|
22
|
-
import { checkRejectionGuard, RejectedValueError, rejectionDigest, normalizeValueForRejection, insertRejectedValue, findRejectedValue, } from './rejection.js';
|
|
23
|
-
// AT1 (plan §5): resolveConflict's kind-aware loser removal needs
|
|
24
|
-
// archiveRawMemory for kind='raw' losers. raw-archive.ts imports
|
|
25
|
-
// markSummaryDirtyInTx from this module — both imports are used only
|
|
26
|
-
// inside function bodies (never at module-evaluation time), so the cycle
|
|
27
|
-
// is the standard safe mutual-function-reference shape under NodeNext ESM.
|
|
28
|
-
import { archiveRawMemory } from './raw-archive.js';
|
|
29
|
-
import { insertDormantRow } from './dormant.js';
|
|
30
|
-
import { log } from './log.js';
|
|
31
|
-
/**
|
|
32
|
-
* Emit an audit event for a mutation against `db`. Wrapped so a broken audit
|
|
33
|
-
* log can never crash the surrounding mutation — the SQLite store is still the
|
|
34
|
-
* source of truth and audit failures are diagnosable from the missing rows.
|
|
35
|
-
*/
|
|
36
|
-
function audit(db, op, targetId, metadata, actor = 'cli', tenantId) {
|
|
37
|
-
try {
|
|
38
|
-
appendAuditEvent(db, {
|
|
39
|
-
tenantId: tenantId ?? resolveTenantId({}),
|
|
40
|
-
actor,
|
|
41
|
-
op,
|
|
42
|
-
targetId,
|
|
43
|
-
metadata,
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
|
-
catch (error) {
|
|
47
|
-
// The mutation has already succeeded; a broken audit table must not undo it.
|
|
48
|
-
reportAuditWriteFailure(op, String(error), targetId);
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
/**
|
|
52
|
-
* Refusal audit for the AT1 rejection guard (plan §3). Written by the
|
|
53
|
-
* transaction OWNER post-rollback — writeEntry's catch (no outer tx exists
|
|
54
|
-
* there, so this lands in a fresh implicit transaction) and api.supersede's
|
|
55
|
-
* catch (after its own ROLLBACK) — never inside a scope the caller's own
|
|
56
|
-
* rollback could claw back. Best-effort `audit()` semantics: never throws.
|
|
57
|
-
*/
|
|
58
|
-
export function auditRejectionRefusal(db, err, actor) {
|
|
59
|
-
audit(db, 'reject_refusal', err.entryId, { digest: err.digest, reason: err.reason }, actor, err.tenantId);
|
|
60
|
-
}
|
|
61
|
-
const INDEX_VERSION = 3;
|
|
62
|
-
export const MEMORY_SELECT_COLUMNS = `id, created, last_retrieved, retrieval_count, strength, half_life_days, layer, tags_json, emotional_valence, schema_fit, source, outcome_score, outcome_positive, outcome_negative, conflicts_with_json, pinned, confidence, content, parents_json, starred, trace_outcome, source_session_id, valid_from, superseded_by, extracted_from, dag_level, dag_parent_id, kind, scope, owner, artifact_ref, tenant_id, origin_project, descendant_count, earliest_at, latest_at, summary_dirty, last_rebuilt_at, rebuild_count, dag_level_3_built_at`;
|
|
63
|
-
// F1 (v1.7.0): qualified-and-aliased columns for the FTS join in
|
|
64
|
-
// loadSearchRows. Every column is `m.<col> AS <col>` so rowToEntry's
|
|
65
|
-
// unqualified field reads keep working unchanged. The trailing
|
|
66
|
-
// bm25(memories_fts) AS bm25_score adds the FTS rank as a result column.
|
|
67
|
-
// Only used inside the FTS path; non-FTS paths keep MEMORY_SELECT_COLUMNS.
|
|
68
|
-
const MEMORY_SEARCH_COLUMNS = `m.id AS id, m.created AS created, m.last_retrieved AS last_retrieved, m.retrieval_count AS retrieval_count, m.strength AS strength, m.half_life_days AS half_life_days, m.layer AS layer, m.tags_json AS tags_json, m.emotional_valence AS emotional_valence, m.schema_fit AS schema_fit, m.source AS source, m.outcome_score AS outcome_score, m.outcome_positive AS outcome_positive, m.outcome_negative AS outcome_negative, m.conflicts_with_json AS conflicts_with_json, m.pinned AS pinned, m.confidence AS confidence, m.content AS content, m.parents_json AS parents_json, m.starred AS starred, m.trace_outcome AS trace_outcome, m.source_session_id AS source_session_id, m.valid_from AS valid_from, m.superseded_by AS superseded_by, m.extracted_from AS extracted_from, m.dag_level AS dag_level, m.dag_parent_id AS dag_parent_id, m.kind AS kind, m.scope AS scope, m.owner AS owner, m.artifact_ref AS artifact_ref, m.tenant_id AS tenant_id, m.origin_project AS origin_project, m.descendant_count AS descendant_count, m.earliest_at AS earliest_at, m.latest_at AS latest_at, m.summary_dirty AS summary_dirty, m.last_rebuilt_at AS last_rebuilt_at, m.rebuild_count AS rebuild_count, m.dag_level_3_built_at AS dag_level_3_built_at, bm25(memories_fts) AS bm25_score`;
|
|
69
|
-
/**
|
|
70
|
-
* Default candidate-pool size for `loadSearchEntries` when called with
|
|
71
|
-
* `limit === undefined`. Single source of truth; `api.recall` imports
|
|
72
|
-
* this for `RecallResult.windowSize` reporting so the two cannot drift.
|
|
73
|
-
*/
|
|
74
|
-
export const DEFAULT_SEARCH_CANDIDATE_LIMIT = 200;
|
|
75
|
-
function layerDir(root, layer) {
|
|
76
|
-
return path.join(root, layer);
|
|
77
|
-
}
|
|
78
|
-
/** Nearest ancestor store like git; the strict join is the fallback so `hippo init` still creates `<cwd>/.hippo`. */
|
|
79
|
-
export function getHippoRoot(cwd = process.cwd(), opts) {
|
|
80
|
-
return findHippoStoreDir(cwd, opts) ?? path.join(realpathOrResolve(cwd), '.hippo');
|
|
81
|
-
}
|
|
82
|
-
export function isInitialized(hippoRoot) {
|
|
83
|
-
// A bare .hippo directory is not enough — autoInstallHooks /
|
|
84
|
-
// setupDailySchedule can create it without ever calling initStore,
|
|
85
|
-
// leaving a partial directory (integrations/, logs/, runs/) with no
|
|
86
|
-
// hippo.db. Returning true in that state caused `hippo init` to skip
|
|
87
|
-
// initStore and `hippo recall` to silently fall back to an empty store
|
|
88
|
-
// (incident 2026-04-26: ingest_direct.py against a bare .hippo).
|
|
89
|
-
// Treat the store as initialized only if hippo.db actually exists.
|
|
90
|
-
return fs.existsSync(path.join(hippoRoot, 'hippo.db'));
|
|
91
|
-
}
|
|
92
|
-
export function initStore(hippoRoot) {
|
|
93
|
-
closeHippoDb(openStore(hippoRoot));
|
|
94
|
-
}
|
|
95
|
-
/** One open connection with init done on it, for callers who used to pay for `initStore` + a second `openHippoDb`. */
|
|
96
|
-
export function openStore(hippoRoot) {
|
|
97
|
-
ensureMirrorDirectories(hippoRoot);
|
|
98
|
-
const db = openHippoDb(hippoRoot);
|
|
99
|
-
try {
|
|
100
|
-
const bootstrapped = bootstrapLegacyStore(db, hippoRoot);
|
|
101
|
-
if (bootstrapped) {
|
|
102
|
-
syncMirrorFiles(hippoRoot, db);
|
|
103
|
-
}
|
|
104
|
-
recordHalfLifeBaseForNewStore(db);
|
|
105
|
-
return db;
|
|
106
|
-
}
|
|
107
|
-
catch (error) {
|
|
108
|
-
try {
|
|
109
|
-
closeHippoDb(db);
|
|
110
|
-
}
|
|
111
|
-
catch {
|
|
112
|
-
// Best effort only; surface the original init error.
|
|
113
|
-
}
|
|
114
|
-
throw error;
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
/** `meta` key holding the default half-life base a store's memories are on (src/half-life-migration.ts). */
|
|
118
|
-
export const HALF_LIFE_BASE_META_KEY = 'default_half_life_base';
|
|
119
|
-
/** `meta` key set once no memory of a decision, incident or other object sits on the old flat 90 days. */
|
|
120
|
-
export const TYPED_HALF_LIFE_META_KEY = 'typed_half_life_on_default';
|
|
121
|
-
/**
|
|
122
|
-
* A store with no memories starts on the current default half-life base, so
|
|
123
|
-
* `hippo sleep` never migrates it. A store that already holds memories and
|
|
124
|
-
* no recorded base predates the record, and keeps reading as the legacy
|
|
125
|
-
* 7-day base until sleep migrates it.
|
|
126
|
-
*/
|
|
127
|
-
function recordHalfLifeBaseForNewStore(db) {
|
|
128
|
-
if (getMeta(db, HALF_LIFE_BASE_META_KEY, '') !== '')
|
|
129
|
-
return;
|
|
130
|
-
if (db.prepare(`SELECT 1 AS x FROM memories LIMIT 1`).get() !== undefined)
|
|
131
|
-
return;
|
|
132
|
-
setMeta(db, HALF_LIFE_BASE_META_KEY, String(DEFAULT_HALF_LIFE_DAYS));
|
|
133
|
-
setMeta(db, TYPED_HALF_LIFE_META_KEY, '1');
|
|
134
|
-
}
|
|
135
|
-
function ensureMirrorDirectories(hippoRoot) {
|
|
136
|
-
const dirs = [
|
|
137
|
-
hippoRoot,
|
|
138
|
-
path.join(hippoRoot, 'buffer'),
|
|
139
|
-
path.join(hippoRoot, 'episodic'),
|
|
140
|
-
path.join(hippoRoot, 'semantic'),
|
|
141
|
-
path.join(hippoRoot, 'conflicts'),
|
|
142
|
-
];
|
|
143
|
-
for (const dir of dirs) {
|
|
144
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
/**
|
|
148
|
-
* Serialize a MemoryEntry to markdown with YAML frontmatter.
|
|
149
|
-
*/
|
|
150
|
-
export function serializeEntry(entry) {
|
|
151
|
-
const frontmatter = {
|
|
152
|
-
id: entry.id,
|
|
153
|
-
created: entry.created,
|
|
154
|
-
last_retrieved: entry.last_retrieved,
|
|
155
|
-
retrieval_count: entry.retrieval_count,
|
|
156
|
-
strength: Math.round(entry.strength * 10000) / 10000,
|
|
157
|
-
half_life_days: entry.half_life_days,
|
|
158
|
-
layer: entry.layer,
|
|
159
|
-
tags: entry.tags,
|
|
160
|
-
emotional_valence: entry.emotional_valence,
|
|
161
|
-
schema_fit: entry.schema_fit,
|
|
162
|
-
source: entry.source,
|
|
163
|
-
outcome_score: entry.outcome_score,
|
|
164
|
-
outcome_positive: entry.outcome_positive,
|
|
165
|
-
outcome_negative: entry.outcome_negative,
|
|
166
|
-
conflicts_with: entry.conflicts_with,
|
|
167
|
-
pinned: entry.pinned,
|
|
168
|
-
confidence: entry.confidence ?? 'observed',
|
|
169
|
-
parents: entry.parents ?? [],
|
|
170
|
-
starred: entry.starred ?? false,
|
|
171
|
-
trace_outcome: entry.trace_outcome ?? null,
|
|
172
|
-
source_session_id: entry.source_session_id ?? null,
|
|
173
|
-
kind: entry.kind ?? 'distilled',
|
|
174
|
-
scope: entry.scope ?? null,
|
|
175
|
-
owner: entry.owner ?? null,
|
|
176
|
-
artifact_ref: entry.artifact_ref ?? null,
|
|
177
|
-
};
|
|
178
|
-
// Emit tenant_id only when not 'default' to keep diffs clean for the dominant
|
|
179
|
-
// single-tenant case (mirrors the plan's task 7 guidance).
|
|
180
|
-
const tenantId = entry.tenantId ?? 'default';
|
|
181
|
-
if (tenantId !== 'default') {
|
|
182
|
-
frontmatter['tenant_id'] = tenantId;
|
|
183
|
-
}
|
|
184
|
-
// v39: '' (user-global) and null (unknown, hidden by default) must both round-trip;
|
|
185
|
-
// only undefined (unstamped) is omitted, so a rebuild stamps nothing it can read.
|
|
186
|
-
if (entry.origin_project !== undefined) {
|
|
187
|
-
frontmatter['origin_project'] = entry.origin_project;
|
|
188
|
-
}
|
|
189
|
-
// Spread into a fresh object literal: dumpFrontmatter's Record<string,
|
|
190
|
-
// YamlValue> parameter needs an index signature, which a named interface
|
|
191
|
-
// reference (EntryFrontmatterFields) doesn't structurally provide even
|
|
192
|
-
// though every property's value type already matches.
|
|
193
|
-
const fm = dumpFrontmatter({ ...frontmatter });
|
|
194
|
-
return `${fm}\n\n${entry.content}\n`;
|
|
195
|
-
}
|
|
196
|
-
/**
|
|
197
|
-
* Deserialize a markdown file to a MemoryEntry.
|
|
198
|
-
*/
|
|
199
|
-
export function deserializeEntry(raw) {
|
|
200
|
-
const { data, content } = parseFrontmatter(raw);
|
|
201
|
-
if (!data['id'] || !data['layer'])
|
|
202
|
-
return null;
|
|
203
|
-
// SAFETY: every `as X` below narrows a raw YAML frontmatter field to an
|
|
204
|
-
// enum/union member of MemoryEntry; frontmatter is only ever written by
|
|
205
|
-
// serializeEntry (whose own fields are typed), so out-of-range values here
|
|
206
|
-
// would indicate hand-edited files, which this parser is not required to
|
|
207
|
-
// reject — matches the pre-existing permissive-parse behavior.
|
|
208
|
-
return {
|
|
209
|
-
id: String(data['id']),
|
|
210
|
-
created: String(data['created'] ?? new Date().toISOString()),
|
|
211
|
-
last_retrieved: String(data['last_retrieved'] ?? new Date().toISOString()),
|
|
212
|
-
retrieval_count: Number(data['retrieval_count'] ?? 0),
|
|
213
|
-
strength: Number(data['strength'] ?? 1.0),
|
|
214
|
-
half_life_days: Number(data['half_life_days'] ?? 7),
|
|
215
|
-
layer: data['layer'],
|
|
216
|
-
tags: normalizeStringArray(data['tags']),
|
|
217
|
-
emotional_valence: data['emotional_valence'] ?? 'neutral',
|
|
218
|
-
schema_fit: Number(data['schema_fit'] ?? 0.5),
|
|
219
|
-
source: String(data['source'] ?? 'cli'),
|
|
220
|
-
outcome_score: data['outcome_score'] === null || data['outcome_score'] === undefined ? null : Number(data['outcome_score']),
|
|
221
|
-
outcome_positive: Number(data['outcome_positive'] ?? 0),
|
|
222
|
-
outcome_negative: Number(data['outcome_negative'] ?? 0),
|
|
223
|
-
conflicts_with: normalizeStringArray(data['conflicts_with']),
|
|
224
|
-
pinned: Boolean(data['pinned'] ?? false),
|
|
225
|
-
confidence: data['confidence'] ?? 'observed',
|
|
226
|
-
content: content.trim(),
|
|
227
|
-
parents: normalizeStringArray(data['parents']),
|
|
228
|
-
starred: Boolean(data['starred'] ?? false),
|
|
229
|
-
trace_outcome: data['trace_outcome'] ?? null,
|
|
230
|
-
source_session_id: data['source_session_id'] === null || data['source_session_id'] === undefined
|
|
231
|
-
? null
|
|
232
|
-
: String(data['source_session_id']),
|
|
233
|
-
valid_from: data['valid_from'] ? String(data['valid_from']) : String(data['created'] ?? new Date().toISOString()),
|
|
234
|
-
superseded_by: data['superseded_by'] === null || data['superseded_by'] === undefined
|
|
235
|
-
? null
|
|
236
|
-
: String(data['superseded_by']),
|
|
237
|
-
extracted_from: data['extracted_from'] ?? null,
|
|
238
|
-
dag_level: Number(data['dag_level'] ?? 0),
|
|
239
|
-
dag_parent_id: data['dag_parent_id'] ?? null,
|
|
240
|
-
kind: (data['kind'] ?? 'distilled'),
|
|
241
|
-
scope: data['scope'] === null || data['scope'] === undefined ? null : String(data['scope']),
|
|
242
|
-
owner: data['owner'] === null || data['owner'] === undefined ? null : String(data['owner']),
|
|
243
|
-
artifact_ref: data['artifact_ref'] === null || data['artifact_ref'] === undefined ? null : String(data['artifact_ref']),
|
|
244
|
-
tenantId: data['tenant_id'] === null || data['tenant_id'] === undefined ? 'default' : String(data['tenant_id']),
|
|
245
|
-
origin_project: !('origin_project' in data) ? undefined : data['origin_project'] === null ? null : String(data['origin_project']),
|
|
246
|
-
};
|
|
247
|
-
}
|
|
248
|
-
function normalizeStringArray(value) {
|
|
249
|
-
if (!Array.isArray(value))
|
|
250
|
-
return [];
|
|
251
|
-
return value.map((item) => String(item));
|
|
252
|
-
}
|
|
253
|
-
function rowToEntry(row) {
|
|
254
|
-
// SAFETY: every `as X` below narrows a SQLite column value to an
|
|
255
|
-
// enum/union member of MemoryEntry; `row` comes from MEMORY_SELECT_COLUMNS
|
|
256
|
-
// / MEMORY_SEARCH_COLUMNS, which are the only queries producing MemoryRow,
|
|
257
|
-
// and the DB layer only ever writes these columns from the same enums.
|
|
258
|
-
const entry = {
|
|
259
|
-
id: row.id,
|
|
260
|
-
created: row.created,
|
|
261
|
-
last_retrieved: row.last_retrieved,
|
|
262
|
-
retrieval_count: Number(row.retrieval_count ?? 0),
|
|
263
|
-
strength: Number(row.strength ?? 1),
|
|
264
|
-
half_life_days: Number(row.half_life_days ?? 7),
|
|
265
|
-
layer: row.layer,
|
|
266
|
-
tags: parseJsonArray(row.tags_json),
|
|
267
|
-
emotional_valence: row.emotional_valence ?? 'neutral',
|
|
268
|
-
schema_fit: Number(row.schema_fit ?? 0.5),
|
|
269
|
-
source: row.source ?? 'cli',
|
|
270
|
-
outcome_score: row.outcome_score === null || row.outcome_score === undefined ? null : Number(row.outcome_score),
|
|
271
|
-
outcome_positive: Number(row.outcome_positive ?? 0),
|
|
272
|
-
outcome_negative: Number(row.outcome_negative ?? 0),
|
|
273
|
-
conflicts_with: parseJsonArray(row.conflicts_with_json),
|
|
274
|
-
pinned: Boolean(row.pinned),
|
|
275
|
-
confidence: row.confidence ?? 'observed',
|
|
276
|
-
content: row.content,
|
|
277
|
-
parents: parseJsonArray(row.parents_json),
|
|
278
|
-
starred: Boolean(row.starred),
|
|
279
|
-
trace_outcome: row.trace_outcome ?? null,
|
|
280
|
-
source_session_id: row.source_session_id ?? null,
|
|
281
|
-
valid_from: row.valid_from ?? row.created,
|
|
282
|
-
superseded_by: row.superseded_by ?? null,
|
|
283
|
-
extracted_from: row.extracted_from ?? null,
|
|
284
|
-
dag_level: Number(row.dag_level ?? 0),
|
|
285
|
-
dag_parent_id: row.dag_parent_id ?? null,
|
|
286
|
-
kind: (row.kind ?? 'distilled'),
|
|
287
|
-
scope: row.scope ?? null,
|
|
288
|
-
owner: row.owner ?? null,
|
|
289
|
-
artifact_ref: row.artifact_ref ?? null,
|
|
290
|
-
tenantId: row.tenant_id ?? 'default',
|
|
291
|
-
origin_project: row.origin_project ?? null,
|
|
292
|
-
descendant_count: Number(row.descendant_count ?? 0),
|
|
293
|
-
earliest_at: row.earliest_at ?? null,
|
|
294
|
-
latest_at: row.latest_at ?? null,
|
|
295
|
-
// v0.30 / E1 of DAG live-coupling (schema v28). Symmetric with v25 cache.
|
|
296
|
-
summary_dirty: (Number(row.summary_dirty ?? 0) === 1 ? 1 : 0),
|
|
297
|
-
last_rebuilt_at: row.last_rebuilt_at ?? null,
|
|
298
|
-
rebuild_count: Number(row.rebuild_count ?? 0),
|
|
299
|
-
dag_level_3_built_at: row.dag_level_3_built_at ?? null,
|
|
300
|
-
};
|
|
301
|
-
// F1 (v1.7.0): preserve bm25_score from the FTS path. `'bm25_score' in row`
|
|
302
|
-
// distinguishes "absent column" (non-FTS path) from "column present but
|
|
303
|
-
// value 0" — though FTS5 bm25() never returns 0, this is defensive.
|
|
304
|
-
if ('bm25_score' in row && row.bm25_score !== undefined && row.bm25_score !== null) {
|
|
305
|
-
entry.bm25_score = Number(row.bm25_score);
|
|
306
|
-
}
|
|
307
|
-
return entry;
|
|
308
|
-
}
|
|
309
|
-
function parseJsonArray(raw) {
|
|
310
|
-
if (!raw)
|
|
311
|
-
return [];
|
|
312
|
-
try {
|
|
313
|
-
const parsed = JSON.parse(raw);
|
|
314
|
-
return Array.isArray(parsed) ? parsed.map((item) => String(item)) : [];
|
|
315
|
-
}
|
|
316
|
-
catch (err) {
|
|
317
|
-
log.debug(`store: corrupt JSON array column read as empty: ${err instanceof Error ? err.message : String(err)}`);
|
|
318
|
-
return [];
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
/**
|
|
322
|
-
* Strict parse for the `last_trace_id` meta value (LC1 F1(d) structural
|
|
323
|
-
* fix). A bare Number(raw) would turn '', whitespace, or garbage into a
|
|
324
|
-
* usable-looking 0/NaN — a consumer INSERTing recall_trace_outcomes with
|
|
325
|
-
* trace_id=0 would hit a masked FK violation (row id 0 never exists).
|
|
326
|
-
* Require a clean positive integer string; anything else is treated as
|
|
327
|
-
* unset. This is the ONE place that decides "clean" — every consumer of
|
|
328
|
-
* `HippoIndex.last_trace_id` (outcomeForLastRecall, tests) reads the
|
|
329
|
-
* already-validated value out of `buildIndexFromDb`'s result and never
|
|
330
|
-
* re-parses the raw meta string itself.
|
|
331
|
-
*/
|
|
332
|
-
function parseLastTraceId(raw) {
|
|
333
|
-
const trimmed = (raw ?? '').trim();
|
|
334
|
-
if (!/^\d+$/.test(trimmed) || Number(trimmed) <= 0)
|
|
335
|
-
return null;
|
|
336
|
-
return trimmed;
|
|
337
|
-
}
|
|
338
|
-
function isPlainJsonObject(x) {
|
|
339
|
-
return x !== null && typeof x === 'object' && !Array.isArray(x);
|
|
340
|
-
}
|
|
341
|
-
function parseJsonObject(raw) {
|
|
342
|
-
if (!raw)
|
|
343
|
-
return {};
|
|
344
|
-
try {
|
|
345
|
-
const parsed = JSON.parse(raw);
|
|
346
|
-
if (isPlainJsonObject(parsed)) {
|
|
347
|
-
return parsed;
|
|
348
|
-
}
|
|
349
|
-
return {};
|
|
350
|
-
}
|
|
351
|
-
catch (err) {
|
|
352
|
-
log.debug(`store: corrupt JSON object column read as empty: ${err instanceof Error ? err.message : String(err)}`);
|
|
353
|
-
return {};
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
function rowToTaskSnapshot(row) {
|
|
357
|
-
return {
|
|
358
|
-
id: Number(row.id),
|
|
359
|
-
task: row.task,
|
|
360
|
-
summary: row.summary,
|
|
361
|
-
next_step: row.next_step,
|
|
362
|
-
status: row.status,
|
|
363
|
-
source: row.source,
|
|
364
|
-
session_id: row.session_id ?? null,
|
|
365
|
-
scope: row.scope ?? null,
|
|
366
|
-
created_at: row.created_at,
|
|
367
|
-
updated_at: row.updated_at,
|
|
368
|
-
};
|
|
369
|
-
}
|
|
370
|
-
function rowToMemoryConflict(row) {
|
|
371
|
-
return {
|
|
372
|
-
id: Number(row.id),
|
|
373
|
-
memory_a_id: row.memory_a_id,
|
|
374
|
-
memory_b_id: row.memory_b_id,
|
|
375
|
-
reason: row.reason,
|
|
376
|
-
score: Number(row.score ?? 0),
|
|
377
|
-
status: row.status,
|
|
378
|
-
detected_at: row.detected_at,
|
|
379
|
-
updated_at: row.updated_at,
|
|
380
|
-
};
|
|
381
|
-
}
|
|
382
|
-
function rowToSessionEvent(row) {
|
|
383
|
-
return {
|
|
384
|
-
id: Number(row.id),
|
|
385
|
-
session_id: row.session_id,
|
|
386
|
-
task: row.task ?? null,
|
|
387
|
-
event_type: row.event_type,
|
|
388
|
-
content: row.content,
|
|
389
|
-
source: row.source,
|
|
390
|
-
scope: row.scope ?? null,
|
|
391
|
-
metadata: parseJsonObject(row.metadata_json),
|
|
392
|
-
created_at: row.created_at,
|
|
393
|
-
};
|
|
394
|
-
}
|
|
395
|
-
// Tenant-scoped mirror file paths. The single-tenant 'default' deployment
|
|
396
|
-
// keeps the original `active-task.md` / `recent-session.md` filenames for
|
|
397
|
-
// on-disk back-compat; multi-tenant deployments get a `.<tenantId>` suffix
|
|
398
|
-
// so tenant B saving cannot overwrite tenant A's mirror file.
|
|
399
|
-
function activeTaskMirrorPath(hippoRoot, tenantId) {
|
|
400
|
-
const file = tenantId === 'default' ? 'active-task.md' : `active-task.${tenantId}.md`;
|
|
401
|
-
return path.join(hippoRoot, 'buffer', file);
|
|
402
|
-
}
|
|
403
|
-
function recentSessionMirrorPath(hippoRoot, tenantId) {
|
|
404
|
-
const file = tenantId === 'default' ? 'recent-session.md' : `recent-session.${tenantId}.md`;
|
|
405
|
-
return path.join(hippoRoot, 'buffer', file);
|
|
406
|
-
}
|
|
407
|
-
function writeActiveTaskMirror(hippoRoot, tenantId, snapshot) {
|
|
408
|
-
const filePath = activeTaskMirrorPath(hippoRoot, tenantId);
|
|
409
|
-
const fm = dumpFrontmatter({
|
|
410
|
-
id: snapshot.id,
|
|
411
|
-
task: snapshot.task,
|
|
412
|
-
status: snapshot.status,
|
|
413
|
-
source: snapshot.source,
|
|
414
|
-
session_id: snapshot.session_id,
|
|
415
|
-
created_at: snapshot.created_at,
|
|
416
|
-
updated_at: snapshot.updated_at,
|
|
417
|
-
next_step: snapshot.next_step,
|
|
418
|
-
});
|
|
419
|
-
const body = [
|
|
420
|
-
`# Active Task Snapshot`,
|
|
421
|
-
'',
|
|
422
|
-
`## Summary`,
|
|
423
|
-
snapshot.summary,
|
|
424
|
-
'',
|
|
425
|
-
`## Next step`,
|
|
426
|
-
snapshot.next_step,
|
|
427
|
-
'',
|
|
428
|
-
`## Task`,
|
|
429
|
-
snapshot.task,
|
|
430
|
-
'',
|
|
431
|
-
];
|
|
432
|
-
if (snapshot.session_id) {
|
|
433
|
-
body.push(`## Session`, snapshot.session_id, '');
|
|
434
|
-
}
|
|
435
|
-
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
436
|
-
fs.writeFileSync(filePath, `${fm}\n\n${body.join('\n')}`, 'utf8');
|
|
437
|
-
}
|
|
438
|
-
function removeActiveTaskMirror(hippoRoot, tenantId) {
|
|
439
|
-
const filePath = activeTaskMirrorPath(hippoRoot, tenantId);
|
|
440
|
-
if (fs.existsSync(filePath)) {
|
|
441
|
-
fs.unlinkSync(filePath);
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
function writeRecentSessionMirror(hippoRoot, tenantId, events) {
|
|
445
|
-
const filePath = recentSessionMirrorPath(hippoRoot, tenantId);
|
|
446
|
-
if (events.length === 0) {
|
|
447
|
-
if (fs.existsSync(filePath)) {
|
|
448
|
-
fs.unlinkSync(filePath);
|
|
449
|
-
}
|
|
450
|
-
return;
|
|
451
|
-
}
|
|
452
|
-
const latest = events[events.length - 1];
|
|
453
|
-
const fm = dumpFrontmatter({
|
|
454
|
-
session_id: latest.session_id,
|
|
455
|
-
task: latest.task,
|
|
456
|
-
event_count: events.length,
|
|
457
|
-
updated_at: latest.created_at,
|
|
458
|
-
});
|
|
459
|
-
const lines = [
|
|
460
|
-
'# Recent Session Trail',
|
|
461
|
-
'',
|
|
462
|
-
`- Session: ${latest.session_id}`,
|
|
463
|
-
`- Task: ${latest.task ?? 'n/a'}`,
|
|
464
|
-
`- Updated: ${latest.created_at}`,
|
|
465
|
-
'',
|
|
466
|
-
'## Events',
|
|
467
|
-
'',
|
|
468
|
-
];
|
|
469
|
-
for (const event of events) {
|
|
470
|
-
lines.push(`- [${event.created_at}] (${event.event_type}) ${event.content}`);
|
|
471
|
-
}
|
|
472
|
-
lines.push('');
|
|
473
|
-
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
474
|
-
fs.writeFileSync(filePath, `${fm}\n\n${lines.join('\n')}`, 'utf8');
|
|
475
|
-
}
|
|
476
|
-
function writeConflictMirrors(hippoRoot, conflicts) {
|
|
477
|
-
const conflictDir = path.join(hippoRoot, 'conflicts');
|
|
478
|
-
fs.mkdirSync(conflictDir, { recursive: true });
|
|
479
|
-
const keep = new Set();
|
|
480
|
-
for (const conflict of conflicts) {
|
|
481
|
-
const filename = `conflict_${conflict.id}.md`;
|
|
482
|
-
keep.add(filename);
|
|
483
|
-
const fm = dumpFrontmatter({
|
|
484
|
-
id: conflict.id,
|
|
485
|
-
memory_a_id: conflict.memory_a_id,
|
|
486
|
-
memory_b_id: conflict.memory_b_id,
|
|
487
|
-
reason: conflict.reason,
|
|
488
|
-
score: Math.round(conflict.score * 10000) / 10000,
|
|
489
|
-
status: conflict.status,
|
|
490
|
-
detected_at: conflict.detected_at,
|
|
491
|
-
updated_at: conflict.updated_at,
|
|
492
|
-
});
|
|
493
|
-
const body = [
|
|
494
|
-
'# Memory Conflict',
|
|
495
|
-
'',
|
|
496
|
-
`- Memory A: ${conflict.memory_a_id}`,
|
|
497
|
-
`- Memory B: ${conflict.memory_b_id}`,
|
|
498
|
-
`- Reason: ${conflict.reason}`,
|
|
499
|
-
`- Score: ${conflict.score.toFixed(3)}`,
|
|
500
|
-
`- Status: ${conflict.status}`,
|
|
501
|
-
'',
|
|
502
|
-
].join('\n');
|
|
503
|
-
fs.writeFileSync(path.join(conflictDir, filename), `${fm}\n\n${body}`, 'utf8');
|
|
504
|
-
}
|
|
505
|
-
for (const existing of fs.readdirSync(conflictDir)) {
|
|
506
|
-
if (existing === '.gitkeep')
|
|
507
|
-
continue;
|
|
508
|
-
if (!keep.has(existing)) {
|
|
509
|
-
fs.unlinkSync(path.join(conflictDir, existing));
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
function canonicalConflictPair(aId, bId) {
|
|
514
|
-
return aId < bId
|
|
515
|
-
? { memory_a_id: aId, memory_b_id: bId }
|
|
516
|
-
: { memory_a_id: bId, memory_b_id: aId };
|
|
517
|
-
}
|
|
518
|
-
function loadSearchRows(db, query, limit, tenantId, scopeFilter, includeSuperseded = true) {
|
|
519
|
-
// tenantId undefined = no tenant filter (legacy callers / cross-deployment
|
|
520
|
-
// helpers). tenantId set = strict tenant isolation, leveraging the composite
|
|
521
|
-
// idx_memories_tenant_created (leading column tenant_id, O(log n) lookup).
|
|
522
|
-
const tenantPredicate = tenantId !== undefined ? ` AND m.tenant_id = ?` : '';
|
|
523
|
-
const tenantPredicateNoAlias = tenantId !== undefined ? ` AND tenant_id = ?` : '';
|
|
524
|
-
const tenantOnlyPredicate = tenantId !== undefined ? ` WHERE tenant_id = ?` : '';
|
|
525
|
-
const tenantParams = tenantId !== undefined ? [tenantId] : [];
|
|
526
|
-
// v1.12.6 — belt-and-suspenders against `kind='archived'` leaking into recall.
|
|
527
|
-
// `kind='archived'` is a transient sentinel inside `archiveRawMemory`'s
|
|
528
|
-
// SAVEPOINT (src/raw-archive.ts:56): UPDATE kind = 'archived' immediately
|
|
529
|
-
// followed by DELETE, both inside one savepoint that commits or rolls back
|
|
530
|
-
// atomically. SQLite atomicity guarantees no concurrent reader sees the
|
|
531
|
-
// intermediate state. This filter is defensive-only against:
|
|
532
|
-
// (a) future bugs that drop the SAVEPOINT,
|
|
533
|
-
// (b) future bugs that introduce kind='archived' as a persisted state,
|
|
534
|
-
// (c) external direct-SQL writes that bypass archiveRawMemory.
|
|
535
|
-
// tenantOnlyPredicate starts with " WHERE tenant_id = ?" when tenant is set;
|
|
536
|
-
// when unset, we have no WHERE yet, so the archived clause needs both AND
|
|
537
|
-
// and WHERE forms. The "tenant-only" path always has WHERE (from tenant or
|
|
538
|
-
// we synthesize one).
|
|
539
|
-
const archivedClauseAlias = ` AND m.kind != 'archived'`;
|
|
540
|
-
const archivedClauseNoAlias = ` AND kind != 'archived'`;
|
|
541
|
-
// For the "tenant-only" path: if no tenant set, tenantOnlyPredicate is '',
|
|
542
|
-
// so prepend WHERE; if tenant set, append AND. handled in each call site
|
|
543
|
-
// by always joining `tenantOnlyPredicate + archivedClauseTenantOnly` where
|
|
544
|
-
// the latter switches between " AND" and " WHERE" based on caller context.
|
|
545
|
-
const archivedClauseTenantOnly = tenantId !== undefined ? ` AND kind != 'archived'` : ` WHERE kind != 'archived'`;
|
|
546
|
-
// v1.7.1 — recall-mode scope predicate (root-cause fix for the
|
|
547
|
-
// `unknown:legacy` leak codex flagged on the v1.6.5 review). Forms:
|
|
548
|
-
// undefined → no scope filter (background pipelines)
|
|
549
|
-
// { mode: 'default-deny' } → exclude unknown:legacy + ':private:'
|
|
550
|
-
// { mode: 'exact' } → m.scope = 'X'
|
|
551
|
-
// { mode: 'default-deny-or-exact' } → default set OR m.scope = 'X'
|
|
552
|
-
// v1.25.0: the private-scope exclusion now ALSO runs here pre-window as a
|
|
553
|
-
// conservative LIKE approximation (see the deny-mode comment below); the
|
|
554
|
-
// exact anchored regex stays the authoritative JS post-filter in the
|
|
555
|
-
// recall consumers.
|
|
556
|
-
//
|
|
557
|
-
// **Cross-reference:** `passesScopeFilterForRecall` in src/api.ts encodes
|
|
558
|
-
// the same default-deny rule. If the deny list grows (e.g. add
|
|
559
|
-
// `unknown:purged`), update BOTH this SQL clause AND that helper AND the
|
|
560
|
-
// continuity inline closure. v1.7.2 will consolidate them.
|
|
561
|
-
let scopeClauseAlias = '';
|
|
562
|
-
let scopeClauseNoAlias = '';
|
|
563
|
-
let scopeClauseTenantOnly = '';
|
|
564
|
-
const scopeParams = [];
|
|
565
|
-
if (scopeFilter !== undefined) {
|
|
566
|
-
if (scopeFilter.mode === 'default-deny') {
|
|
567
|
-
// T2: bind from RECALL_DEFAULT_DENY_SCOPES so SQL and JS share one
|
|
568
|
-
// source of truth. Module-load assertion at the top of this file
|
|
569
|
-
// guarantees length > 0, so NOT IN () (a SQL parse error) is impossible.
|
|
570
|
-
// NULL handling: m.scope NOT IN (?, ?) returns NULL on m.scope = NULL
|
|
571
|
-
// (three-valued logic). The `m.scope IS NULL OR ...` disjunct admits
|
|
572
|
-
// NULL rows.
|
|
573
|
-
// v1.25.0 (codex review-stage P2): the private-scope exclusion must run
|
|
574
|
-
// BEFORE the LIMIT, or a store where >window matching rows are
|
|
575
|
-
// `<source>:private:*` (heavy private-channel ingestion) starves every
|
|
576
|
-
// admitted row out of the candidate window and recall returns
|
|
577
|
-
// empty/incomplete. SQL uses a deliberately CONSERVATIVE approximation
|
|
578
|
-
// of the exact JS regex (`NOT LIKE '%:private:%'`, ASCII
|
|
579
|
-
// case-insensitive): it denies a strict superset (any scope containing
|
|
580
|
-
// ':private:' anywhere, any case) — fail-closed for a security filter.
|
|
581
|
-
// The exact anchored regex (`passesScopeFilterForRecall` /
|
|
582
|
-
// `isPrivateScope`) remains the authoritative JS post-filter.
|
|
583
|
-
const placeholders = RECALL_DEFAULT_DENY_SCOPES.map(() => '?').join(', ');
|
|
584
|
-
scopeClauseAlias = ` AND (m.scope IS NULL OR (m.scope NOT IN (${placeholders}) AND m.scope NOT LIKE '%:private:%'))`;
|
|
585
|
-
scopeClauseNoAlias = ` AND (scope IS NULL OR (scope NOT IN (${placeholders}) AND scope NOT LIKE '%:private:%'))`;
|
|
586
|
-
scopeClauseTenantOnly = scopeClauseNoAlias;
|
|
587
|
-
scopeParams.push(...RECALL_DEFAULT_DENY_SCOPES);
|
|
588
|
-
}
|
|
589
|
-
else if (scopeFilter.mode === 'default-deny-or-exact') {
|
|
590
|
-
// v1.25.0 CLI semantics: default-admitted set PLUS the explicitly
|
|
591
|
-
// requested scope (see the RecallScopeFilter doc above). Same NULL
|
|
592
|
-
// three-valued-logic handling and same pre-window private exclusion as
|
|
593
|
-
// 'default-deny' (codex P2, comment above); the trailing `OR scope = ?`
|
|
594
|
-
// arm keeps the explicitly requested scope loadable, INCLUDING a
|
|
595
|
-
// requested private or quarantine scope (deliberate owner access, same
|
|
596
|
-
// as api.recall's exact-match for the same input).
|
|
597
|
-
const placeholders = RECALL_DEFAULT_DENY_SCOPES.map(() => '?').join(', ');
|
|
598
|
-
scopeClauseAlias = ` AND (m.scope IS NULL OR (m.scope NOT IN (${placeholders}) AND m.scope NOT LIKE '%:private:%') OR m.scope = ?)`;
|
|
599
|
-
scopeClauseNoAlias = ` AND (scope IS NULL OR (scope NOT IN (${placeholders}) AND scope NOT LIKE '%:private:%') OR scope = ?)`;
|
|
600
|
-
scopeClauseTenantOnly = scopeClauseNoAlias;
|
|
601
|
-
scopeParams.push(...RECALL_DEFAULT_DENY_SCOPES, scopeFilter.value);
|
|
602
|
-
}
|
|
603
|
-
else {
|
|
604
|
-
// mode === 'exact'
|
|
605
|
-
scopeClauseAlias = ` AND m.scope = ?`;
|
|
606
|
-
scopeClauseNoAlias = ` AND scope = ?`;
|
|
607
|
-
scopeClauseTenantOnly = scopeClauseNoAlias;
|
|
608
|
-
scopeParams.push(scopeFilter.value);
|
|
609
|
-
}
|
|
610
|
-
}
|
|
611
|
-
const currentAlias = includeSuperseded ? '' : ' AND m.superseded_by IS NULL';
|
|
612
|
-
const currentNoAlias = includeSuperseded ? '' : ' AND superseded_by IS NULL';
|
|
613
|
-
const terms = Array.from(new Set(tokenize(query)));
|
|
614
|
-
if (terms.length === 0) {
|
|
615
|
-
// F3 (v1.7.0) self-review: empty-query path is the second uncapped
|
|
616
|
-
// path (codex diff-pass caught the full-store fallback at the bottom;
|
|
617
|
-
// this no-terms path had the same shape). Apply LIMIT so all four
|
|
618
|
-
// candidate paths honour the caller's cap when set.
|
|
619
|
-
const sql = `SELECT ${MEMORY_SELECT_COLUMNS} FROM memories${tenantOnlyPredicate}${archivedClauseTenantOnly}${scopeClauseTenantOnly}${currentNoAlias} ORDER BY created ASC, id ASC LIMIT ?`;
|
|
620
|
-
// SAFETY: sql selects exactly MEMORY_SELECT_COLUMNS, whose column list
|
|
621
|
-
// matches MemoryRow's field set.
|
|
622
|
-
return db.prepare(sql).all(...tenantParams, ...scopeParams, limit);
|
|
623
|
-
}
|
|
624
|
-
// v1.7.1 — test/diagnostic hook: `HIPPO_FORCE_LIKE_PATH=1` forces the
|
|
625
|
-
// LIKE-fallback path here only. Gated at the read-call site so writes
|
|
626
|
-
// (`syncFtsRow`, `deleteFtsRow`, `raw-archive.ts::archiveRaw`) keep using
|
|
627
|
-
// `isFtsAvailable` honestly and never silently skip FTS index sync.
|
|
628
|
-
// Lets tests exercise the LIKE branch deterministically without
|
|
629
|
-
// poisoning the on-disk FTS state.
|
|
630
|
-
const forceLikePath = process.env.HIPPO_FORCE_LIKE_PATH === '1';
|
|
631
|
-
if (!forceLikePath && isFtsAvailable(db)) {
|
|
632
|
-
try {
|
|
633
|
-
const ftsQuery = terms.map((t) => `"${t.replace(/"/g, '""')}"`).join(' OR ');
|
|
634
|
-
// memories_fts virtual table has no tenant_id column; filter via the
|
|
635
|
-
// joined memories row (cheap with idx_memories_tenant_created leading
|
|
636
|
-
// on tenant_id).
|
|
637
|
-
// F1 (v1.7.0): MEMORY_SEARCH_COLUMNS adds bm25_score as the trailing
|
|
638
|
-
// result column. Every other column is m.<col> AS <col> so rowToEntry
|
|
639
|
-
// sees the same shape it always has.
|
|
640
|
-
// SAFETY: MEMORY_SEARCH_COLUMNS aliases every column to the same name
|
|
641
|
-
// MEMORY_SELECT_COLUMNS uses (plus bm25_score), matching MemoryRow.
|
|
642
|
-
const rows = db.prepare(`
|
|
643
|
-
SELECT ${MEMORY_SEARCH_COLUMNS}
|
|
644
|
-
FROM memories m
|
|
645
|
-
JOIN memories_fts f ON f.id = m.id
|
|
646
|
-
WHERE memories_fts MATCH ?${tenantPredicate}${archivedClauseAlias}${scopeClauseAlias}${currentAlias}
|
|
647
|
-
ORDER BY bm25(memories_fts), m.updated_at DESC, m.content ASC, m.id ASC
|
|
648
|
-
LIMIT ?
|
|
649
|
-
`).all(ftsQuery, ...tenantParams, ...scopeParams, limit);
|
|
650
|
-
if (rows.length > 0)
|
|
651
|
-
return rows;
|
|
652
|
-
}
|
|
653
|
-
catch {
|
|
654
|
-
// Fall back to LIKE matching below.
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
const escapeLike = (term) => term.replace(/[%_\\]/g, '\\$&');
|
|
658
|
-
const where = terms.map(() => `(LOWER(content) LIKE ? ESCAPE '\\' OR LOWER(tags_json) LIKE ? ESCAPE '\\')`).join(' OR ');
|
|
659
|
-
const params = terms.flatMap((term) => {
|
|
660
|
-
const like = `%${escapeLike(term)}%`;
|
|
661
|
-
return [like, like];
|
|
662
|
-
});
|
|
663
|
-
// SAFETY: this query selects exactly MEMORY_SELECT_COLUMNS, matching
|
|
664
|
-
// MemoryRow's field set.
|
|
665
|
-
const rows = db.prepare(`
|
|
666
|
-
SELECT ${MEMORY_SELECT_COLUMNS}
|
|
667
|
-
FROM memories
|
|
668
|
-
WHERE (${where})${tenantPredicateNoAlias}${archivedClauseNoAlias}${scopeClauseNoAlias}${currentNoAlias}
|
|
669
|
-
ORDER BY updated_at DESC, created DESC, content ASC, id ASC
|
|
670
|
-
LIMIT ?
|
|
671
|
-
`).all(...params, ...tenantParams, ...scopeParams, limit);
|
|
672
|
-
if (rows.length > 0)
|
|
673
|
-
return rows;
|
|
674
|
-
// F3 (v1.7.0) codex P1: pre-v1.7.0 the full-store fallback ignored
|
|
675
|
-
// `limit` and could return the whole tenant store. With scorerWindow
|
|
676
|
-
// now reported on RecallResult, an unbounded fallback would lie about
|
|
677
|
-
// candidate-pool size. Apply LIMIT here so all four paths honour the
|
|
678
|
-
// caller's cap.
|
|
679
|
-
const fallback = `SELECT ${MEMORY_SELECT_COLUMNS} FROM memories${tenantOnlyPredicate}${archivedClauseTenantOnly}${scopeClauseTenantOnly}${currentNoAlias} ORDER BY created ASC, id ASC LIMIT ?`;
|
|
680
|
-
// SAFETY: fallback selects exactly MEMORY_SELECT_COLUMNS, matching
|
|
681
|
-
// MemoryRow's field set.
|
|
682
|
-
return db.prepare(fallback).all(...tenantParams, ...scopeParams, limit);
|
|
683
|
-
}
|
|
684
|
-
function writeMarkdownMirror(hippoRoot, entry) {
|
|
685
|
-
removeEntryMirrors(hippoRoot, entry.id);
|
|
686
|
-
const dir = layerDir(hippoRoot, entry.layer);
|
|
687
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
688
|
-
fs.writeFileSync(path.join(dir, `${entry.id}.md`), serializeEntry(entry), 'utf8');
|
|
689
|
-
}
|
|
690
|
-
// AT1 P1 fix (codex): `writeMarkdownMirror` writes ANY layer's mirror,
|
|
691
|
-
// including `trace/<id>.md` for Layer.Trace rows (auto-promoted traces,
|
|
692
|
-
// consolidate.ts) — but this enumeration only walked
|
|
693
|
-
// Buffer/Episodic/Semantic. A rejected/forgotten trace row's markdown
|
|
694
|
-
// content survived on disk while the purge (and `hippo reject`/plain
|
|
695
|
-
// `forget`) reported success, and a stale trace mirror is exactly the
|
|
696
|
-
// resurrection channel bootstrapLegacyStore/rebuildIndex guard against.
|
|
697
|
-
// Fixes BOTH the AT1 reject-flow purge and the pre-existing plain-`forget`
|
|
698
|
-
// gap for trace rows (deleteEntry has always called this same function).
|
|
699
|
-
export function removeEntryMirrors(hippoRoot, id) {
|
|
700
|
-
for (const layer of [Layer.Buffer, Layer.Episodic, Layer.Semantic, Layer.Trace]) {
|
|
701
|
-
const file = path.join(layerDir(hippoRoot, layer), `${id}.md`);
|
|
702
|
-
if (fs.existsSync(file)) {
|
|
703
|
-
fs.unlinkSync(file);
|
|
704
|
-
}
|
|
705
|
-
}
|
|
706
|
-
}
|
|
707
|
-
/**
|
|
708
|
-
* AT1 mirror-purge honesty fix (docs/plans/2026-08-15-at1-rejected-value-tombstone.md):
|
|
709
|
-
* the candidate markdown mirror paths still on disk for `id`, computed the
|
|
710
|
-
* same way `removeEntryMirrors` walks them (one per layer: buffer/episodic/
|
|
711
|
-
* semantic), filtered to the ones that still `fs.existsSync`. Used to report
|
|
712
|
-
* an EXPLICIT path when a best-effort purge fails and no reaper exists to
|
|
713
|
-
* retry it — plain `removeEntryMirrors` returns void, giving no way to name
|
|
714
|
-
* which file is stuck.
|
|
715
|
-
*/
|
|
716
|
-
export function getExistingEntryMirrorPaths(hippoRoot, id) {
|
|
717
|
-
// AT1 P1 fix (codex): same missing Layer.Trace as removeEntryMirrors above
|
|
718
|
-
// — kept in lockstep with it since this function's whole purpose is
|
|
719
|
-
// walking the mirror paths "the same way removeEntryMirrors walks them"
|
|
720
|
-
// (see its own doc comment).
|
|
721
|
-
return [Layer.Buffer, Layer.Episodic, Layer.Semantic, Layer.Trace]
|
|
722
|
-
.map((layer) => path.join(layerDir(hippoRoot, layer), `${id}.md`))
|
|
723
|
-
.filter((file) => fs.existsSync(file));
|
|
724
|
-
}
|
|
725
|
-
/**
|
|
726
|
-
* AT1 fix: best-effort markdown-mirror purge shared by `reject-flow.ts`'s
|
|
727
|
-
* `rejectValue` and `resolveConflict`'s post-commit purge. Both used to log
|
|
728
|
-
* "will retry via reaper on next open" for EVERY failure, but the reaper
|
|
729
|
-
* (`cleanupArchivedMirrors`, raw-archive-mirror-cleanup.ts) only scans
|
|
730
|
-
* `raw_archive` — that message was false for a non-raw id, which has no
|
|
731
|
-
* reaper at all.
|
|
732
|
-
*
|
|
733
|
-
* Retries the unlink once synchronously (the common real-world failure is a
|
|
734
|
-
* transient lock/AV-scanner false positive, not a permanent one). On a
|
|
735
|
-
* second failure: raw ids still get the honest reaper message (true); non-raw
|
|
736
|
-
* ids get the EXPLICIT leftover file path(s) and a manual-delete instruction,
|
|
737
|
-
* since nothing will ever retry them automatically.
|
|
738
|
-
*
|
|
739
|
-
* Returns true if the mirror ended up purged (first or second attempt).
|
|
740
|
-
*/
|
|
741
|
-
export function purgeMirrorBestEffort(hippoRoot, id, isRaw, logPrefix) {
|
|
742
|
-
try {
|
|
743
|
-
removeEntryMirrors(hippoRoot, id);
|
|
744
|
-
return true;
|
|
745
|
-
}
|
|
746
|
-
catch {
|
|
747
|
-
try {
|
|
748
|
-
removeEntryMirrors(hippoRoot, id);
|
|
749
|
-
return true;
|
|
750
|
-
}
|
|
751
|
-
catch (secondErr) {
|
|
752
|
-
const msg = secondErr instanceof Error ? secondErr.message : String(secondErr);
|
|
753
|
-
if (isRaw) {
|
|
754
|
-
log.error(`${logPrefix}: mirror cleanup failed for ${id} (will retry via reaper on next open): ${msg}`);
|
|
755
|
-
}
|
|
756
|
-
else {
|
|
757
|
-
const leftover = getExistingEntryMirrorPaths(hippoRoot, id);
|
|
758
|
-
const pathsNote = leftover.length > 0 ? leftover.join(', ') : `${id}.md (path unresolved)`;
|
|
759
|
-
log.error(`${logPrefix}: mirror cleanup failed for ${id} - no automatic retry exists for this file, ` +
|
|
760
|
-
`delete it manually: ${pathsNote} (${msg})`);
|
|
761
|
-
}
|
|
762
|
-
return false;
|
|
763
|
-
}
|
|
764
|
-
}
|
|
765
|
-
}
|
|
766
|
-
function bootstrapLegacyStore(db, hippoRoot) {
|
|
767
|
-
// SAFETY: countRow's shape matches the single `COUNT(*) AS count` column
|
|
768
|
-
// selected above; `.get()` returns undefined only when no row exists.
|
|
769
|
-
const countRow = db.prepare(`SELECT COUNT(*) AS count FROM memories`).get();
|
|
770
|
-
const memoryCount = Number(countRow?.count ?? 0);
|
|
771
|
-
if (memoryCount > 0)
|
|
772
|
-
return false;
|
|
773
|
-
// AT1 P2 fix: memoryCount alone is not a reliable "already bootstrapped"
|
|
774
|
-
// signal once the rejection guard exists. If EVERY legacy mirror row is
|
|
775
|
-
// rejected, memories stays at 0 rows even after a successful bootstrap
|
|
776
|
-
// pass, so the memoryCount>0 gate above never trips — every subsequent
|
|
777
|
-
// initStore() call would re-run this whole function: re-scan the legacy
|
|
778
|
-
// mirrors, re-attempt (and re-refuse, re-auditing) every row, and
|
|
779
|
-
// re-INSERT the legacy consolidation_runs rows with no dedup, duplicating
|
|
780
|
-
// them on each open. A dedicated meta flag marks bootstrap as
|
|
781
|
-
// attempted-and-settled regardless of how many rows actually landed.
|
|
782
|
-
if (getMeta(db, 'legacy_bootstrap_completed', '0') === '1')
|
|
783
|
-
return false;
|
|
784
|
-
const legacyEntries = loadLegacyEntriesFromMarkdown(hippoRoot);
|
|
785
|
-
if (legacyEntries.length === 0)
|
|
786
|
-
return false;
|
|
787
|
-
db.exec('BEGIN');
|
|
788
|
-
try {
|
|
789
|
-
// AT1 (plan §3, round-3 redesign): run the guard LIVE per row rather
|
|
790
|
-
// than bypassing it. bootstrapLegacyStore is exactly the channel through
|
|
791
|
-
// which a stale/never-purged markdown mirror could resurrect a rejected
|
|
792
|
-
// value; a skip-and-count here closes that structurally, independent of
|
|
793
|
-
// mirror state. The refusal audit is written INLINE inside this
|
|
794
|
-
// still-open loop transaction (plain audit() — nothing is rolled back
|
|
795
|
-
// on a per-row skip, so the post-rollback auditRejectionRefusal helper
|
|
796
|
-
// is the wrong tool here).
|
|
797
|
-
let rejectedCount = 0;
|
|
798
|
-
for (const entry of legacyEntries) {
|
|
799
|
-
// v39: legacy markdown carries no origin_project; stamp from the store
|
|
800
|
-
// location so bootstrapped rows stay visible to ambient context.
|
|
801
|
-
const stamped = stampOriginProjectForImport(hippoRoot, entry);
|
|
802
|
-
try {
|
|
803
|
-
upsertEntryRow(db, stamped);
|
|
804
|
-
}
|
|
805
|
-
catch (err) {
|
|
806
|
-
if (err instanceof RejectedValueError) {
|
|
807
|
-
rejectedCount++;
|
|
808
|
-
audit(db, 'reject_refusal', err.entryId, { digest: err.digest, reason: err.reason }, 'cli', err.tenantId);
|
|
809
|
-
continue;
|
|
810
|
-
}
|
|
811
|
-
throw err;
|
|
812
|
-
}
|
|
813
|
-
}
|
|
814
|
-
if (rejectedCount > 0) {
|
|
815
|
-
log.warn(`bootstrapLegacyStore: skipped ${rejectedCount} rejected value(s) found in legacy mirrors`);
|
|
816
|
-
}
|
|
817
|
-
const legacyIndex = loadLegacyIndexFile(hippoRoot);
|
|
818
|
-
setMeta(db, 'last_retrieval_ids', JSON.stringify(legacyIndex.last_retrieval_ids ?? []));
|
|
819
|
-
// LC1: legacy index.json predates last_trace_id, so this is '' for every
|
|
820
|
-
// pre-v40 store — harmless, matches the ensureMetaDefaults default.
|
|
821
|
-
// Coerce like its neighbors below coerce theirs (independent-review-critic
|
|
822
|
-
// LOW finding): accept only a clean digit string, else fall back to ''
|
|
823
|
-
// rather than trusting whatever a hand-edited/corrupt index.json carries.
|
|
824
|
-
const legacyTraceId = String(legacyIndex.last_trace_id ?? '');
|
|
825
|
-
setMeta(db, 'last_trace_id', /^\d+$/.test(legacyTraceId) ? legacyTraceId : '');
|
|
826
|
-
const legacyStats = loadLegacyStatsFile(hippoRoot);
|
|
827
|
-
setMeta(db, 'total_remembered', String(Number(legacyStats.total_remembered ?? 0)));
|
|
828
|
-
setMeta(db, 'total_recalled', String(Number(legacyStats.total_recalled ?? 0)));
|
|
829
|
-
setMeta(db, 'total_forgotten', String(Number(legacyStats.total_forgotten ?? 0)));
|
|
830
|
-
const runs = Array.isArray(legacyStats.consolidation_runs) ? legacyStats.consolidation_runs : [];
|
|
831
|
-
const insertRun = db.prepare(`INSERT INTO consolidation_runs(timestamp, decayed, merged, removed) VALUES (?, ?, ?, ?)`);
|
|
832
|
-
for (const run of runs) {
|
|
833
|
-
if (!isPlainJsonObject(run))
|
|
834
|
-
continue;
|
|
835
|
-
const row = run;
|
|
836
|
-
insertRun.run(String(row.timestamp ?? new Date().toISOString()), Number(row.decayed ?? 0), Number(row.merged ?? 0), Number(row.removed ?? 0));
|
|
837
|
-
}
|
|
838
|
-
// AT1 P2 fix: stamp completion regardless of how many rows actually
|
|
839
|
-
// landed (all-rejected included) — see the gate comment above.
|
|
840
|
-
setMeta(db, 'legacy_bootstrap_completed', '1');
|
|
841
|
-
db.exec('COMMIT');
|
|
842
|
-
}
|
|
843
|
-
catch (error) {
|
|
844
|
-
try {
|
|
845
|
-
db.exec('ROLLBACK');
|
|
846
|
-
}
|
|
847
|
-
catch { /* already rolled back; keep the original error */ }
|
|
848
|
-
throw error;
|
|
849
|
-
}
|
|
850
|
-
return true;
|
|
851
|
-
}
|
|
852
|
-
function loadLegacyEntriesFromMarkdown(hippoRoot) {
|
|
853
|
-
const entries = [];
|
|
854
|
-
for (const layer of [Layer.Buffer, Layer.Episodic, Layer.Semantic]) {
|
|
855
|
-
const dir = layerDir(hippoRoot, layer);
|
|
856
|
-
if (!fs.existsSync(dir))
|
|
857
|
-
continue;
|
|
858
|
-
for (const file of fs.readdirSync(dir)) {
|
|
859
|
-
if (!file.endsWith('.md'))
|
|
860
|
-
continue;
|
|
861
|
-
const raw = fs.readFileSync(path.join(dir, file), 'utf8');
|
|
862
|
-
const entry = deserializeEntry(raw);
|
|
863
|
-
if (entry)
|
|
864
|
-
entries.push(entry);
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
|
-
return entries;
|
|
868
|
-
}
|
|
869
|
-
function loadLegacyIndexFile(hippoRoot) {
|
|
870
|
-
const indexPath = path.join(hippoRoot, 'index.json');
|
|
871
|
-
if (!fs.existsSync(indexPath)) {
|
|
872
|
-
return { version: 1, entries: {}, last_retrieval_ids: [], last_trace_id: null };
|
|
873
|
-
}
|
|
874
|
-
try {
|
|
875
|
-
// SAFETY: index.json is only ever written by writeIndexMirror below,
|
|
876
|
-
// which always serializes a HippoIndex; a hand-edited or corrupted file
|
|
877
|
-
// that violates the shape falls through to the catch block's fallback.
|
|
878
|
-
return JSON.parse(fs.readFileSync(indexPath, 'utf8'));
|
|
879
|
-
}
|
|
880
|
-
catch (err) {
|
|
881
|
-
log.debug(`store: unreadable index.json read as empty: ${err instanceof Error ? err.message : String(err)}`);
|
|
882
|
-
return { version: 1, entries: {}, last_retrieval_ids: [], last_trace_id: null };
|
|
883
|
-
}
|
|
884
|
-
}
|
|
885
|
-
function loadLegacyStatsFile(hippoRoot) {
|
|
886
|
-
const statsPath = path.join(hippoRoot, 'stats.json');
|
|
887
|
-
if (!fs.existsSync(statsPath)) {
|
|
888
|
-
return {
|
|
889
|
-
total_remembered: 0,
|
|
890
|
-
total_recalled: 0,
|
|
891
|
-
total_forgotten: 0,
|
|
892
|
-
consolidation_runs: [],
|
|
893
|
-
};
|
|
894
|
-
}
|
|
895
|
-
try {
|
|
896
|
-
// SAFETY: stats.json is only ever written by writeStatsMirror below,
|
|
897
|
-
// which always emits exactly these four fields; callers additionally
|
|
898
|
-
// guard every read with `?? 0` / `Array.isArray`, tolerating a
|
|
899
|
-
// hand-edited or corrupted file even if this optimistic cast is wrong.
|
|
900
|
-
return JSON.parse(fs.readFileSync(statsPath, 'utf8'));
|
|
901
|
-
}
|
|
902
|
-
catch (err) {
|
|
903
|
-
log.debug(`store: unreadable stats.json read as zero: ${err instanceof Error ? err.message : String(err)}`);
|
|
904
|
-
return {
|
|
905
|
-
total_remembered: 0,
|
|
906
|
-
total_recalled: 0,
|
|
907
|
-
total_forgotten: 0,
|
|
908
|
-
consolidation_runs: [],
|
|
909
|
-
};
|
|
910
|
-
}
|
|
911
|
-
}
|
|
912
|
-
/**
|
|
913
|
-
* `bypassRejectionGuard` (AT1, plan §3): ONLY `batchWriteAndDelete`'s call
|
|
914
|
-
* site passes `true`. Consolidation merges are DETERMINISTIC CONCATENATION
|
|
915
|
-
* (mergeContents, consolidate.ts:736-751), not LLM paraphrase — the bypass
|
|
916
|
-
* is safe because the producer (consolidate.ts's merge pass) now checks the
|
|
917
|
-
* merged content's rejection digest against the tenant's tombstones BEFORE
|
|
918
|
-
* ever assembling a batch to write, and skips the merge entirely on a hit.
|
|
919
|
-
* Every other caller (writeEntryDbOnly, bootstrapLegacyStore, rebuildIndex)
|
|
920
|
-
* leaves this false and the guard runs live.
|
|
921
|
-
*
|
|
922
|
-
* AT1 P1 fix (codex, batch-transaction rejection race): the producer check
|
|
923
|
-
* above runs on a DIFFERENT connection BEFORE this transaction opens — a
|
|
924
|
-
* `hippo reject X` that commits in that window is invisible to it. This
|
|
925
|
-
* parameter's contract is UNCHANGED (still the sole bypass, still trusted
|
|
926
|
-
* by the producer-side check for the common case); what changed is that
|
|
927
|
-
* `batchWriteAndDelete` no longer trusts it BLINDLY. It now runs its own
|
|
928
|
-
* in-transaction point-probe (same connection, same digest lookup this
|
|
929
|
-
* function's guard would have done) immediately before each upsert and
|
|
930
|
-
* skips — rather than writes — any entry whose content matches a tombstone
|
|
931
|
-
* that landed after the producer's check. See batchWriteAndDelete for the
|
|
932
|
-
* skip logic.
|
|
933
|
-
*/
|
|
934
|
-
function upsertEntryRow(db, entry, bypassRejectionGuard = false) {
|
|
935
|
-
if (!bypassRejectionGuard) {
|
|
936
|
-
checkRejectionGuard(db, entry.tenantId ?? 'default', entry.id, entry.content);
|
|
937
|
-
}
|
|
938
|
-
const isNewRow = db.prepare(`SELECT 1 FROM memories WHERE id = ?`).get(entry.id) === undefined;
|
|
939
|
-
db.prepare(`
|
|
940
|
-
INSERT INTO memories(
|
|
941
|
-
id, created, last_retrieved, retrieval_count, strength, half_life_days, layer,
|
|
942
|
-
tags_json, emotional_valence, schema_fit, source, outcome_score,
|
|
943
|
-
outcome_positive, outcome_negative,
|
|
944
|
-
conflicts_with_json, pinned, confidence, content,
|
|
945
|
-
parents_json, starred,
|
|
946
|
-
trace_outcome, source_session_id,
|
|
947
|
-
valid_from, superseded_by,
|
|
948
|
-
extracted_from,
|
|
949
|
-
dag_level, dag_parent_id,
|
|
950
|
-
kind, scope, owner, artifact_ref,
|
|
951
|
-
tenant_id, origin_project,
|
|
952
|
-
descendant_count, earliest_at, latest_at,
|
|
953
|
-
dag_level_3_built_at,
|
|
954
|
-
updated_at
|
|
955
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
|
|
956
|
-
ON CONFLICT(id) DO UPDATE SET
|
|
957
|
-
created = excluded.created,
|
|
958
|
-
last_retrieved = excluded.last_retrieved,
|
|
959
|
-
retrieval_count = excluded.retrieval_count,
|
|
960
|
-
strength = excluded.strength,
|
|
961
|
-
half_life_days = excluded.half_life_days,
|
|
962
|
-
layer = excluded.layer,
|
|
963
|
-
tags_json = excluded.tags_json,
|
|
964
|
-
emotional_valence = excluded.emotional_valence,
|
|
965
|
-
schema_fit = excluded.schema_fit,
|
|
966
|
-
source = excluded.source,
|
|
967
|
-
outcome_score = excluded.outcome_score,
|
|
968
|
-
outcome_positive = excluded.outcome_positive,
|
|
969
|
-
outcome_negative = excluded.outcome_negative,
|
|
970
|
-
conflicts_with_json = excluded.conflicts_with_json,
|
|
971
|
-
pinned = excluded.pinned,
|
|
972
|
-
confidence = excluded.confidence,
|
|
973
|
-
content = excluded.content,
|
|
974
|
-
parents_json = excluded.parents_json,
|
|
975
|
-
starred = excluded.starred,
|
|
976
|
-
trace_outcome = excluded.trace_outcome,
|
|
977
|
-
source_session_id = excluded.source_session_id,
|
|
978
|
-
valid_from = excluded.valid_from,
|
|
979
|
-
superseded_by = excluded.superseded_by,
|
|
980
|
-
extracted_from = excluded.extracted_from,
|
|
981
|
-
dag_level = excluded.dag_level,
|
|
982
|
-
dag_parent_id = excluded.dag_parent_id,
|
|
983
|
-
kind = excluded.kind,
|
|
984
|
-
scope = excluded.scope,
|
|
985
|
-
owner = excluded.owner,
|
|
986
|
-
artifact_ref = excluded.artifact_ref,
|
|
987
|
-
tenant_id = excluded.tenant_id,
|
|
988
|
-
origin_project = excluded.origin_project,
|
|
989
|
-
descendant_count = excluded.descendant_count,
|
|
990
|
-
earliest_at = excluded.earliest_at,
|
|
991
|
-
latest_at = excluded.latest_at,
|
|
992
|
-
dag_level_3_built_at = excluded.dag_level_3_built_at,
|
|
993
|
-
updated_at = datetime('now')
|
|
994
|
-
`).run(entry.id, entry.created, entry.last_retrieved, entry.retrieval_count, entry.strength, entry.half_life_days, entry.layer, JSON.stringify(entry.tags ?? []), entry.emotional_valence, entry.schema_fit, entry.source, entry.outcome_score, entry.outcome_positive ?? 0, entry.outcome_negative ?? 0, JSON.stringify(entry.conflicts_with ?? []), entry.pinned ? 1 : 0, entry.confidence, entry.content, JSON.stringify(entry.parents ?? []), entry.starred ? 1 : 0, entry.trace_outcome ?? null, entry.source_session_id ?? null, entry.valid_from ?? entry.created, entry.superseded_by ?? null, entry.extracted_from ?? null, entry.dag_level ?? 0, entry.dag_parent_id ?? null, entry.kind ?? 'distilled', entry.scope ?? null, entry.owner ?? null, entry.artifact_ref ?? null, entry.tenantId ?? 'default', entry.origin_project ?? null, entry.descendant_count ?? 0, entry.earliest_at ?? null, entry.latest_at ?? null, entry.dag_level_3_built_at ?? null);
|
|
995
|
-
syncFtsRow(db, entry, isNewRow);
|
|
996
|
-
}
|
|
997
|
-
function syncFtsRow(db, entry, isNewRow = false) {
|
|
998
|
-
if (!isFtsAvailable(db))
|
|
999
|
-
return;
|
|
1000
|
-
try {
|
|
1001
|
-
if (!isNewRow)
|
|
1002
|
-
db.prepare(`DELETE FROM memories_fts WHERE id = ?`).run(entry.id);
|
|
1003
|
-
db.prepare(`INSERT INTO memories_fts(id, content, tags) VALUES (?, ?, ?)`).run(entry.id, entry.content, entry.tags.join(' '));
|
|
1004
|
-
}
|
|
1005
|
-
catch {
|
|
1006
|
-
// Best effort only. SQLite store is still authoritative even if FTS is unavailable.
|
|
1007
|
-
}
|
|
1008
|
-
}
|
|
1009
|
-
function deleteFtsRow(db, id) {
|
|
1010
|
-
if (!isFtsAvailable(db))
|
|
1011
|
-
return;
|
|
1012
|
-
try {
|
|
1013
|
-
db.prepare(`DELETE FROM memories_fts WHERE id = ?`).run(id);
|
|
1014
|
-
}
|
|
1015
|
-
catch {
|
|
1016
|
-
// Best effort.
|
|
1017
|
-
}
|
|
1018
|
-
}
|
|
1019
|
-
/** Derive the current `HippoIndex` from SQLite. Exported for `rebuildIndex`
|
|
1020
|
-
* (the only index.json writer) and the longmemeval benchmark. */
|
|
1021
|
-
export function buildIndexFromDb(db) {
|
|
1022
|
-
// SAFETY: rows' shape matches the seven columns named in the SELECT below.
|
|
1023
|
-
const rows = db.prepare(`SELECT id, created, last_retrieved, strength, layer, tags_json, pinned FROM memories ORDER BY created ASC, id ASC`).all();
|
|
1024
|
-
const entries = {};
|
|
1025
|
-
for (const row of rows) {
|
|
1026
|
-
// SAFETY: layer is only ever written from the Layer enum by this
|
|
1027
|
-
// module's own INSERT/UPDATE paths.
|
|
1028
|
-
const layer = row.layer;
|
|
1029
|
-
entries[row.id] = {
|
|
1030
|
-
id: row.id,
|
|
1031
|
-
file: path.join(layer, `${row.id}.md`),
|
|
1032
|
-
layer,
|
|
1033
|
-
strength: Number(row.strength ?? 0),
|
|
1034
|
-
tags: parseJsonArray(row.tags_json),
|
|
1035
|
-
created: row.created,
|
|
1036
|
-
last_retrieved: row.last_retrieved,
|
|
1037
|
-
pinned: Boolean(row.pinned),
|
|
1038
|
-
};
|
|
1039
|
-
}
|
|
1040
|
-
// LC1 codex round-2 med: the two lockstep keys must be read in ONE
|
|
1041
|
-
// statement. Two autocommit SELECTs leave a window where a concurrent
|
|
1042
|
-
// saveIndex (which commits both keys in one transaction) lands between
|
|
1043
|
-
// them, handing the reader mismatched last_retrieval_ids / last_trace_id
|
|
1044
|
-
// and re-opening the mislinkage hole saveIndex's BEGIN/COMMIT closed on
|
|
1045
|
-
// the write side. One SELECT = one SQLite read snapshot.
|
|
1046
|
-
// SAFETY: lockstepRows' shape matches the key/value columns named above.
|
|
1047
|
-
const lockstepRows = db.prepare(`SELECT key, value FROM meta WHERE key IN ('last_retrieval_ids', 'last_trace_id')`).all();
|
|
1048
|
-
const lockstep = new Map(lockstepRows.map((r) => [r.key, r.value]));
|
|
1049
|
-
return {
|
|
1050
|
-
version: INDEX_VERSION,
|
|
1051
|
-
entries,
|
|
1052
|
-
last_retrieval_ids: parseJsonArray(lockstep.get('last_retrieval_ids') ?? '[]'),
|
|
1053
|
-
last_trace_id: parseLastTraceId(lockstep.get('last_trace_id') ?? ''),
|
|
1054
|
-
};
|
|
1055
|
-
}
|
|
1056
|
-
function buildStatsFromDb(db) {
|
|
1057
|
-
// SAFETY: runs' shape matches the four columns named in the SELECT above.
|
|
1058
|
-
const runs = db.prepare(`SELECT timestamp, decayed, merged, removed FROM consolidation_runs ORDER BY timestamp ASC, id ASC`).all();
|
|
1059
|
-
return {
|
|
1060
|
-
total_remembered: Number(getMeta(db, 'total_remembered', '0')),
|
|
1061
|
-
total_recalled: Number(getMeta(db, 'total_recalled', '0')),
|
|
1062
|
-
total_forgotten: Number(getMeta(db, 'total_forgotten', '0')),
|
|
1063
|
-
consolidation_runs: runs.map((run) => ({
|
|
1064
|
-
timestamp: run.timestamp,
|
|
1065
|
-
decayed: run.decayed,
|
|
1066
|
-
merged: run.merged,
|
|
1067
|
-
removed: run.removed,
|
|
1068
|
-
})),
|
|
1069
|
-
};
|
|
1070
|
-
}
|
|
1071
|
-
/** Write the `index.json` mirror file for an already-derived index. Exported for
|
|
1072
|
-
* `rebuildIndex` (the only index.json writer) and the longmemeval benchmark. */
|
|
1073
|
-
export function writeIndexMirror(hippoRoot, index) {
|
|
1074
|
-
mirrorBestEffort('index.json', () => fs.writeFileSync(path.join(hippoRoot, 'index.json'), JSON.stringify(index, null, 2), 'utf8'));
|
|
1075
|
-
}
|
|
1076
|
-
function writeStatsMirror(hippoRoot, stats) {
|
|
1077
|
-
mirrorBestEffort('stats.json', () => fs.writeFileSync(path.join(hippoRoot, 'stats.json'), JSON.stringify(stats, null, 2), 'utf8'));
|
|
1078
|
-
}
|
|
1079
|
-
/** Mirrors are derived from SQLite and written after COMMIT, so a failed write warns instead of failing a committed change. */
|
|
1080
|
-
function mirrorBestEffort(what, write) {
|
|
1081
|
-
try {
|
|
1082
|
-
write();
|
|
1083
|
-
}
|
|
1084
|
-
catch (err) {
|
|
1085
|
-
log.warn(`${what} not refreshed (${err instanceof Error ? err.message : String(err)}); the database write succeeded`);
|
|
1086
|
-
}
|
|
1087
|
-
}
|
|
1088
|
-
function syncMirrorFiles(hippoRoot, db) {
|
|
1089
|
-
// SAFETY: this query selects exactly MEMORY_SELECT_COLUMNS, matching
|
|
1090
|
-
// MemoryRow's field set.
|
|
1091
|
-
const entries = db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories ORDER BY created ASC, id ASC`).all();
|
|
1092
|
-
mirrorBestEffort('markdown mirrors', () => {
|
|
1093
|
-
for (const entry of entries.map(rowToEntry))
|
|
1094
|
-
writeMarkdownMirror(hippoRoot, entry);
|
|
1095
|
-
});
|
|
1096
|
-
// SAFETY: conflicts' shape matches the eight columns named in the SELECT
|
|
1097
|
-
// above.
|
|
1098
|
-
const conflicts = db.prepare(`
|
|
1099
|
-
SELECT id, memory_a_id, memory_b_id, reason, score, status, detected_at, updated_at
|
|
1100
|
-
FROM memory_conflicts
|
|
1101
|
-
WHERE status = 'open'
|
|
1102
|
-
ORDER BY updated_at DESC, id DESC
|
|
1103
|
-
`).all();
|
|
1104
|
-
mirrorBestEffort('conflict mirrors', () => writeConflictMirrors(hippoRoot, conflicts.map(rowToMemoryConflict)));
|
|
1105
|
-
writeStatsMirror(hippoRoot, buildStatsFromDb(db));
|
|
1106
|
-
}
|
|
1107
|
-
/** Load the derived index from SQLite. Read-only: index.json is only ever written by `rebuildIndex`. */
|
|
1108
|
-
export function loadIndex(hippoRoot) {
|
|
1109
|
-
const db = openStore(hippoRoot);
|
|
1110
|
-
try {
|
|
1111
|
-
return buildIndexFromDb(db);
|
|
1112
|
-
}
|
|
1113
|
-
finally {
|
|
1114
|
-
closeHippoDb(db);
|
|
1115
|
-
}
|
|
1116
|
-
}
|
|
1117
|
-
/**
|
|
1118
|
-
* Persist mutable index metadata. Entry rows themselves are derived from SQLite.
|
|
1119
|
-
*
|
|
1120
|
-
* LC1 F1(c) structural fix: `last_retrieval_ids` and `last_trace_id` must
|
|
1121
|
-
* land atomically — callers (getContext, cmdRecall) fold a freshly-written
|
|
1122
|
-
* trace id into `index.last_trace_id` before calling this, relying on BOTH
|
|
1123
|
-
* meta keys committing together. Wrapped in BEGIN/COMMIT so a crash or a
|
|
1124
|
-
* mid-write failure can never advance one key without the other. index.json
|
|
1125
|
-
* is left untouched; only `rebuildIndex` writes it.
|
|
1126
|
-
*/
|
|
1127
|
-
export function saveIndex(hippoRoot, index) {
|
|
1128
|
-
const db = openStore(hippoRoot);
|
|
1129
|
-
try {
|
|
1130
|
-
db.exec('BEGIN');
|
|
1131
|
-
try {
|
|
1132
|
-
setMeta(db, 'last_retrieval_ids', JSON.stringify(index.last_retrieval_ids ?? []));
|
|
1133
|
-
setMeta(db, 'last_trace_id', index.last_trace_id ?? '');
|
|
1134
|
-
db.exec('COMMIT');
|
|
1135
|
-
}
|
|
1136
|
-
catch (error) {
|
|
1137
|
-
try {
|
|
1138
|
-
db.exec('ROLLBACK');
|
|
1139
|
-
}
|
|
1140
|
-
catch { /* already rolled back; keep the original error */ }
|
|
1141
|
-
throw error;
|
|
1142
|
-
}
|
|
1143
|
-
}
|
|
1144
|
-
finally {
|
|
1145
|
-
closeHippoDb(db);
|
|
1146
|
-
}
|
|
1147
|
-
}
|
|
1148
|
-
/**
|
|
1149
|
-
* Write a memory entry to SQLite and refresh compatibility mirrors.
|
|
1150
|
-
*
|
|
1151
|
-
* `opts.actor` defaults to 'cli' so unauthenticated direct-CLI callers still
|
|
1152
|
-
* get the right audit attribution. The HTTP server (A1) and api.* layer pass
|
|
1153
|
-
* the resolved actor (`api_key:<key_id>` / `localhost:cli`) so audit events
|
|
1154
|
-
* land with one row per write, no double-emit.
|
|
1155
|
-
*
|
|
1156
|
-
* `opts.afterWrite` is invoked inside the same SAVEPOINT as the memories
|
|
1157
|
-
* INSERT (mirrors archiveRawMemory's shape in raw-archive.ts). On callback
|
|
1158
|
-
* throw, the SAVEPOINT rolls back — the memory row never lands, and the
|
|
1159
|
-
* filesystem mirrors / audit emit never run. Used by E1.3+ connectors to
|
|
1160
|
-
* stamp idempotency rows atomically with the memory write.
|
|
1161
|
-
*/
|
|
1162
|
-
/**
|
|
1163
|
-
* Stamp origin_project from the store's own location when the entry has
|
|
1164
|
-
* never been stamped (v39 memory scope isolation). The store dir is
|
|
1165
|
-
* `<project>/.hippo`, so its parent resolves to the owning project; the
|
|
1166
|
-
* home/global store resolves to '' (user-global). Callers that know a better
|
|
1167
|
-
* origin (shareMemory, syncGlobalToLocal) set entry.origin_project before
|
|
1168
|
-
* writing and this is a no-op. Returns a stamped copy; never mutates.
|
|
1169
|
-
*
|
|
1170
|
-
* NULL is deliberately PRESERVED, not re-stamped: null means "legacy row the
|
|
1171
|
-
* v39 migration found no evidence for" and is deny-by-default in ambient
|
|
1172
|
-
* context. A writeback (e.g. markRetrieved on a crossProject-included row)
|
|
1173
|
-
* must not launder it into an injectable origin - the migration is the only
|
|
1174
|
-
* evidence-based NULL converter (codex gating round 2 P1).
|
|
1175
|
-
*/
|
|
1176
|
-
export function stampOriginProject(hippoRoot, entry) {
|
|
1177
|
-
if (entry.origin_project !== undefined)
|
|
1178
|
-
return entry;
|
|
1179
|
-
return { ...entry, origin_project: deriveOriginProject(path.dirname(hippoRoot)) };
|
|
1180
|
-
}
|
|
1181
|
-
/**
|
|
1182
|
-
* Import-time variant for a mirror with no origin field (an explicit null stays null): used only where evidence exists
|
|
1183
|
-
* for rows that predate the origin column - the legacy-markdown bootstrap and
|
|
1184
|
-
* rebuildIndex import, which are the markdown-store equivalent of the v39 SQL
|
|
1185
|
-
* backfill. Same evidence order as the migration: the provenance source
|
|
1186
|
-
* (`shared:<project>:` / `promoted:<localRoot>`) wins over the destination
|
|
1187
|
-
* store's location, so a shared row imported into the global store keeps its
|
|
1188
|
-
* owning project instead of becoming user-global (codex gating round 3 P1).
|
|
1189
|
-
*/
|
|
1190
|
-
function stampOriginProjectForImport(hippoRoot, entry) {
|
|
1191
|
-
if (entry.origin_project !== undefined)
|
|
1192
|
-
return entry;
|
|
1193
|
-
const fromSource = originFromSource(entry.source);
|
|
1194
|
-
return {
|
|
1195
|
-
...entry,
|
|
1196
|
-
origin_project: fromSource ?? deriveOriginProject(path.dirname(hippoRoot)),
|
|
1197
|
-
};
|
|
1198
|
-
}
|
|
1199
|
-
export function writeEntry(hippoRoot, entry, opts) {
|
|
1200
|
-
const db = openStore(hippoRoot);
|
|
1201
|
-
try {
|
|
1202
|
-
const stamped = stampOriginProject(hippoRoot, entry);
|
|
1203
|
-
writeEntryDbOnly(db, stamped, opts);
|
|
1204
|
-
opts?.afterCommit?.();
|
|
1205
|
-
writeEntryMirrors(hippoRoot, stamped);
|
|
1206
|
-
}
|
|
1207
|
-
catch (error) {
|
|
1208
|
-
// AT1 (plan §3): writeEntryDbOnly's own SAVEPOINT has already unwound by
|
|
1209
|
-
// the time this catch runs, so the refusal audit lands post-rollback in
|
|
1210
|
-
// a fresh implicit transaction — then rethrow so the caller sees the
|
|
1211
|
-
// refusal.
|
|
1212
|
-
if (error instanceof RejectedValueError) {
|
|
1213
|
-
auditRejectionRefusal(db, error, opts?.actor ?? 'cli');
|
|
1214
|
-
}
|
|
1215
|
-
throw error;
|
|
1216
|
-
}
|
|
1217
|
-
finally {
|
|
1218
|
-
closeHippoDb(db);
|
|
1219
|
-
}
|
|
1220
|
-
}
|
|
1221
|
-
/**
|
|
1222
|
-
* DB-only write path. Caller owns the open `db` handle. Runs SAVEPOINT +
|
|
1223
|
-
* upsert + afterWrite hook + audit row inside the SAVEPOINT scope. Caller
|
|
1224
|
-
* is responsible for opening `db`, optionally wrapping in a larger BEGIN/
|
|
1225
|
-
* COMMIT (e.g. supersede's BEGIN IMMEDIATE), closing `db`, AND calling
|
|
1226
|
-
* `writeEntryMirrors` after the larger tx commits — mirrors must run
|
|
1227
|
-
* post-commit so a rolled-back tx never leaves orphan markdown.
|
|
1228
|
-
*
|
|
1229
|
-
* Audit-order note: the audit row is emitted INSIDE the SAVEPOINT, so audit
|
|
1230
|
-
* commits atomically with the row INSERT. A subsequent mirror failure cannot
|
|
1231
|
-
* leave a recorded audit entry without its corresponding DB row. This is a
|
|
1232
|
-
* documented hardening over the prior writeEntry-as-monolith ordering.
|
|
1233
|
-
*/
|
|
1234
|
-
export function writeEntryDbOnly(db, entry, opts) {
|
|
1235
|
-
// SAVEPOINT (not BEGIN) so this nests safely inside any outer transaction
|
|
1236
|
-
// a caller might hold (e.g. supersede's BEGIN IMMEDIATE). SQLite refuses
|
|
1237
|
-
// BEGIN within a transaction; SAVEPOINT is the only way to scope rollback
|
|
1238
|
-
// without disturbing outers.
|
|
1239
|
-
db.exec('SAVEPOINT write_entry');
|
|
1240
|
-
try {
|
|
1241
|
-
upsertEntryRow(db, entry);
|
|
1242
|
-
if (opts?.afterWrite) {
|
|
1243
|
-
opts.afterWrite(db, entry.id);
|
|
1244
|
-
}
|
|
1245
|
-
audit(db, 'remember', entry.id, {
|
|
1246
|
-
kind: entry.kind ?? 'distilled',
|
|
1247
|
-
scope: entry.scope ?? null,
|
|
1248
|
-
}, opts?.actor ?? 'cli', entry.tenantId);
|
|
1249
|
-
// v0.30 / E2 — DAG live-coupling: child write under a level-2 summary
|
|
1250
|
-
// marks the parent dirty for E3 sleep-cycle rebuild. Early-exit on
|
|
1251
|
-
// null dag_parent_id (vast majority of writes); cost is one null check
|
|
1252
|
-
// on the hot path.
|
|
1253
|
-
if (entry.dag_parent_id) {
|
|
1254
|
-
markSummaryDirtyInTx(db, entry.dag_parent_id, entry.tenantId, opts?.actor ?? 'cli');
|
|
1255
|
-
}
|
|
1256
|
-
db.exec('RELEASE SAVEPOINT write_entry');
|
|
1257
|
-
}
|
|
1258
|
-
catch (e) {
|
|
1259
|
-
try {
|
|
1260
|
-
db.exec('ROLLBACK TO SAVEPOINT write_entry');
|
|
1261
|
-
db.exec('RELEASE SAVEPOINT write_entry');
|
|
1262
|
-
}
|
|
1263
|
-
catch {
|
|
1264
|
-
// Ignore rollback failures — the throw below is what matters.
|
|
1265
|
-
}
|
|
1266
|
-
throw e;
|
|
1267
|
-
}
|
|
1268
|
-
}
|
|
1269
|
-
/** Markdown mirror path, invoked AFTER commit (a rolled-back tx must leave no orphan markdown). */
|
|
1270
|
-
export function writeEntryMirrors(hippoRoot, entry) {
|
|
1271
|
-
mirrorBestEffort(`${entry.id}.md`, () => writeMarkdownMirror(hippoRoot, entry));
|
|
1272
|
-
}
|
|
1273
|
-
/**
|
|
1274
|
-
* Read a memory entry by ID.
|
|
1275
|
-
*
|
|
1276
|
-
* When `tenantId` is provided, the read is scoped to that tenant (cross-tenant
|
|
1277
|
-
* lookups return null). When omitted, no tenant filter is applied — preserves
|
|
1278
|
-
* legacy single-tenant callers and the writeEntry/readEntry round-trip.
|
|
1279
|
-
*/
|
|
1280
|
-
export function readEntry(hippoRoot, id, tenantId) {
|
|
1281
|
-
const db = openStore(hippoRoot);
|
|
1282
|
-
try {
|
|
1283
|
-
// SAFETY: both branches select exactly MEMORY_SELECT_COLUMNS, matching
|
|
1284
|
-
// MemoryRow's field set.
|
|
1285
|
-
const row = tenantId !== undefined
|
|
1286
|
-
? db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories WHERE id = ? AND tenant_id = ?`).get(id, tenantId)
|
|
1287
|
-
: db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories WHERE id = ?`).get(id);
|
|
1288
|
-
return row ? rowToEntry(row) : null;
|
|
1289
|
-
}
|
|
1290
|
-
finally {
|
|
1291
|
-
closeHippoDb(db);
|
|
1292
|
-
}
|
|
1293
|
-
}
|
|
1294
|
-
/**
|
|
1295
|
-
* Batched lookup. Caps at 500 ids per call to keep the IN(?,?,...) clause
|
|
1296
|
-
* within SQLite limits. Tenant filter is enforced when `tenantId` is passed.
|
|
1297
|
-
* Used by DAG-aware recall (docs/plans/2026-05-05-dag-recall.md Task 1.5)
|
|
1298
|
-
* to fetch parent summaries for a set of overflowed leaves.
|
|
1299
|
-
*/
|
|
1300
|
-
export function loadEntriesByIds(hippoRoot, ids, tenantId) {
|
|
1301
|
-
if (ids.length === 0)
|
|
1302
|
-
return [];
|
|
1303
|
-
const capped = ids.slice(0, 500);
|
|
1304
|
-
const db = openStore(hippoRoot);
|
|
1305
|
-
try {
|
|
1306
|
-
const placeholders = capped.map(() => '?').join(',');
|
|
1307
|
-
// T2: no ORDER BY meant row order followed SQLite's IN(...) scan order
|
|
1308
|
-
// (undefined w.r.t. the caller's `ids` order). created ASC, id ASC
|
|
1309
|
-
// makes it deterministic.
|
|
1310
|
-
// SAFETY: both branches select exactly MEMORY_SELECT_COLUMNS, matching
|
|
1311
|
-
// MemoryRow's field set.
|
|
1312
|
-
const rows = tenantId !== undefined
|
|
1313
|
-
? db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories WHERE id IN (${placeholders}) AND tenant_id = ? ORDER BY created ASC, content ASC, id ASC`).all(...capped, tenantId)
|
|
1314
|
-
: db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories WHERE id IN (${placeholders}) ORDER BY created ASC, content ASC, id ASC`).all(...capped);
|
|
1315
|
-
return rows.map(rowToEntry);
|
|
1316
|
-
}
|
|
1317
|
-
finally {
|
|
1318
|
-
closeHippoDb(db);
|
|
1319
|
-
}
|
|
1320
|
-
}
|
|
1321
|
-
/** Strengthen what a read returned: update only the four retrieval columns on the live row, never a stale copy.
|
|
1322
|
-
* Best effort: a failure logs and never fails the read. Returns the ids found in this store. */
|
|
1323
|
-
export function strengthenRetrieved(hippoRoot, ids, tenantId) {
|
|
1324
|
-
const found = new Set();
|
|
1325
|
-
if (ids.length === 0 || isRecallBoostAblated())
|
|
1326
|
-
return found;
|
|
1327
|
-
let db;
|
|
1328
|
-
try {
|
|
1329
|
-
db = openHippoDb(hippoRoot);
|
|
1330
|
-
db.exec('BEGIN IMMEDIATE');
|
|
1331
|
-
for (const id of strengthenRetrievedOn(db, ids, tenantId))
|
|
1332
|
-
found.add(id);
|
|
1333
|
-
db.exec('COMMIT');
|
|
1334
|
-
}
|
|
1335
|
-
catch (error) {
|
|
1336
|
-
try {
|
|
1337
|
-
db?.exec('ROLLBACK');
|
|
1338
|
-
}
|
|
1339
|
-
catch { /* already rolled back; keep the original error */ }
|
|
1340
|
-
log.warn(`retrieval stats not saved (${error instanceof Error ? error.message : String(error)})`);
|
|
1341
|
-
found.clear();
|
|
1342
|
-
}
|
|
1343
|
-
finally {
|
|
1344
|
-
if (db)
|
|
1345
|
-
closeHippoDb(db);
|
|
1346
|
-
}
|
|
1347
|
-
return found;
|
|
1348
|
-
}
|
|
1349
|
-
/** strengthenRetrieved on the caller's handle, inside the caller's transaction. Throws; the caller decides. */
|
|
1350
|
-
export function strengthenRetrievedOn(db, ids, tenantId) {
|
|
1351
|
-
const found = new Set();
|
|
1352
|
-
if (ids.length === 0 || isRecallBoostAblated())
|
|
1353
|
-
return found;
|
|
1354
|
-
const tenantClause = tenantId !== undefined ? ' AND tenant_id = ?' : '';
|
|
1355
|
-
const select = db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories WHERE id = ?${tenantClause}`);
|
|
1356
|
-
const live = [];
|
|
1357
|
-
for (const id of ids) {
|
|
1358
|
-
// SAFETY: the SELECT names exactly MEMORY_SELECT_COLUMNS, matching MemoryRow's field set.
|
|
1359
|
-
const row = (tenantId !== undefined ? select.get(id, tenantId) : select.get(id));
|
|
1360
|
-
if (row)
|
|
1361
|
-
live.push(rowToEntry(row));
|
|
1362
|
-
}
|
|
1363
|
-
const update = db.prepare('UPDATE memories SET retrieval_count = ?, last_retrieved = ?, half_life_days = ?, strength = ? WHERE id = ?');
|
|
1364
|
-
for (const e of markRetrieved(live)) {
|
|
1365
|
-
update.run(e.retrieval_count, e.last_retrieved, e.half_life_days, e.strength, e.id);
|
|
1366
|
-
found.add(e.id);
|
|
1367
|
-
}
|
|
1368
|
-
return found;
|
|
1369
|
-
}
|
|
1370
|
-
/**
|
|
1371
|
-
* All `kind='raw'` rows for a given session, tenant-scoped, returned
|
|
1372
|
-
* oldest-first. Used by `api.assemble` to walk a session's chronological
|
|
1373
|
-
* context. Excludes superseded rows.
|
|
1374
|
-
*
|
|
1375
|
-
* Cap semantics (v1.6.2 codex fix): when `cap` is provided, the NEWEST
|
|
1376
|
-
* `cap` rows are loaded — `ORDER BY created DESC LIMIT cap` server-side,
|
|
1377
|
-
* reversed to oldest-first client-side. Pre-v1.6.2 ordered ASC + LIMIT,
|
|
1378
|
-
* which silently dropped the newest rows and broke fresh-tail in assemble.
|
|
1379
|
-
*
|
|
1380
|
-
* Returns `[]` for an empty sessionId. Final order: `created ASC, id ASC`.
|
|
1381
|
-
*/
|
|
1382
|
-
export function loadSessionRawMemories(hippoRoot, sessionId, tenantId, cap) {
|
|
1383
|
-
if (!sessionId)
|
|
1384
|
-
return [];
|
|
1385
|
-
const db = openStore(hippoRoot);
|
|
1386
|
-
try {
|
|
1387
|
-
const params = [];
|
|
1388
|
-
let sql = `SELECT ${MEMORY_SELECT_COLUMNS} FROM memories WHERE kind = 'raw' AND source_session_id = ? AND superseded_by IS NULL`;
|
|
1389
|
-
params.push(sessionId);
|
|
1390
|
-
if (tenantId !== undefined) {
|
|
1391
|
-
sql += ' AND tenant_id = ?';
|
|
1392
|
-
params.push(tenantId);
|
|
1393
|
-
}
|
|
1394
|
-
if (cap !== undefined && cap > 0) {
|
|
1395
|
-
sql += ' ORDER BY created DESC, id DESC LIMIT ?';
|
|
1396
|
-
params.push(cap);
|
|
1397
|
-
// SAFETY: sql starts from MEMORY_SELECT_COLUMNS, matching MemoryRow.
|
|
1398
|
-
const rows = db.prepare(sql).all(...params);
|
|
1399
|
-
return rows.reverse().map(rowToEntry);
|
|
1400
|
-
}
|
|
1401
|
-
sql += ' ORDER BY created ASC, id ASC';
|
|
1402
|
-
// SAFETY: sql starts from MEMORY_SELECT_COLUMNS, matching MemoryRow.
|
|
1403
|
-
const rows = db.prepare(sql).all(...params);
|
|
1404
|
-
return rows.map(rowToEntry);
|
|
1405
|
-
}
|
|
1406
|
-
finally {
|
|
1407
|
-
closeHippoDb(db);
|
|
1408
|
-
}
|
|
1409
|
-
}
|
|
1410
|
-
/**
|
|
1411
|
-
* Pre-cap, scope-aware row count for a session. Lets `assemble` report
|
|
1412
|
-
* the full session size even when `rowCap` truncates the loaded window,
|
|
1413
|
-
* WITHOUT leaking rows the caller wouldn't have been allowed to load.
|
|
1414
|
-
*
|
|
1415
|
-
* v1.6.3 codex P1 / senior P0: an earlier draft of this helper ran an
|
|
1416
|
-
* unscoped COUNT, which let a no-scope caller infer the existence of
|
|
1417
|
-
* private rows by comparing `totalRaw` against `items.length`. This
|
|
1418
|
-
* version SQL-encodes the same default-deny rule `passesScopeFilterForRecall`
|
|
1419
|
-
* applies in TS:
|
|
1420
|
-
* - explicit scope passed: exact-match
|
|
1421
|
-
* - no scope: rows where scope IS NULL, or scope is NOT a `<source>:private:*`
|
|
1422
|
-
* pattern AND not the `unknown:legacy` quarantine bucket.
|
|
1423
|
-
*
|
|
1424
|
-
* `tenantId` is optional for back-compat. Pass `undefined` only when
|
|
1425
|
-
* intentionally counting cross-tenant; `assemble()` passes `ctx.tenantId`.
|
|
1426
|
-
*/
|
|
1427
|
-
export function countSessionRawMemories(hippoRoot, sessionId, tenantId, scope) {
|
|
1428
|
-
if (!sessionId)
|
|
1429
|
-
return 0;
|
|
1430
|
-
const db = openStore(hippoRoot);
|
|
1431
|
-
try {
|
|
1432
|
-
const params = [];
|
|
1433
|
-
let sql = `SELECT COUNT(*) AS c FROM memories WHERE kind = 'raw' AND source_session_id = ? AND superseded_by IS NULL`;
|
|
1434
|
-
params.push(sessionId);
|
|
1435
|
-
if (tenantId !== undefined) {
|
|
1436
|
-
sql += ' AND tenant_id = ?';
|
|
1437
|
-
params.push(tenantId);
|
|
1438
|
-
}
|
|
1439
|
-
if (scope !== undefined && scope !== '') {
|
|
1440
|
-
sql += ' AND scope = ?';
|
|
1441
|
-
params.push(scope);
|
|
1442
|
-
}
|
|
1443
|
-
else {
|
|
1444
|
-
// SQL-ify the TS default-deny: scope IS NULL OR (NOT LIKE '%:private:%'
|
|
1445
|
-
// AND != 'unknown:legacy'). Mirrors api.passesScopeFilterForRecall.
|
|
1446
|
-
sql += ` AND (scope IS NULL OR (scope NOT LIKE '%:private:%' AND scope != 'unknown:legacy'))`;
|
|
1447
|
-
}
|
|
1448
|
-
// SAFETY: row's shape matches the single `COUNT(*) AS c` column above.
|
|
1449
|
-
const row = db.prepare(sql).get(...params);
|
|
1450
|
-
return Number(row?.c ?? 0);
|
|
1451
|
-
}
|
|
1452
|
-
finally {
|
|
1453
|
-
closeHippoDb(db);
|
|
1454
|
-
}
|
|
1455
|
-
}
|
|
1456
|
-
/**
|
|
1457
|
-
* Last N kind='raw' memories by `created` desc. Tenant scoped. When
|
|
1458
|
-
* `sessionId` is supplied, also constrains to a specific session — that
|
|
1459
|
-
* is the correct shape for "what did I just see in THIS session."
|
|
1460
|
-
*
|
|
1461
|
-
* v1.6.2 codex review fix: pre-v1.6.2 was tenant-wide only. With multiple
|
|
1462
|
-
* concurrent sessions in a tenant, fresh-tail recall surfaced unrelated
|
|
1463
|
-
* rows from other sessions and stamped them `isFreshTail=true`. Callers
|
|
1464
|
-
* that want session-scoped fresh-tail now pass `sessionId`. The
|
|
1465
|
-
* tenant-wide form (no sessionId) still exists for "anything new across
|
|
1466
|
-
* the whole tenant" — pass undefined to opt in.
|
|
1467
|
-
*
|
|
1468
|
-
* Bounded count cap at 200 — beyond that the caller should filter via
|
|
1469
|
-
* tags/scope rather than time-windowed recall.
|
|
1470
|
-
*
|
|
1471
|
-
* Deprecation note (v1.6.5) — the **tenant-wide call shape** (omitting
|
|
1472
|
-
* `sessionId`) is rarely the right shape for "what did I just see in this
|
|
1473
|
-
* conversation". `api.recall` enforces session scoping when
|
|
1474
|
-
* `HIPPO_REQUIRE_SESSION_SCOPED_FRESH_TAIL=1` is set, throwing
|
|
1475
|
-
* `RecallContractError` instead. Tenant-wide remains the back-compat default
|
|
1476
|
-
* but is discouraged for new callers. Passing `sessionId` is fully supported
|
|
1477
|
-
* and recommended; this function is NOT deprecated as a whole.
|
|
1478
|
-
*/
|
|
1479
|
-
export function loadFreshRawMemories(hippoRoot, count, tenantId, sessionId) {
|
|
1480
|
-
if (count <= 0)
|
|
1481
|
-
return [];
|
|
1482
|
-
const capped = Math.min(count, 200);
|
|
1483
|
-
const db = openStore(hippoRoot);
|
|
1484
|
-
try {
|
|
1485
|
-
const params = [];
|
|
1486
|
-
let sql = `SELECT ${MEMORY_SELECT_COLUMNS} FROM memories WHERE kind = 'raw' AND superseded_by IS NULL`;
|
|
1487
|
-
if (tenantId !== undefined) {
|
|
1488
|
-
sql += ' AND tenant_id = ?';
|
|
1489
|
-
params.push(tenantId);
|
|
1490
|
-
}
|
|
1491
|
-
if (sessionId !== undefined && sessionId !== '') {
|
|
1492
|
-
sql += ' AND source_session_id = ?';
|
|
1493
|
-
params.push(sessionId);
|
|
1494
|
-
}
|
|
1495
|
-
// T2: tie tail keeps the LIMIT window keyed on `created` while making
|
|
1496
|
-
// same-`created` rows deterministic. `content` before `id` (codex
|
|
1497
|
-
// review): ids are random UUIDs, so an id-only tail would pick WHICH
|
|
1498
|
-
// same-created rows make the window per-instance; content is
|
|
1499
|
-
// cross-ingest-stable.
|
|
1500
|
-
sql += ' ORDER BY created DESC, content ASC, id ASC LIMIT ?';
|
|
1501
|
-
params.push(capped);
|
|
1502
|
-
// SAFETY: sql starts from MEMORY_SELECT_COLUMNS, matching MemoryRow.
|
|
1503
|
-
const rows = db.prepare(sql).all(...params);
|
|
1504
|
-
return rows.map(rowToEntry);
|
|
1505
|
-
}
|
|
1506
|
-
finally {
|
|
1507
|
-
closeHippoDb(db);
|
|
1508
|
-
}
|
|
1509
|
-
}
|
|
1510
|
-
/**
|
|
1511
|
-
* Direct DAG children of a parent summary. Tenant scoped. Returns only rows
|
|
1512
|
-
* whose `dag_parent_id` matches `parentId`; does NOT walk recursively.
|
|
1513
|
-
* Used by `drillDown` (Task 3).
|
|
1514
|
-
*/
|
|
1515
|
-
export function loadChildrenOf(hippoRoot, parentId, tenantId) {
|
|
1516
|
-
const db = openStore(hippoRoot);
|
|
1517
|
-
try {
|
|
1518
|
-
// SAFETY: both branches select exactly MEMORY_SELECT_COLUMNS, matching
|
|
1519
|
-
// MemoryRow's field set.
|
|
1520
|
-
const rows = tenantId !== undefined
|
|
1521
|
-
? db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories WHERE dag_parent_id = ? AND tenant_id = ? ORDER BY created ASC, id ASC`).all(parentId, tenantId)
|
|
1522
|
-
: db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories WHERE dag_parent_id = ? ORDER BY created ASC, id ASC`).all(parentId);
|
|
1523
|
-
return rows.map(rowToEntry);
|
|
1524
|
-
}
|
|
1525
|
-
finally {
|
|
1526
|
-
closeHippoDb(db);
|
|
1527
|
-
}
|
|
1528
|
-
}
|
|
1529
|
-
/** Tables whose rows keep a first-class object's backing memory in `memory_id` (ON DELETE SET NULL); tests/dormant-memories.test.ts pins it to the schema. */
|
|
1530
|
-
export const MEMORY_BACKED_TABLES = ['predictions', 'decisions', 'incidents', 'processes', 'policies', 'skills', 'project_briefs', 'customer_notes'];
|
|
1531
|
-
/** Deleting a memory that backs an object nulls the object's link, and no restore can repair it, so no automatic pass may. */
|
|
1532
|
-
const AUTOMATIC_DELETE_SQL = `${AUTO_DELETABLE_SQL}${MEMORY_BACKED_TABLES.map((t) => ` AND NOT EXISTS (SELECT 1 FROM ${t} WHERE ${t}.memory_id = memories.id)`).join('')}`;
|
|
1533
|
-
/** Ids of memories that back a first-class object, for passes that plan deletes before making them. A table missing from an older schema is skipped. */
|
|
1534
|
-
export function memoriesBackingObjects(hippoRoot) {
|
|
1535
|
-
const ids = new Set();
|
|
1536
|
-
const db = openHippoDb(hippoRoot);
|
|
1537
|
-
try {
|
|
1538
|
-
for (const table of MEMORY_BACKED_TABLES) {
|
|
1539
|
-
try {
|
|
1540
|
-
// SAFETY: SELECT of one nullable TEXT column, filtered to non-null.
|
|
1541
|
-
const rows = db.prepare(`SELECT memory_id FROM ${table} WHERE memory_id IS NOT NULL`).all();
|
|
1542
|
-
for (const r of rows)
|
|
1543
|
-
ids.add(r.memory_id);
|
|
1544
|
-
}
|
|
1545
|
-
catch (err) {
|
|
1546
|
-
// A missing table is an older schema; any other error could hide a backing memory, so the caller stops.
|
|
1547
|
-
if (!(err instanceof Error && err.message.includes('no such table')))
|
|
1548
|
-
throw err;
|
|
1549
|
-
}
|
|
1550
|
-
}
|
|
1551
|
-
}
|
|
1552
|
-
finally {
|
|
1553
|
-
closeHippoDb(db);
|
|
1554
|
-
}
|
|
1555
|
-
return ids;
|
|
1556
|
-
}
|
|
1557
|
-
/**
|
|
1558
|
-
* AT1 (plan §4, round-2 fix, designed from source): db-scoped delete core.
|
|
1559
|
-
* `deleteEntry` used to open+close its OWN connection, which meant it could
|
|
1560
|
-
* never compose inside a caller's transaction (unlike writeEntry/
|
|
1561
|
-
* writeEntryDbOnly, which already split this way). Split identically: row-
|
|
1562
|
-
* meta SELECT, `DELETE FROM memories`, FTS delete, `forget` audit, DAG
|
|
1563
|
-
* dirty-mark. NO filesystem I/O — the caller's own transaction may still be
|
|
1564
|
-
* rolled back, and mirror writes must only happen post-commit.
|
|
1565
|
-
*
|
|
1566
|
-
* `opts.suppressForgetAudit` (default false, off): two AT1 callers set this
|
|
1567
|
-
* so a removed non-raw row does NOT ALSO emit a `forget` row, because each
|
|
1568
|
-
* already writes its own aggregate audit trail — `src/reject-flow.ts`'s
|
|
1569
|
-
* `rejectValue` (single `reject_value` row covering every same-digest row
|
|
1570
|
-
* removed) and `resolveConflict` (`conflict_resolve` row per resolution).
|
|
1571
|
-
* Default keeps `deleteEntry` byte-identical to its pre-split behavior.
|
|
1572
|
-
*
|
|
1573
|
-
* Returns `{tenantId, dagParentId}` for the removed row, or `null` if no row with `id`
|
|
1574
|
-
* existed or `automatic` refused it (pinned, raw, kept for good or backing an object at DELETE time, so a late pin wins).
|
|
1575
|
-
*/
|
|
1576
|
-
export function deleteEntryCore(db, id, opts) {
|
|
1577
|
-
// SAFETY: row's shape matches the three columns named in the SELECT above.
|
|
1578
|
-
const row = db
|
|
1579
|
-
.prepare(`SELECT id, tenant_id, dag_parent_id FROM memories WHERE id = ?`)
|
|
1580
|
-
.get(id);
|
|
1581
|
-
if (!row?.id)
|
|
1582
|
-
return null;
|
|
1583
|
-
const guard = opts?.automatic ? ` AND ${AUTOMATIC_DELETE_SQL}` : '';
|
|
1584
|
-
if (Number(db.prepare(`DELETE FROM memories WHERE id = ?${guard}`).run(id).changes ?? 0) === 0)
|
|
1585
|
-
return null;
|
|
1586
|
-
deleteFtsRow(db, id);
|
|
1587
|
-
if (!opts?.suppressForgetAudit) {
|
|
1588
|
-
audit(db, 'forget', id, opts?.reason ? { reason: opts.reason } : undefined, opts?.actor ?? 'cli', row.tenant_id);
|
|
1589
|
-
}
|
|
1590
|
-
// v0.30 / E2 — DAG live-coupling: forget of a child under a level-2
|
|
1591
|
-
// summary marks parent dirty. Non-atomic with the DELETE (no SAVEPOINT
|
|
1592
|
-
// wrapper here, same as pre-split deleteEntry); markSummaryDirtyInTx is
|
|
1593
|
-
// idempotent so any future child mutation re-marks parent if this fails.
|
|
1594
|
-
// Acceptable degradation, mirrors the pre-split audit best-effort posture.
|
|
1595
|
-
if (row.dag_parent_id) {
|
|
1596
|
-
markSummaryDirtyInTx(db, row.dag_parent_id, row.tenant_id ?? 'default', opts?.actor ?? 'cli');
|
|
1597
|
-
}
|
|
1598
|
-
return { tenantId: row.tenant_id ?? 'default', dagParentId: row.dag_parent_id ?? null };
|
|
1599
|
-
}
|
|
1600
|
-
/**
|
|
1601
|
-
* Delete an entry from SQLite and mirrors.
|
|
1602
|
-
*
|
|
1603
|
-
* `opts.actor` defaults to 'cli'. The api.* layer threads `ctx.actor` so HTTP
|
|
1604
|
-
* callers land with `api_key:<key_id>` in the audit log without a duplicate
|
|
1605
|
-
* emit from the api wrapper.
|
|
1606
|
-
*
|
|
1607
|
-
* Thin wrapper over `deleteEntryCore` (open → core → mirrors → close);
|
|
1608
|
-
* behavior is byte-identical to the pre-split implementation for every
|
|
1609
|
-
* existing caller.
|
|
1610
|
-
*/
|
|
1611
|
-
export function deleteEntry(hippoRoot, id, opts) {
|
|
1612
|
-
const db = openStore(hippoRoot);
|
|
1613
|
-
try {
|
|
1614
|
-
const result = deleteEntryCore(db, id, opts);
|
|
1615
|
-
if (!result)
|
|
1616
|
-
return false;
|
|
1617
|
-
purgeMirrorBestEffort(hippoRoot, id, false, 'deleteEntry');
|
|
1618
|
-
return true;
|
|
1619
|
-
}
|
|
1620
|
-
finally {
|
|
1621
|
-
closeHippoDb(db);
|
|
1622
|
-
}
|
|
1623
|
-
}
|
|
1624
|
-
// The child fields a level-2/3 summary is built from (loadChildrenOfSummary, generateDagSummary).
|
|
1625
|
-
const SUMMARY_INPUTS = ['content', 'created', 'dag_parent_id', 'kind'];
|
|
1626
|
-
function mergeOwnChanges(base, ours, live) {
|
|
1627
|
-
const row = { ...live };
|
|
1628
|
-
const loaded = new Map(Object.entries(base));
|
|
1629
|
-
for (const [key, value] of Object.entries(ours)) {
|
|
1630
|
-
if (JSON.stringify(value) !== JSON.stringify(loaded.get(key)))
|
|
1631
|
-
Object.assign(row, { [key]: value });
|
|
1632
|
-
}
|
|
1633
|
-
return row;
|
|
1634
|
-
}
|
|
1635
|
-
/** Consolidation's flush, one transaction. With `snapshot` (rows as the caller loaded them), a write keeps only
|
|
1636
|
-
* the fields the caller changed, takes the rest from the live row, and never resurrects a row that is gone.
|
|
1637
|
-
*
|
|
1638
|
-
* `dormant` (src/dormant.ts): each move's snapshot is inserted into `dormant_memories` and its `memories` row
|
|
1639
|
-
* leaves exactly like a delete (FTS row, DAG parent dirty-mark, mirrors), in the same transaction, so a memory
|
|
1640
|
-
* is never in both places or in neither. Deletes and moves both skip rows that are no longer auto-deletable
|
|
1641
|
-
* (pinned, raw, kept for good or backing an object since the caller decided). Returns the ids that left `memories`, deleted or moved. */
|
|
1642
|
-
export function batchWriteAndDelete(hippoRoot, toWrite, toDeleteIds, opts) {
|
|
1643
|
-
const dormantMoves = opts?.dormant ?? [];
|
|
1644
|
-
if (toWrite.length === 0 && toDeleteIds.length === 0 && dormantMoves.length === 0)
|
|
1645
|
-
return [];
|
|
1646
|
-
const db = openStore(hippoRoot);
|
|
1647
|
-
try {
|
|
1648
|
-
// BEGIN IMMEDIATE (codex delta-review P2): the AT1 tombstone probes below
|
|
1649
|
-
// READ before the first write. Under a deferred BEGIN, that read pins a
|
|
1650
|
-
// WAL snapshot; a concurrent writer (e.g. `hippo reject`) committing
|
|
1651
|
-
// between probe and first upsert would make the later write-lock upgrade
|
|
1652
|
-
// fail with SQLITE_BUSY and roll back the ENTIRE batch — the exact race
|
|
1653
|
-
// the probe exists to contain. Taking the write lock up front serializes
|
|
1654
|
-
// the probe and the writes on one consistent snapshot.
|
|
1655
|
-
db.exec('BEGIN IMMEDIATE');
|
|
1656
|
-
// v0.30 / E2 — DAG live-coupling: BEFORE deletes, snapshot dag_parent_id
|
|
1657
|
-
// for every doomed row so we can mark parents dirty post-COMMIT. Done
|
|
1658
|
-
// inside the same BEGIN so the SELECT sees pre-delete state.
|
|
1659
|
-
// independent-review-critic R1 HIGH: consolidate.ts/sleep flushes through
|
|
1660
|
-
// this path every cycle; without these hooks parents NEVER get marked
|
|
1661
|
-
// dirty for the dominant mutation source (decay, merge, garbage-collect).
|
|
1662
|
-
const dirtyParents = new Set();
|
|
1663
|
-
const tenantById = new Map();
|
|
1664
|
-
const deletableIds = [];
|
|
1665
|
-
if (toDeleteIds.length > 0) {
|
|
1666
|
-
const placeholders = toDeleteIds.map(() => '?').join(',');
|
|
1667
|
-
// A row pinned after the caller decided to delete it survives.
|
|
1668
|
-
// SAFETY: rows' shape matches the three columns named in the SELECT.
|
|
1669
|
-
const rows = db.prepare(`SELECT id, dag_parent_id, tenant_id FROM memories WHERE id IN (${placeholders}) AND ${AUTOMATIC_DELETE_SQL}`).all(...toDeleteIds);
|
|
1670
|
-
for (const row of rows) {
|
|
1671
|
-
deletableIds.push(row.id);
|
|
1672
|
-
if (row.dag_parent_id) {
|
|
1673
|
-
dirtyParents.add(row.dag_parent_id);
|
|
1674
|
-
tenantById.set(row.dag_parent_id, row.tenant_id ?? 'default');
|
|
1675
|
-
}
|
|
1676
|
-
}
|
|
1677
|
-
}
|
|
1678
|
-
// v39: batch writers bypass writeEntry, so stamp store-derived origins here too (a NULL origin hides new
|
|
1679
|
-
// memories from ambient context). A row queued twice keeps only its last version, the one the merge compares.
|
|
1680
|
-
const stampedWrites = [...new Map(toWrite.map((e) => [e.id, stampOriginProject(hippoRoot, e)])).values()];
|
|
1681
|
-
// AT1 P1 fix (codex, batch-transaction rejection race): the producer-side
|
|
1682
|
-
// check (e.g. consolidate.ts's merge pass) runs BEFORE this transaction,
|
|
1683
|
-
// on a different connection. A `hippo reject X` that commits in that
|
|
1684
|
-
// window is invisible to it — a queued same-id write of X already
|
|
1685
|
-
// sitting in `toWrite` (decay/replay re-persist, or a merge built before
|
|
1686
|
-
// the reject) would silently re-INSERT the just-rejected row via the
|
|
1687
|
-
// blind bypass. Fix: one indexed point probe per batch entry, on THIS
|
|
1688
|
-
// connection, INSIDE this transaction — closes the race regardless of
|
|
1689
|
-
// which write class hits it. N is small per sleep, so the extra query
|
|
1690
|
-
// per entry is cheap.
|
|
1691
|
-
//
|
|
1692
|
-
// Skip, don't throw: the batch must still complete for every OTHER
|
|
1693
|
-
// entry. Skipping is correct for every write class here — a merge
|
|
1694
|
-
// summary skip just means that rollup is absent this cycle (its source
|
|
1695
|
-
// facts stay merely demoted, recoverable next sleep); a skipped
|
|
1696
|
-
// demotion/replay re-persist of a rejected-removed row means it stays
|
|
1697
|
-
// gone, which is the entire point of the tombstone.
|
|
1698
|
-
let batchRejectedSkips = 0;
|
|
1699
|
-
const written = [];
|
|
1700
|
-
const readLiveRow = db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories WHERE id = ?`);
|
|
1701
|
-
for (const entry of stampedWrites) {
|
|
1702
|
-
const base = opts?.snapshot?.get(entry.id);
|
|
1703
|
-
// SAFETY: MEMORY_SELECT_COLUMNS is the MemoryRow shape rowToEntry reads.
|
|
1704
|
-
const liveRow = readLiveRow.get(entry.id);
|
|
1705
|
-
const live = liveRow ? rowToEntry(liveRow) : undefined;
|
|
1706
|
-
const row = base && live ? mergeOwnChanges(base, entry, live) : entry;
|
|
1707
|
-
const entryTenantId = row.tenantId ?? 'default';
|
|
1708
|
-
// Codex delta-review P2 fix: reuse checkRejectionGuard rather than a
|
|
1709
|
-
// bare tombstone probe — the guard's content-INTRODUCTION
|
|
1710
|
-
// classification must apply here too. A tombstone can legitimately
|
|
1711
|
-
// coexist with a live same-content row (resolveConflict deliberately
|
|
1712
|
-
// excludes keepId from its sweep; unreject-then-re-reject windows), and
|
|
1713
|
-
// an unconditional skip would starve that row of decay/replay metadata
|
|
1714
|
-
// updates forever. The guard throws only when the write is new-row or
|
|
1715
|
-
// changes content TO the rejected value; unchanged same-id re-persists
|
|
1716
|
-
// pass through, exactly as on the writeEntry path.
|
|
1717
|
-
try {
|
|
1718
|
-
checkRejectionGuard(db, entryTenantId, row.id, row.content);
|
|
1719
|
-
}
|
|
1720
|
-
catch (err) {
|
|
1721
|
-
if (err instanceof RejectedValueError) {
|
|
1722
|
-
batchRejectedSkips++;
|
|
1723
|
-
audit(db, 'reject_refusal', row.id, { digest: err.digest, reason: err.reason }, 'sleep-batch', entryTenantId);
|
|
1724
|
-
continue;
|
|
1725
|
-
}
|
|
1726
|
-
throw err;
|
|
1727
|
-
}
|
|
1728
|
-
if (base && !live)
|
|
1729
|
-
continue;
|
|
1730
|
-
written.push(row);
|
|
1731
|
-
// AT1 (plan §3, corrected): bypass the rejection guard here.
|
|
1732
|
-
// Consolidation merges are DETERMINISTIC CONCATENATION (mergeContents,
|
|
1733
|
-
// consolidate.ts:736-751) of already-guarded leaf facts, not an LLM
|
|
1734
|
-
// paraphrase — refusing mid-batch would abort the whole consolidation
|
|
1735
|
-
// transaction. The bypass is safe because consolidate.ts's merge pass
|
|
1736
|
-
// now checks the merged content's rejection digest against the
|
|
1737
|
-
// tenant's tombstones BEFORE ever pushing a merge into pendingWrites,
|
|
1738
|
-
// skipping that merge entirely on a hit, AND because the point-probe
|
|
1739
|
-
// immediately above closes the race window between that producer
|
|
1740
|
-
// check and this COMMIT. The guard itself still belongs on leaf
|
|
1741
|
-
// inserts, which write through writeEntry / writeEntryDbOnly and stay
|
|
1742
|
-
// guarded (bypassRejectionGuard defaults false).
|
|
1743
|
-
upsertEntryRow(db, row, true);
|
|
1744
|
-
// Hook for writes: a new child, or a change to what its summary reads, marks the parent dirty; decay alone does not.
|
|
1745
|
-
if (row.dag_parent_id && (!live || SUMMARY_INPUTS.some((k) => row[k] !== live[k]))) {
|
|
1746
|
-
dirtyParents.add(row.dag_parent_id);
|
|
1747
|
-
tenantById.set(row.dag_parent_id, row.tenantId);
|
|
1748
|
-
}
|
|
1749
|
-
}
|
|
1750
|
-
// Dormant moves: same eligibility and DAG bookkeeping as deletes.
|
|
1751
|
-
const movable = [];
|
|
1752
|
-
if (dormantMoves.length > 0) {
|
|
1753
|
-
const byId = new Map(dormantMoves.map((m) => [m.entry.id, m]));
|
|
1754
|
-
const placeholders = dormantMoves.map(() => '?').join(',');
|
|
1755
|
-
// SAFETY: rows' shape matches the three columns named in the SELECT.
|
|
1756
|
-
const rows = db.prepare(`SELECT id, dag_parent_id, tenant_id FROM memories WHERE id IN (${placeholders}) AND ${AUTOMATIC_DELETE_SQL}`).all(...byId.keys());
|
|
1757
|
-
for (const row of rows) {
|
|
1758
|
-
movable.push(byId.get(row.id));
|
|
1759
|
-
if (row.dag_parent_id) {
|
|
1760
|
-
dirtyParents.add(row.dag_parent_id);
|
|
1761
|
-
tenantById.set(row.dag_parent_id, row.tenant_id ?? 'default');
|
|
1762
|
-
}
|
|
1763
|
-
}
|
|
1764
|
-
}
|
|
1765
|
-
for (const move of movable) {
|
|
1766
|
-
insertDormantRow(db, move);
|
|
1767
|
-
}
|
|
1768
|
-
const removedIds = [...deletableIds, ...movable.map((m) => m.entry.id)];
|
|
1769
|
-
for (const id of removedIds) {
|
|
1770
|
-
db.prepare('DELETE FROM memories WHERE id = ?').run(id);
|
|
1771
|
-
deleteFtsRow(db, id);
|
|
1772
|
-
}
|
|
1773
|
-
// Fire dirty-mark for every collected parent INSIDE the BEGIN, so the
|
|
1774
|
-
// dirty flag commits atomically with the writes + deletes.
|
|
1775
|
-
for (const parentId of dirtyParents) {
|
|
1776
|
-
markSummaryDirtyInTx(db, parentId, tenantById.get(parentId) ?? 'default', 'batch');
|
|
1777
|
-
}
|
|
1778
|
-
db.exec('COMMIT');
|
|
1779
|
-
if (batchRejectedSkips > 0) {
|
|
1780
|
-
log.warn(`batchWriteAndDelete: skipped ${batchRejectedSkips} write(s) whose content matches a rejected value (tombstone hit during the batch transaction)`);
|
|
1781
|
-
}
|
|
1782
|
-
// Sync mirrors once after all DB writes. Entries skipped above were
|
|
1783
|
-
// never inserted — writing their markdown mirror would resurrect the
|
|
1784
|
-
// exact content the skip just kept out of the DB.
|
|
1785
|
-
mirrorBestEffort('markdown mirrors', () => {
|
|
1786
|
-
for (const entry of written)
|
|
1787
|
-
writeMarkdownMirror(hippoRoot, entry);
|
|
1788
|
-
});
|
|
1789
|
-
for (const id of removedIds)
|
|
1790
|
-
purgeMirrorBestEffort(hippoRoot, id, false, 'batchWriteAndDelete');
|
|
1791
|
-
return removedIds;
|
|
1792
|
-
}
|
|
1793
|
-
catch (error) {
|
|
1794
|
-
try {
|
|
1795
|
-
db.exec('ROLLBACK');
|
|
1796
|
-
}
|
|
1797
|
-
catch { /* ignore */ }
|
|
1798
|
-
throw error;
|
|
1799
|
-
}
|
|
1800
|
-
finally {
|
|
1801
|
-
closeHippoDb(db);
|
|
1802
|
-
}
|
|
1803
|
-
}
|
|
1804
|
-
/**
|
|
1805
|
-
* Load all entries from SQLite.
|
|
1806
|
-
*
|
|
1807
|
-
* When `tenantId` is provided, results are scoped to that tenant. Omitting it
|
|
1808
|
-
* yields all rows (legacy behavior used by consolidate/autolearn etc.). Recall
|
|
1809
|
-
* paths that surface results to a user MUST pass a resolved tenant.
|
|
1810
|
-
*/
|
|
1811
|
-
export function loadAllEntries(hippoRoot, tenantId) {
|
|
1812
|
-
const db = openStore(hippoRoot);
|
|
1813
|
-
try {
|
|
1814
|
-
return selectAllEntries(db, tenantId);
|
|
1815
|
-
}
|
|
1816
|
-
finally {
|
|
1817
|
-
closeHippoDb(db);
|
|
1818
|
-
}
|
|
1819
|
-
}
|
|
1820
|
-
/** Every memory row on an open connection, so a caller can read inside its own transaction. */
|
|
1821
|
-
export function selectAllEntries(db, tenantId) {
|
|
1822
|
-
// SAFETY: both branches select exactly MEMORY_SELECT_COLUMNS, matching
|
|
1823
|
-
// MemoryRow's field set.
|
|
1824
|
-
const rows = tenantId !== undefined
|
|
1825
|
-
? db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories WHERE tenant_id = ? ORDER BY created ASC, id ASC`).all(tenantId)
|
|
1826
|
-
: db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories ORDER BY created ASC, id ASC`).all();
|
|
1827
|
-
return rows.map(rowToEntry);
|
|
1828
|
-
}
|
|
1829
|
-
/** Live rows whose source starts with `prefix`, on the caller's handle; LIKE folds case, so the prefix is checked again exactly. */
|
|
1830
|
-
export function selectLiveEntriesBySourcePrefix(db, tenantId, prefix) {
|
|
1831
|
-
// SAFETY: selects exactly MEMORY_SELECT_COLUMNS, matching MemoryRow's field set.
|
|
1832
|
-
const rows = db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories WHERE tenant_id = ? AND superseded_by IS NULL AND source LIKE ? ESCAPE '\\'`).all(tenantId, `${prefix.replace(/[%_\\]/g, '\\$&')}%`);
|
|
1833
|
-
return rows.map(rowToEntry).filter((entry) => entry.source.startsWith(prefix));
|
|
1834
|
-
}
|
|
1835
|
-
/** Rewrites a live row's tags and its full-text row on the caller's transaction, with no audit row. */
|
|
1836
|
-
export function setEntryTagsInTx(db, entry) {
|
|
1837
|
-
db.prepare(`UPDATE memories SET tags_json = ?, updated_at = datetime('now') WHERE id = ? AND tenant_id = ?`)
|
|
1838
|
-
.run(JSON.stringify(entry.tags), entry.id, entry.tenantId);
|
|
1839
|
-
syncFtsRow(db, entry);
|
|
1840
|
-
}
|
|
1841
|
-
/** Removes a row from `memories` and full-text search on the caller's transaction, as sleep's dormant move does, and marks its summary parent dirty. */
|
|
1842
|
-
export function deleteEntryRowInTx(db, entry, actor) {
|
|
1843
|
-
db.prepare('DELETE FROM memories WHERE id = ? AND tenant_id = ?').run(entry.id, entry.tenantId);
|
|
1844
|
-
deleteFtsRow(db, entry.id);
|
|
1845
|
-
if (entry.dag_parent_id)
|
|
1846
|
-
markSummaryDirtyInTx(db, entry.dag_parent_id, entry.tenantId, actor);
|
|
1847
|
-
}
|
|
1848
|
-
// Content of every tenant row tagged `tag`, without reading the rest of the store.
|
|
1849
|
-
// `instr` is a substring prefilter over the raw JSON; `includes` below re-checks exactly.
|
|
1850
|
-
export function loadContentsWithTag(hippoRoot, tenantId, tag) {
|
|
1851
|
-
const db = openStore(hippoRoot);
|
|
1852
|
-
try {
|
|
1853
|
-
/** SAFETY: rows' shape matches the two columns named in the SELECT below. */
|
|
1854
|
-
const rows = db.prepare(`SELECT content, tags_json FROM memories WHERE tenant_id = ? AND instr(tags_json, ?) > 0`).all(tenantId, JSON.stringify(tag));
|
|
1855
|
-
return rows.filter((r) => parseJsonArray(r.tags_json).includes(tag)).map((r) => r.content);
|
|
1856
|
-
}
|
|
1857
|
-
finally {
|
|
1858
|
-
closeHippoDb(db);
|
|
1859
|
-
}
|
|
1860
|
-
}
|
|
1861
|
-
const AMBIENT_SCOPED = 'superseded_by IS NULL AND tenant_id = ?';
|
|
1862
|
-
/** Exported so the plan test runs the exact SQL; idx_memories_pinned (db.ts v51) serves it. */
|
|
1863
|
-
export const AMBIENT_PINNED_WHERE = `pinned = 1 AND ${AMBIENT_SCOPED} ORDER BY created ASC, id ASC`;
|
|
1864
|
-
/** Exported so the plan test runs the exact SQL; idx_memories_created_drift (db.ts v51) serves it. */
|
|
1865
|
-
export const AMBIENT_DRIFT_SQL = `SELECT 1 FROM memories WHERE ${AMBIENT_SCOPED} AND (length(created) <> 24 OR created NOT LIKE '%Z') LIMIT 1`;
|
|
1866
|
-
// The pins plus the `recentNeeded` newest rows that pass `admit`, for ambient
|
|
1867
|
-
// injection. One connection; `recall` piggybacks the Z1 FTS query on it too.
|
|
1868
|
-
export function loadAmbientCandidates(hippoRoot, tenantId, recentNeeded, admit, recall) {
|
|
1869
|
-
// A SQL LIMIT takes an integer; the Array.slice this replaced truncated one,
|
|
1870
|
-
// and include_recent is any non-negative finite number at the HTTP edge.
|
|
1871
|
-
const needed = Math.trunc(recentNeeded);
|
|
1872
|
-
const db = openStore(hippoRoot);
|
|
1873
|
-
try {
|
|
1874
|
-
// SAFETY: every `where` below starts from MEMORY_SELECT_COLUMNS' table.
|
|
1875
|
-
const run = (where, params) => db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories WHERE ${where}`).all(...params).map(rowToEntry);
|
|
1876
|
-
const byId = new Map();
|
|
1877
|
-
const scoped = AMBIENT_SCOPED;
|
|
1878
|
-
for (const e of run(AMBIENT_PINNED_WHERE, [tenantId])) {
|
|
1879
|
-
if (admit(e))
|
|
1880
|
-
byId.set(e.id, e);
|
|
1881
|
-
}
|
|
1882
|
-
if (needed > 0) {
|
|
1883
|
-
// Text order is chronological only for canonical UTC ISO (memory.ts).
|
|
1884
|
-
const drifted = db.prepare(AMBIENT_DRIFT_SQL).get(tenantId) !== undefined;
|
|
1885
|
-
// `id DESC` mirrors getContext's comparator, not loadFreshRawMemories'
|
|
1886
|
-
// cross-ingest-stable order: that would change what the hook injects.
|
|
1887
|
-
const window = Math.max(needed * 4, 32);
|
|
1888
|
-
const windowed = drifted
|
|
1889
|
-
? []
|
|
1890
|
-
: run(`${scoped} ORDER BY created DESC, id DESC LIMIT ?`, [tenantId, window]);
|
|
1891
|
-
let kept = windowed.filter(admit);
|
|
1892
|
-
if (drifted || (kept.length < needed && windowed.length === window)) {
|
|
1893
|
-
kept = run(`${scoped} ORDER BY created DESC, id DESC`, [tenantId]).filter(admit);
|
|
1894
|
-
}
|
|
1895
|
-
for (const e of kept)
|
|
1896
|
-
byId.set(e.id, e);
|
|
1897
|
-
}
|
|
1898
|
-
// loadAllEntries' order: rankedPinned's comparator can tie and Array.sort
|
|
1899
|
-
// is stable, so input order is load-bearing downstream.
|
|
1900
|
-
const entries = [...byId.values()].sort((a, b) => {
|
|
1901
|
-
const byCreated = a.created.localeCompare(b.created);
|
|
1902
|
-
return byCreated !== 0 ? byCreated : a.id.localeCompare(b.id);
|
|
1903
|
-
});
|
|
1904
|
-
if (!recall)
|
|
1905
|
-
return { entries };
|
|
1906
|
-
const ftsQuery = pickRarestFtsQuery(db, recall.terms);
|
|
1907
|
-
const recallEntries = ftsQuery
|
|
1908
|
-
? loadRecallSearchEntriesFromDb(db, ftsQuery, recall.limit, tenantId, undefined, 'exact', false)
|
|
1909
|
-
: [];
|
|
1910
|
-
return { entries, recall: recallEntries };
|
|
1911
|
-
}
|
|
1912
|
-
finally {
|
|
1913
|
-
closeHippoDb(db);
|
|
1914
|
-
}
|
|
1915
|
-
}
|
|
1916
|
-
// calculateStrength's decay exponent with its reward factor, pins first: the strongest rows, without scoring each one in JS.
|
|
1917
|
-
const DECAY_RANK_SQL = `pinned DESC,
|
|
1918
|
-
CASE WHEN half_life_days > 0 THEN (julianday(?) - julianday(last_retrieved))
|
|
1919
|
-
/ (half_life_days * (1.0 + 0.5 * (COALESCE(outcome_positive, 0) - COALESCE(outcome_negative, 0))
|
|
1920
|
-
/ (COALESCE(outcome_positive, 0) + COALESCE(outcome_negative, 0) + 1.0))) END ASC NULLS LAST,
|
|
1921
|
-
id ASC`;
|
|
1922
|
-
/** Live tenant rows passing `filter`, at most `filter.cap`, in loadAllEntries' order; below the cap, every such row. */
|
|
1923
|
-
export function loadContextCandidates(hippoRoot, tenantId, filter) {
|
|
1924
|
-
const where = ['tenant_id = ?', `COALESCE(superseded_by, '') = ''`];
|
|
1925
|
-
const params = [tenantId];
|
|
1926
|
-
if (filter.exactScope) {
|
|
1927
|
-
where.push('scope = ?');
|
|
1928
|
-
params.push(filter.exactScope);
|
|
1929
|
-
}
|
|
1930
|
-
else {
|
|
1931
|
-
// isRestrictedScope's rule; NOT LIKE folds ASCII case as its /:private:/i does.
|
|
1932
|
-
where.push(`(scope IS NULL OR (scope NOT IN (${RECALL_DEFAULT_DENY_SCOPES.map(() => '?').join(', ')}) AND scope NOT LIKE '%:private:%'))`);
|
|
1933
|
-
params.push(...RECALL_DEFAULT_DENY_SCOPES);
|
|
1934
|
-
}
|
|
1935
|
-
if (filter.project !== undefined) {
|
|
1936
|
-
where.push(`(origin_project = '' OR origin_project = ?)`);
|
|
1937
|
-
params.push(filter.project);
|
|
1938
|
-
}
|
|
1939
|
-
const db = openStore(hippoRoot);
|
|
1940
|
-
try {
|
|
1941
|
-
// SAFETY: the outer SELECT names exactly MEMORY_SELECT_COLUMNS, matching MemoryRow's field set; the rank sort carries ids only.
|
|
1942
|
-
const rows = db.prepare(`SELECT ${MEMORY_SELECT_COLUMNS} FROM memories WHERE id IN (
|
|
1943
|
-
SELECT id FROM memories WHERE ${where.join(' AND ')} ORDER BY ${DECAY_RANK_SQL} LIMIT ?
|
|
1944
|
-
) ORDER BY created ASC, id ASC`).all(...params, filter.now.toISOString(), Math.max(0, Math.trunc(filter.cap)));
|
|
1945
|
-
return rows.map(rowToEntry);
|
|
1946
|
-
}
|
|
1947
|
-
finally {
|
|
1948
|
-
closeHippoDb(db);
|
|
1949
|
-
}
|
|
1950
|
-
}
|
|
1951
|
-
/** Every tenant row as a StrengthRow, for whole-store health numbers; defaults match rowToEntry's. */
|
|
1952
|
-
export function loadStrengthRows(hippoRoot, tenantId) {
|
|
1953
|
-
const db = openStore(hippoRoot);
|
|
1954
|
-
try {
|
|
1955
|
-
// SAFETY: rows' shape matches the columns named in the SELECT below.
|
|
1956
|
-
const rows = db.prepare(`SELECT pinned, created, last_retrieved, half_life_days, retrieval_count, emotional_valence, outcome_positive, outcome_negative, tags_json
|
|
1957
|
-
FROM memories WHERE tenant_id = ?`).all(tenantId);
|
|
1958
|
-
return rows.map((row) => ({
|
|
1959
|
-
pinned: Boolean(row.pinned),
|
|
1960
|
-
created: row.created,
|
|
1961
|
-
last_retrieved: row.last_retrieved,
|
|
1962
|
-
half_life_days: Number(row.half_life_days ?? 7),
|
|
1963
|
-
retrieval_count: Number(row.retrieval_count ?? 0),
|
|
1964
|
-
emotional_valence: row.emotional_valence ?? 'neutral',
|
|
1965
|
-
outcome_positive: Number(row.outcome_positive ?? 0),
|
|
1966
|
-
outcome_negative: Number(row.outcome_negative ?? 0),
|
|
1967
|
-
tags: parseJsonArray(row.tags_json),
|
|
1968
|
-
}));
|
|
1969
|
-
}
|
|
1970
|
-
finally {
|
|
1971
|
-
closeHippoDb(db);
|
|
1972
|
-
}
|
|
1973
|
-
}
|
|
1974
|
-
/** Text and source of tenant rows holding any of `words`; a row equal to a text apart from spacing holds its every word. */
|
|
1975
|
-
export function loadTextsHoldingWords(hippoRoot, tenantId, words) {
|
|
1976
|
-
const unique = [...new Set(words)];
|
|
1977
|
-
const out = [];
|
|
1978
|
-
const db = openStore(hippoRoot);
|
|
1979
|
-
try {
|
|
1980
|
-
// Chunked so one statement stays far under SQLite's bound-parameter limit.
|
|
1981
|
-
for (let i = 0; i < unique.length; i += 200) {
|
|
1982
|
-
const chunk = unique.slice(i, i + 200);
|
|
1983
|
-
// SAFETY: rows' shape matches the two columns named in the SELECT below.
|
|
1984
|
-
const rows = db.prepare(`SELECT content, source FROM memories WHERE tenant_id = ? AND (${chunk.map(() => 'instr(content, ?) > 0').join(' OR ')})`).all(tenantId, ...chunk);
|
|
1985
|
-
for (const row of rows)
|
|
1986
|
-
out.push({ content: row.content, source: row.source ?? 'cli' });
|
|
1987
|
-
}
|
|
1988
|
-
return out;
|
|
1989
|
-
}
|
|
1990
|
-
finally {
|
|
1991
|
-
closeHippoDb(db);
|
|
1992
|
-
}
|
|
1993
|
-
}
|
|
1994
|
-
/** Row count and newest `created` per source, for peer listings that need no row; all tenants when `tenantId` is absent. */
|
|
1995
|
-
export function tallySources(hippoRoot, tenantId) {
|
|
1996
|
-
const db = openStore(hippoRoot);
|
|
1997
|
-
try {
|
|
1998
|
-
const where = tenantId !== undefined ? 'WHERE tenant_id = ?' : '';
|
|
1999
|
-
// SAFETY: rows' shape matches the four aliased columns in the SELECT below.
|
|
2000
|
-
return db.prepare(`SELECT COALESCE(source, 'cli') AS source, COUNT(*) AS count, MAX(created) AS latest, MIN(created || char(31) || id) AS first
|
|
2001
|
-
FROM memories ${where} GROUP BY COALESCE(source, 'cli')`).all(...(tenantId !== undefined ? [tenantId] : []));
|
|
2002
|
-
}
|
|
2003
|
-
finally {
|
|
2004
|
-
closeHippoDb(db);
|
|
2005
|
-
}
|
|
2006
|
-
}
|
|
2007
|
-
/**
|
|
2008
|
-
* Load likely search candidates directly from SQLite.
|
|
2009
|
-
* Uses FTS5 when available, falls back to LIKE matching, then full-store fallback.
|
|
2010
|
-
*
|
|
2011
|
-
* When `tenantId` is provided, every SELECT (FTS join, LIKE, fallback) filters
|
|
2012
|
-
* by tenant_id. Cross-tenant memories never surface. Omitted = no filter.
|
|
2013
|
-
*/
|
|
2014
|
-
export function loadSearchEntries(hippoRoot, query, limit = DEFAULT_SEARCH_CANDIDATE_LIMIT, tenantId) {
|
|
2015
|
-
const db = openStore(hippoRoot);
|
|
2016
|
-
try {
|
|
2017
|
-
return loadSearchRows(db, query, limit, tenantId).map(rowToEntry);
|
|
2018
|
-
}
|
|
2019
|
-
finally {
|
|
2020
|
-
closeHippoDb(db);
|
|
2021
|
-
}
|
|
2022
|
-
}
|
|
2023
|
-
/**
|
|
2024
|
-
* v1.7.1 — recall-mode loader. Pushes the recall-side scope predicate into
|
|
2025
|
-
* SQL so `unknown:legacy` cannot leak via any consumer that hasn't remembered
|
|
2026
|
-
* to re-filter (root-cause-over-patches: codex flagged this on v1.6.5 review).
|
|
2027
|
-
*
|
|
2028
|
-
* - `requestedScope` undefined / '': default-deny on `unknown:legacy`.
|
|
2029
|
-
* - `requestedScope` non-empty string: exact match on `m.scope = requestedScope`.
|
|
2030
|
-
*
|
|
2031
|
-
* Private-scope (`<source>:private:*`) exclusion: SQL applies a conservative
|
|
2032
|
-
* pre-window approximation (`NOT LIKE '%:private:%'`, v1.25.0 — codex P2:
|
|
2033
|
-
* post-window-only filtering let private rows starve admitted candidates out
|
|
2034
|
-
* of the LIMIT window); the exact anchored regex
|
|
2035
|
-
* (`passesScopeFilterForRecall`) remains the authoritative JS post-filter in
|
|
2036
|
-
* the recall consumers.
|
|
2037
|
-
*
|
|
2038
|
-
* Consumers: `api.recall` (v1.7.1+), `cmdRecall`/`cmdExplain` direct CLI paths
|
|
2039
|
-
* and `searchBothHybrid` recall mode (v1.25.0). Background pipelines
|
|
2040
|
-
* (`consolidate`, `embeddings`, `refine-llm`, ...) keep using
|
|
2041
|
-
* `loadSearchEntries` so they can see quarantined rows when needed.
|
|
2042
|
-
*
|
|
2043
|
-
* `tenantId` widened to optional in v1.25.0 for the searchBothHybrid recall
|
|
2044
|
-
* mode (its `tenantId` option is optional); `loadSearchRows` already treats
|
|
2045
|
-
* undefined as "no tenant filter" for legacy callers.
|
|
2046
|
-
*/
|
|
2047
|
-
export function loadRecallSearchEntries(hippoRoot, query, limit = DEFAULT_SEARCH_CANDIDATE_LIMIT, tenantId, requestedScope, explicitScopeMode = 'exact', includeSuperseded = true) {
|
|
2048
|
-
const db = openStore(hippoRoot);
|
|
2049
|
-
try {
|
|
2050
|
-
return loadRecallSearchEntriesFromDb(db, query, limit, tenantId, requestedScope, explicitScopeMode, includeSuperseded);
|
|
2051
|
-
}
|
|
2052
|
-
finally {
|
|
2053
|
-
closeHippoDb(db);
|
|
2054
|
-
}
|
|
2055
|
-
}
|
|
2056
|
-
// Split out so callers with an already-open db (Z1 prompt-recall path) skip
|
|
2057
|
-
// the initStore+open/close cycle per store per call.
|
|
2058
|
-
export function loadRecallSearchEntriesFromDb(db, query, limit = DEFAULT_SEARCH_CANDIDATE_LIMIT, tenantId, requestedScope, explicitScopeMode = 'exact', includeSuperseded = true) {
|
|
2059
|
-
// 'exact' narrows to requestedScope; 'additive' adds it to the default-admitted set.
|
|
2060
|
-
const scopeFilter = requestedScope && requestedScope !== ''
|
|
2061
|
-
? explicitScopeMode === 'additive'
|
|
2062
|
-
? { mode: 'default-deny-or-exact', value: requestedScope }
|
|
2063
|
-
: { mode: 'exact', value: requestedScope }
|
|
2064
|
-
: { mode: 'default-deny' };
|
|
2065
|
-
return loadSearchRows(db, query, limit, tenantId, scopeFilter, includeSuperseded).map(rowToEntry);
|
|
2066
|
-
}
|
|
2067
|
-
/** Rarest-K prompt terms for this connection's FTS index, as a space-joined query string.
|
|
2068
|
-
* Without FTS, returns the first 32 terms, as before rarest-term selection. */
|
|
2069
|
-
export function pickRarestFtsQuery(db, terms, maxTerms = RAREST_TERM_COUNT) {
|
|
2070
|
-
// The LIKE path has no bm25 ranking to bound, so it keeps the pre-rarest 32-term query.
|
|
2071
|
-
if (!isFtsAvailable(db))
|
|
2072
|
-
return terms.slice(0, 32).join(' ');
|
|
2073
|
-
db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS temp.z1_rarest_vocab USING fts5vocab(main, 'memories_fts', 'row')`);
|
|
2074
|
-
// unicode61 splits `journal_mode` into two vocab terms; a term's count is its rarest part's (an upper bound).
|
|
2075
|
-
const partsOf = (t) => t.split(/[^\p{L}\p{N}]+/u).filter(Boolean);
|
|
2076
|
-
const vocab = Array.from(new Set(terms.flatMap(partsOf)));
|
|
2077
|
-
if (vocab.length === 0)
|
|
2078
|
-
return '';
|
|
2079
|
-
// SAFETY: rows' shape matches the two columns named in the SELECT.
|
|
2080
|
-
const rows = db
|
|
2081
|
-
.prepare(`SELECT term, doc FROM temp.z1_rarest_vocab WHERE term IN (${vocab.map(() => '?').join(', ')})`)
|
|
2082
|
-
.all(...vocab);
|
|
2083
|
-
const counts = new Map(rows.map((r) => [r.term, r.doc]));
|
|
2084
|
-
const docCount = (t) => {
|
|
2085
|
-
const parts = partsOf(t);
|
|
2086
|
-
return parts.length === 0 ? 0 : Math.min(...parts.map((x) => counts.get(x) ?? 0));
|
|
2087
|
-
};
|
|
2088
|
-
return rarestPromptTerms(terms, docCount, maxTerms).join(' ');
|
|
2089
|
-
}
|
|
2090
|
-
/**
|
|
2091
|
-
* Rebuild mirrors from SQLite, importing any legacy markdown files not already present.
|
|
2092
|
-
*/
|
|
2093
|
-
export function rebuildIndex(hippoRoot) {
|
|
2094
|
-
const db = openStore(hippoRoot);
|
|
2095
|
-
try {
|
|
2096
|
-
// SAFETY: rows' shape matches the single `id` column selected above.
|
|
2097
|
-
const existingIds = new Set(db.prepare(`SELECT id FROM memories`).all().map((row) => row.id));
|
|
2098
|
-
const legacyEntries = loadLegacyEntriesFromMarkdown(hippoRoot).filter((entry) => !existingIds.has(entry.id));
|
|
2099
|
-
if (legacyEntries.length > 0) {
|
|
2100
|
-
db.exec('BEGIN');
|
|
2101
|
-
try {
|
|
2102
|
-
// AT1 (plan §3, round-3 redesign): same guard-with-per-row-skip as
|
|
2103
|
-
// bootstrapLegacyStore — rebuildIndex is the other channel through
|
|
2104
|
-
// which a stale markdown mirror could resurrect a rejected value.
|
|
2105
|
-
// Refusal audit written INLINE (nothing rolls back on a skip).
|
|
2106
|
-
let rejectedCount = 0;
|
|
2107
|
-
for (const entry of legacyEntries) {
|
|
2108
|
-
// v39: same store-derived origin stamp as bootstrapLegacyStore.
|
|
2109
|
-
const stamped = stampOriginProjectForImport(hippoRoot, entry);
|
|
2110
|
-
try {
|
|
2111
|
-
upsertEntryRow(db, stamped);
|
|
2112
|
-
}
|
|
2113
|
-
catch (err) {
|
|
2114
|
-
if (err instanceof RejectedValueError) {
|
|
2115
|
-
rejectedCount++;
|
|
2116
|
-
audit(db, 'reject_refusal', err.entryId, { digest: err.digest, reason: err.reason }, 'cli', err.tenantId);
|
|
2117
|
-
continue;
|
|
2118
|
-
}
|
|
2119
|
-
throw err;
|
|
2120
|
-
}
|
|
2121
|
-
}
|
|
2122
|
-
if (rejectedCount > 0) {
|
|
2123
|
-
log.warn(`rebuildIndex: skipped ${rejectedCount} rejected value(s) found in legacy mirrors`);
|
|
2124
|
-
}
|
|
2125
|
-
db.exec('COMMIT');
|
|
2126
|
-
}
|
|
2127
|
-
catch (err) {
|
|
2128
|
-
try {
|
|
2129
|
-
db.exec('ROLLBACK');
|
|
2130
|
-
}
|
|
2131
|
-
catch { /* ignore if no active txn */ }
|
|
2132
|
-
throw err;
|
|
2133
|
-
}
|
|
2134
|
-
}
|
|
2135
|
-
syncMirrorFiles(hippoRoot, db);
|
|
2136
|
-
const index = buildIndexFromDb(db);
|
|
2137
|
-
writeIndexMirror(hippoRoot, index);
|
|
2138
|
-
return index;
|
|
2139
|
-
}
|
|
2140
|
-
finally {
|
|
2141
|
-
closeHippoDb(db);
|
|
2142
|
-
}
|
|
2143
|
-
}
|
|
2144
|
-
export function updateStats(hippoRoot, delta) {
|
|
2145
|
-
const db = openStore(hippoRoot);
|
|
2146
|
-
try {
|
|
2147
|
-
// One atomic statement per counter, and only for counters the caller
|
|
2148
|
-
// named: the read-modify-write this replaces both lost increments to a
|
|
2149
|
-
// concurrent writer and stamped stale values over the untouched two.
|
|
2150
|
-
const increments = [
|
|
2151
|
-
['total_remembered', delta.remembered ?? 0],
|
|
2152
|
-
['total_recalled', delta.recalled ?? 0],
|
|
2153
|
-
['total_forgotten', delta.forgotten ?? 0],
|
|
2154
|
-
];
|
|
2155
|
-
for (const [key, amount] of increments) {
|
|
2156
|
-
if (amount === 0)
|
|
2157
|
-
continue;
|
|
2158
|
-
// Both binds are the same string: node:sqlite binds a JS number as REAL,
|
|
2159
|
-
// which would store "1.0" into this TEXT column instead of "1".
|
|
2160
|
-
db.prepare(`
|
|
2161
|
-
INSERT INTO meta(key, value) VALUES(?, ?)
|
|
2162
|
-
ON CONFLICT(key) DO UPDATE SET value = CAST(meta.value AS INTEGER) + CAST(? AS INTEGER)
|
|
2163
|
-
`).run(key, String(amount), String(amount));
|
|
2164
|
-
}
|
|
2165
|
-
writeStatsMirror(hippoRoot, buildStatsFromDb(db));
|
|
2166
|
-
}
|
|
2167
|
-
finally {
|
|
2168
|
-
closeHippoDb(db);
|
|
2169
|
-
}
|
|
2170
|
-
}
|
|
2171
|
-
export function loadStats(hippoRoot) {
|
|
2172
|
-
const db = openStore(hippoRoot);
|
|
2173
|
-
try {
|
|
2174
|
-
return buildStatsFromDb(db);
|
|
2175
|
-
}
|
|
2176
|
-
finally {
|
|
2177
|
-
closeHippoDb(db);
|
|
2178
|
-
}
|
|
2179
|
-
}
|
|
2180
|
-
export function appendConsolidationRun(hippoRoot, run) {
|
|
2181
|
-
const db = openStore(hippoRoot);
|
|
2182
|
-
try {
|
|
2183
|
-
db.prepare(`INSERT INTO consolidation_runs(timestamp, decayed, merged, removed) VALUES (?, ?, ?, ?)`).run(run.timestamp, run.decayed, run.merged, run.removed);
|
|
2184
|
-
pruneConsolidationRuns(db, 50);
|
|
2185
|
-
writeStatsMirror(hippoRoot, buildStatsFromDb(db));
|
|
2186
|
-
}
|
|
2187
|
-
finally {
|
|
2188
|
-
closeHippoDb(db);
|
|
2189
|
-
}
|
|
2190
|
-
}
|
|
2191
|
-
/** Rows a tenant created since the last sleep (runs are host-wide), looking back at most 24 hours. */
|
|
2192
|
-
export function countCreatedSinceLastSleep(hippoRoot, tenantId, now = new Date()) {
|
|
2193
|
-
const db = openStore(hippoRoot);
|
|
2194
|
-
try {
|
|
2195
|
-
const dayAgo = new Date(now.getTime() - 86_400_000).toISOString();
|
|
2196
|
-
const row = db.prepare(`SELECT COUNT(*) AS n FROM memories WHERE tenant_id = ?
|
|
2197
|
-
AND created > MAX(?, COALESCE((SELECT MAX(timestamp) FROM consolidation_runs), ''))`).get(tenantId, dayAgo);
|
|
2198
|
-
return row.n;
|
|
2199
|
-
}
|
|
2200
|
-
finally {
|
|
2201
|
-
closeHippoDb(db);
|
|
2202
|
-
}
|
|
2203
|
-
}
|
|
2204
|
-
/**
|
|
2205
|
-
* Load the session decay context from the store.
|
|
2206
|
-
* Uses consolidation_runs timestamps to compute session intervals.
|
|
2207
|
-
*/
|
|
2208
|
-
export function loadSessionDecayContext(hippoRoot) {
|
|
2209
|
-
const db = openStore(hippoRoot);
|
|
2210
|
-
try {
|
|
2211
|
-
// Get recent consolidation timestamps (last 20)
|
|
2212
|
-
// SAFETY: rows' shape matches the single `timestamp` column above.
|
|
2213
|
-
const rows = db.prepare(`SELECT timestamp FROM consolidation_runs ORDER BY timestamp DESC, id DESC LIMIT 20`).all();
|
|
2214
|
-
const sleepCount = Number(getMeta(db, 'sleep_count', '0')) || rows.length;
|
|
2215
|
-
if (rows.length < 2) {
|
|
2216
|
-
return { sleepCount, avgSessionIntervalDays: 0 };
|
|
2217
|
-
}
|
|
2218
|
-
// Compute average interval between consecutive sessions
|
|
2219
|
-
const timestamps = rows.map((r) => new Date(r.timestamp).getTime()).reverse();
|
|
2220
|
-
let totalInterval = 0;
|
|
2221
|
-
for (let i = 1; i < timestamps.length; i++) {
|
|
2222
|
-
totalInterval += timestamps[i] - timestamps[i - 1];
|
|
2223
|
-
}
|
|
2224
|
-
const avgMs = totalInterval / (timestamps.length - 1);
|
|
2225
|
-
const avgDays = avgMs / (1000 * 60 * 60 * 24);
|
|
2226
|
-
return { sleepCount, avgSessionIntervalDays: Math.max(0, avgDays) };
|
|
2227
|
-
}
|
|
2228
|
-
finally {
|
|
2229
|
-
closeHippoDb(db);
|
|
2230
|
-
}
|
|
2231
|
-
}
|
|
2232
|
-
/**
|
|
2233
|
-
* Increment the sleep counter. Called after each consolidation run.
|
|
2234
|
-
*/
|
|
2235
|
-
export function incrementSleepCount(hippoRoot) {
|
|
2236
|
-
const db = openStore(hippoRoot);
|
|
2237
|
-
try {
|
|
2238
|
-
const current = Number(getMeta(db, 'sleep_count', '0')) || 0;
|
|
2239
|
-
setMeta(db, 'sleep_count', String(current + 1));
|
|
2240
|
-
}
|
|
2241
|
-
finally {
|
|
2242
|
-
closeHippoDb(db);
|
|
2243
|
-
}
|
|
2244
|
-
}
|
|
2245
|
-
export function saveActiveTaskSnapshot(hippoRoot, tenantId, snapshot) {
|
|
2246
|
-
assertTenantId('saveActiveTaskSnapshot', tenantId);
|
|
2247
|
-
const db = openStore(hippoRoot);
|
|
2248
|
-
const now = new Date().toISOString();
|
|
2249
|
-
try {
|
|
2250
|
-
db.exec('BEGIN');
|
|
2251
|
-
db.prepare(`UPDATE task_snapshots SET status = 'superseded', updated_at = ? WHERE status = 'active' AND tenant_id = ?`).run(now, tenantId);
|
|
2252
|
-
const result = db.prepare(`
|
|
2253
|
-
INSERT INTO task_snapshots(task, summary, next_step, status, source, session_id, scope, tenant_id, created_at, updated_at)
|
|
2254
|
-
VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, ?)
|
|
2255
|
-
`).run(redactSecretsStrict(snapshot.task), redactSecretsStrict(snapshot.summary), redactSecretsStrict(snapshot.next_step), snapshot.source ?? 'cli', snapshot.session_id ?? null, snapshot.scope ?? null, tenantId, now, now);
|
|
2256
|
-
db.exec('COMMIT');
|
|
2257
|
-
const id = Number(result.lastInsertRowid ?? 0);
|
|
2258
|
-
// SAFETY: row's shape matches the ten columns named in the SELECT above.
|
|
2259
|
-
const row = db.prepare(`
|
|
2260
|
-
SELECT id, task, summary, next_step, status, source, session_id, scope, created_at, updated_at
|
|
2261
|
-
FROM task_snapshots
|
|
2262
|
-
WHERE id = ?
|
|
2263
|
-
`).get(id);
|
|
2264
|
-
if (!row) {
|
|
2265
|
-
throw new Error('Failed to reload saved active task snapshot');
|
|
2266
|
-
}
|
|
2267
|
-
const loaded = rowToTaskSnapshot(row);
|
|
2268
|
-
writeActiveTaskMirror(hippoRoot, tenantId, loaded);
|
|
2269
|
-
return loaded;
|
|
2270
|
-
}
|
|
2271
|
-
catch (error) {
|
|
2272
|
-
try {
|
|
2273
|
-
db.exec('ROLLBACK');
|
|
2274
|
-
}
|
|
2275
|
-
catch {
|
|
2276
|
-
// Ignore nested rollback failures.
|
|
2277
|
-
}
|
|
2278
|
-
throw error;
|
|
2279
|
-
}
|
|
2280
|
-
finally {
|
|
2281
|
-
closeHippoDb(db);
|
|
2282
|
-
}
|
|
2283
|
-
}
|
|
2284
|
-
export function loadActiveTaskSnapshot(hippoRoot, tenantId) {
|
|
2285
|
-
assertTenantId('loadActiveTaskSnapshot', tenantId);
|
|
2286
|
-
const db = openStore(hippoRoot);
|
|
2287
|
-
try {
|
|
2288
|
-
// SAFETY: row's shape matches the ten columns named in the SELECT above.
|
|
2289
|
-
const row = db.prepare(`
|
|
2290
|
-
SELECT id, task, summary, next_step, status, source, session_id, scope, created_at, updated_at
|
|
2291
|
-
FROM task_snapshots
|
|
2292
|
-
WHERE status = 'active' AND tenant_id = ?
|
|
2293
|
-
ORDER BY updated_at DESC, id DESC
|
|
2294
|
-
LIMIT 1
|
|
2295
|
-
`).get(tenantId);
|
|
2296
|
-
if (!row) {
|
|
2297
|
-
removeActiveTaskMirror(hippoRoot, tenantId);
|
|
2298
|
-
return null;
|
|
2299
|
-
}
|
|
2300
|
-
const loaded = rowToTaskSnapshot(row);
|
|
2301
|
-
writeActiveTaskMirror(hippoRoot, tenantId, loaded);
|
|
2302
|
-
return loaded;
|
|
2303
|
-
}
|
|
2304
|
-
finally {
|
|
2305
|
-
closeHippoDb(db);
|
|
2306
|
-
}
|
|
2307
|
-
}
|
|
2308
|
-
/**
|
|
2309
|
-
* Default freshness bound for AMBIENT active-task-snapshot reads (DF1,
|
|
2310
|
-
* docs/plans/2026-08-23-df1-snapshot-lifecycle.md): 72h, chosen over 48h so
|
|
2311
|
-
* a Friday-evening orphan still offers continuity on Monday morning.
|
|
2312
|
-
* Exported so callers can override via `loadFreshActiveTaskSnapshot`'s
|
|
2313
|
-
* `opts.maxAgeMs`; deliberately no env knob (Simplicity First).
|
|
2314
|
-
*/
|
|
2315
|
-
export const SNAPSHOT_AMBIENT_MAX_AGE_MS = 72 * 60 * 60 * 1000;
|
|
2316
|
-
/** A usable session id: non-null, non-empty string. Named predicate (not an
|
|
2317
|
-
* inline `typeof` check) so the owner-match rule in
|
|
2318
|
-
* `loadFreshActiveTaskSnapshot` states its contract once. */
|
|
2319
|
-
function isNonEmptySessionId(value) {
|
|
2320
|
-
return typeof value === 'string' && value.length > 0;
|
|
2321
|
-
}
|
|
2322
|
-
/**
|
|
2323
|
-
* Bounded read for AMBIENT active-task-snapshot surfaces (UserPromptSubmit
|
|
2324
|
-
* hook context, MCP recall block) — the never-expires fix for DF1. A
|
|
2325
|
-
* snapshot written by `hippo pre-compact` has no death path tied to the
|
|
2326
|
-
* session that owns it, so an orphaned row would otherwise inject into
|
|
2327
|
-
* every prompt of every later session forever. Wraps `loadActiveTaskSnapshot`
|
|
2328
|
-
* (unchanged, still the source of truth for explicit continuity surfaces),
|
|
2329
|
-
* then applies, in order:
|
|
2330
|
-
*
|
|
2331
|
-
* 1. Owner match — ONLY when both `opts.sessionId` and the snapshot's
|
|
2332
|
-
* `session_id` are non-null, non-empty strings and strictly equal
|
|
2333
|
-
* (`===`). Owner reads are unbounded: the session that owns the snapshot
|
|
2334
|
-
* can always see its own working state, regardless of age.
|
|
2335
|
-
* 2. Age check — everything else, including absent-vs-absent ids. A
|
|
2336
|
-
* null/undefined/empty id on EITHER side never counts as an owner match;
|
|
2337
|
-
* it falls through here instead. (`runPreCompact` can legitimately save a
|
|
2338
|
-
* snapshot with `session_id = null`; a null-equals-null "match" would
|
|
2339
|
-
* reopen indefinite ambient injection for exactly those rows.) Returns
|
|
2340
|
-
* the snapshot only when `age(updated_at) <= maxAgeMs` (default
|
|
2341
|
-
* `SNAPSHOT_AMBIENT_MAX_AGE_MS`); otherwise null.
|
|
2342
|
-
*
|
|
2343
|
-
* No SQL change — age derives from the existing `updated_at` column.
|
|
2344
|
-
*/
|
|
2345
|
-
export function loadFreshActiveTaskSnapshot(hippoRoot, tenantId, opts = {}) {
|
|
2346
|
-
const snapshot = loadActiveTaskSnapshot(hippoRoot, tenantId);
|
|
2347
|
-
if (!snapshot)
|
|
2348
|
-
return null;
|
|
2349
|
-
const callerSessionId = opts.sessionId;
|
|
2350
|
-
const isOwnerMatch = isNonEmptySessionId(callerSessionId) &&
|
|
2351
|
-
isNonEmptySessionId(snapshot.session_id) &&
|
|
2352
|
-
callerSessionId === snapshot.session_id;
|
|
2353
|
-
if (isOwnerMatch)
|
|
2354
|
-
return snapshot;
|
|
2355
|
-
const maxAgeMs = opts.maxAgeMs ?? SNAPSHOT_AMBIENT_MAX_AGE_MS;
|
|
2356
|
-
const ageMs = Date.now() - Date.parse(snapshot.updated_at);
|
|
2357
|
-
return ageMs <= maxAgeMs ? snapshot : null;
|
|
2358
|
-
}
|
|
2359
|
-
export function clearActiveTaskSnapshot(hippoRoot, tenantId, clearedStatus = 'cleared') {
|
|
2360
|
-
assertTenantId('clearActiveTaskSnapshot', tenantId);
|
|
2361
|
-
const db = openStore(hippoRoot);
|
|
2362
|
-
const now = new Date().toISOString();
|
|
2363
|
-
try {
|
|
2364
|
-
// SAFETY: active's shape matches the single `id` column selected above.
|
|
2365
|
-
const active = db.prepare(`SELECT id FROM task_snapshots WHERE status = 'active' AND tenant_id = ? ORDER BY updated_at DESC, id DESC LIMIT 1`).get(tenantId);
|
|
2366
|
-
if (!active?.id) {
|
|
2367
|
-
removeActiveTaskMirror(hippoRoot, tenantId);
|
|
2368
|
-
return false;
|
|
2369
|
-
}
|
|
2370
|
-
db.prepare(`UPDATE task_snapshots SET status = ?, updated_at = ? WHERE id = ? AND tenant_id = ?`).run(clearedStatus, now, active.id, tenantId);
|
|
2371
|
-
removeActiveTaskMirror(hippoRoot, tenantId);
|
|
2372
|
-
return true;
|
|
2373
|
-
}
|
|
2374
|
-
finally {
|
|
2375
|
-
closeHippoDb(db);
|
|
2376
|
-
}
|
|
2377
|
-
}
|
|
2378
|
-
/**
|
|
2379
|
-
* Close the `active` task snapshot(s) owned by `sessionId`, for the T3
|
|
2380
|
-
* session-end death path (DF1, docs/plans/2026-08-23-df1-snapshot-lifecycle.md).
|
|
2381
|
-
* Only one `active` row exists per tenant in practice (supersession happens
|
|
2382
|
-
* at save), but the WHERE clause scopes on `session_id` too — not just
|
|
2383
|
-
* `status='active' AND tenant_id=?` — so an ending session can never close a
|
|
2384
|
-
* different, newer session's active snapshot. Returns the number of rows
|
|
2385
|
-
* closed (0 when no active row is owned by `sessionId`).
|
|
2386
|
-
*/
|
|
2387
|
-
export function closeTaskSnapshotsForSession(hippoRoot, tenantId, sessionId, status = 'session-ended') {
|
|
2388
|
-
assertTenantId('closeTaskSnapshotsForSession', tenantId);
|
|
2389
|
-
const db = openStore(hippoRoot);
|
|
2390
|
-
const now = new Date().toISOString();
|
|
2391
|
-
try {
|
|
2392
|
-
const result = db.prepare(`UPDATE task_snapshots SET status = ?, updated_at = ? WHERE status = 'active' AND tenant_id = ? AND session_id = ?`).run(status, now, tenantId, sessionId);
|
|
2393
|
-
return Number(result.changes ?? 0);
|
|
2394
|
-
}
|
|
2395
|
-
finally {
|
|
2396
|
-
closeHippoDb(db);
|
|
2397
|
-
}
|
|
2398
|
-
}
|
|
2399
|
-
export function appendSessionEvent(hippoRoot, tenantId, event) {
|
|
2400
|
-
assertTenantId('appendSessionEvent', tenantId);
|
|
2401
|
-
const db = openStore(hippoRoot);
|
|
2402
|
-
const now = new Date().toISOString();
|
|
2403
|
-
// v1.2: scope is wired through. Default-deny in api.recall + cmdRecall
|
|
2404
|
-
// continuity reads applies to slack:private:* and 'unknown:legacy' rows.
|
|
2405
|
-
try {
|
|
2406
|
-
const result = db.prepare(`
|
|
2407
|
-
INSERT INTO session_events(session_id, task, event_type, content, source, scope, metadata_json, tenant_id, created_at)
|
|
2408
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2409
|
-
`).run(event.session_id, event.task ?? null, event.event_type, event.content, event.source ?? 'cli', event.scope ?? null, JSON.stringify(event.metadata ?? {}), tenantId, now);
|
|
2410
|
-
const id = Number(result.lastInsertRowid ?? 0);
|
|
2411
|
-
// SAFETY: row's shape matches the nine columns named in the SELECT
|
|
2412
|
-
// above.
|
|
2413
|
-
const row = db.prepare(`
|
|
2414
|
-
SELECT id, session_id, task, event_type, content, source, scope, metadata_json, created_at
|
|
2415
|
-
FROM session_events
|
|
2416
|
-
WHERE id = ?
|
|
2417
|
-
`).get(id);
|
|
2418
|
-
if (!row) {
|
|
2419
|
-
throw new Error('Failed to reload saved session event');
|
|
2420
|
-
}
|
|
2421
|
-
const loaded = rowToSessionEvent(row);
|
|
2422
|
-
// SAFETY: recentRows' shape matches the nine columns named in the
|
|
2423
|
-
// SELECT above.
|
|
2424
|
-
const recentRows = db.prepare(`
|
|
2425
|
-
SELECT id, session_id, task, event_type, content, source, scope, metadata_json, created_at
|
|
2426
|
-
FROM session_events
|
|
2427
|
-
WHERE session_id = ? AND tenant_id = ?
|
|
2428
|
-
ORDER BY created_at DESC, id DESC
|
|
2429
|
-
LIMIT ?
|
|
2430
|
-
`).all(loaded.session_id, tenantId, 20);
|
|
2431
|
-
const recent = recentRows.map(rowToSessionEvent).reverse();
|
|
2432
|
-
writeRecentSessionMirror(hippoRoot, tenantId, recent);
|
|
2433
|
-
return loaded;
|
|
2434
|
-
}
|
|
2435
|
-
finally {
|
|
2436
|
-
closeHippoDb(db);
|
|
2437
|
-
}
|
|
2438
|
-
}
|
|
2439
|
-
export function listSessionEvents(hippoRoot, tenantId, options = {}) {
|
|
2440
|
-
assertTenantId('listSessionEvents', tenantId);
|
|
2441
|
-
const db = openStore(hippoRoot);
|
|
2442
|
-
try {
|
|
2443
|
-
const clauses = ['tenant_id = ?'];
|
|
2444
|
-
const params = [tenantId];
|
|
2445
|
-
if (options.session_id) {
|
|
2446
|
-
clauses.push('session_id = ?');
|
|
2447
|
-
params.push(options.session_id);
|
|
2448
|
-
}
|
|
2449
|
-
if (options.task) {
|
|
2450
|
-
clauses.push('task = ?');
|
|
2451
|
-
params.push(options.task);
|
|
2452
|
-
}
|
|
2453
|
-
const limit = Math.max(1, Math.trunc(options.limit ?? 8));
|
|
2454
|
-
params.push(limit);
|
|
2455
|
-
const where = `WHERE ${clauses.join(' AND ')}`;
|
|
2456
|
-
// SAFETY: rows' shape matches the nine columns named in the SELECT
|
|
2457
|
-
// above.
|
|
2458
|
-
const rows = db.prepare(`
|
|
2459
|
-
SELECT id, session_id, task, event_type, content, source, scope, metadata_json, created_at
|
|
2460
|
-
FROM session_events
|
|
2461
|
-
${where}
|
|
2462
|
-
ORDER BY created_at DESC, id DESC
|
|
2463
|
-
LIMIT ?
|
|
2464
|
-
`).all(...params);
|
|
2465
|
-
return rows.map(rowToSessionEvent).reverse();
|
|
2466
|
-
}
|
|
2467
|
-
finally {
|
|
2468
|
-
closeHippoDb(db);
|
|
2469
|
-
}
|
|
2470
|
-
}
|
|
2471
|
-
/**
|
|
2472
|
-
* Return session_ids with a `session_complete` event newer than `sinceMs`.
|
|
2473
|
-
* Used by the sleep auto-promotion pass to bound scanning to a fixed window.
|
|
2474
|
-
*/
|
|
2475
|
-
export function findPromotableSessions(hippoRoot, tenantId, sinceMs) {
|
|
2476
|
-
assertTenantId('findPromotableSessions', tenantId);
|
|
2477
|
-
const db = openStore(hippoRoot);
|
|
2478
|
-
try {
|
|
2479
|
-
// SAFETY: rows' shape matches the single `session_id` column selected
|
|
2480
|
-
// above.
|
|
2481
|
-
const rows = db.prepare(`
|
|
2482
|
-
SELECT DISTINCT session_id FROM session_events
|
|
2483
|
-
WHERE event_type = 'session_complete' AND created_at >= ? AND tenant_id = ?
|
|
2484
|
-
`).all(new Date(sinceMs).toISOString(), tenantId);
|
|
2485
|
-
return rows;
|
|
2486
|
-
}
|
|
2487
|
-
finally {
|
|
2488
|
-
closeHippoDb(db);
|
|
2489
|
-
}
|
|
2490
|
-
}
|
|
2491
|
-
/**
|
|
2492
|
-
* Idempotency guard — true if a trace-layer memory with this source_session_id
|
|
2493
|
-
* already exists.
|
|
2494
|
-
*/
|
|
2495
|
-
export function traceExistsForSession(hippoRoot, tenantId, session_id) {
|
|
2496
|
-
assertTenantId('traceExistsForSession', tenantId);
|
|
2497
|
-
const db = openStore(hippoRoot);
|
|
2498
|
-
try {
|
|
2499
|
-
const row = db.prepare(`
|
|
2500
|
-
SELECT 1 FROM memories
|
|
2501
|
-
WHERE source_session_id = ? AND layer = 'trace' AND tenant_id = ?
|
|
2502
|
-
LIMIT 1
|
|
2503
|
-
`).get(session_id, tenantId);
|
|
2504
|
-
return !!row;
|
|
2505
|
-
}
|
|
2506
|
-
finally {
|
|
2507
|
-
closeHippoDb(db);
|
|
2508
|
-
}
|
|
2509
|
-
}
|
|
2510
|
-
export function listMemoryConflicts(hippoRoot, status = 'open', tenantId) {
|
|
2511
|
-
const db = openStore(hippoRoot);
|
|
2512
|
-
try {
|
|
2513
|
-
// v0.28 — '*' is a sentinel meaning "no status filter, return all rows".
|
|
2514
|
-
// Pre-v0.28 callers (cli/mcp/dashboard) always passed 'open' or default,
|
|
2515
|
-
// so this sentinel is purely additive. The 4 SQL branches below cover
|
|
2516
|
-
// {tenanted | unscoped} × {all-statuses | specific-status}.
|
|
2517
|
-
const allStatuses = status === '*';
|
|
2518
|
-
let rows;
|
|
2519
|
-
if (tenantId !== undefined) {
|
|
2520
|
-
// Tenanted query — JOIN to memories on both conflict members and require
|
|
2521
|
-
// each in-tenant, so neither a normal cross-tenant pair nor a stale
|
|
2522
|
-
// pre-fix row surfaces (consistent with resolveConflict).
|
|
2523
|
-
// SAFETY: both branches select the same eight mc.* columns (aliased
|
|
2524
|
-
// to MemoryConflictRow's field names) from memory_conflicts.
|
|
2525
|
-
rows = allStatuses
|
|
2526
|
-
? db.prepare(`
|
|
2527
|
-
SELECT mc.id, mc.memory_a_id, mc.memory_b_id, mc.reason, mc.score,
|
|
2528
|
-
mc.status, mc.detected_at, mc.updated_at
|
|
2529
|
-
FROM memory_conflicts mc
|
|
2530
|
-
JOIN memories ma ON ma.id = mc.memory_a_id
|
|
2531
|
-
JOIN memories mb ON mb.id = mc.memory_b_id
|
|
2532
|
-
WHERE ma.tenant_id = ? AND mb.tenant_id = ?
|
|
2533
|
-
ORDER BY mc.updated_at DESC, mc.id DESC
|
|
2534
|
-
`).all(tenantId, tenantId)
|
|
2535
|
-
: db.prepare(`
|
|
2536
|
-
SELECT mc.id, mc.memory_a_id, mc.memory_b_id, mc.reason, mc.score,
|
|
2537
|
-
mc.status, mc.detected_at, mc.updated_at
|
|
2538
|
-
FROM memory_conflicts mc
|
|
2539
|
-
JOIN memories ma ON ma.id = mc.memory_a_id
|
|
2540
|
-
JOIN memories mb ON mb.id = mc.memory_b_id
|
|
2541
|
-
WHERE mc.status = ? AND ma.tenant_id = ? AND mb.tenant_id = ?
|
|
2542
|
-
ORDER BY mc.updated_at DESC, mc.id DESC
|
|
2543
|
-
`).all(status, tenantId, tenantId);
|
|
2544
|
-
}
|
|
2545
|
-
else {
|
|
2546
|
-
// Unscoped query — legacy direct-mode (CLI, tests, consolidate).
|
|
2547
|
-
// SAFETY: both branches select the same eight columns matching
|
|
2548
|
-
// MemoryConflictRow's field set.
|
|
2549
|
-
rows = allStatuses
|
|
2550
|
-
? db.prepare(`
|
|
2551
|
-
SELECT id, memory_a_id, memory_b_id, reason, score, status, detected_at, updated_at
|
|
2552
|
-
FROM memory_conflicts
|
|
2553
|
-
ORDER BY updated_at DESC, id DESC
|
|
2554
|
-
`).all()
|
|
2555
|
-
: db.prepare(`
|
|
2556
|
-
SELECT id, memory_a_id, memory_b_id, reason, score, status, detected_at, updated_at
|
|
2557
|
-
FROM memory_conflicts
|
|
2558
|
-
WHERE status = ?
|
|
2559
|
-
ORDER BY updated_at DESC, id DESC
|
|
2560
|
-
`).all(status);
|
|
2561
|
-
}
|
|
2562
|
-
return rows.map(rowToMemoryConflict);
|
|
2563
|
-
}
|
|
2564
|
-
finally {
|
|
2565
|
-
closeHippoDb(db);
|
|
2566
|
-
}
|
|
2567
|
-
}
|
|
2568
|
-
export function replaceDetectedConflicts(hippoRoot, detected, detectedAt = new Date().toISOString()) {
|
|
2569
|
-
const db = openStore(hippoRoot);
|
|
2570
|
-
try {
|
|
2571
|
-
db.exec('BEGIN');
|
|
2572
|
-
// Tenant guard (E2): a conflict is meaningful only within one tenant.
|
|
2573
|
-
// Build id -> tenant_id once and skip cross-tenant pairs both when
|
|
2574
|
-
// inserting rows and when rebuilding conflicts_with_json, so a stale
|
|
2575
|
-
// cross-tenant row can neither persist nor leak a foreign id.
|
|
2576
|
-
const tenantById = new Map();
|
|
2577
|
-
// SAFETY: rows' shape matches the two columns named in the SELECT below.
|
|
2578
|
-
for (const r of db.prepare(`SELECT id, tenant_id FROM memories`).all()) {
|
|
2579
|
-
tenantById.set(r.id, r.tenant_id);
|
|
2580
|
-
}
|
|
2581
|
-
const sameTenant = (a, b) => {
|
|
2582
|
-
const ta = tenantById.get(a);
|
|
2583
|
-
const tb = tenantById.get(b);
|
|
2584
|
-
return ta !== undefined && tb !== undefined && ta === tb;
|
|
2585
|
-
};
|
|
2586
|
-
const canonicalDetected = detected.map((conflict) => ({
|
|
2587
|
-
...canonicalConflictPair(conflict.memory_a_id, conflict.memory_b_id),
|
|
2588
|
-
reason: conflict.reason,
|
|
2589
|
-
score: conflict.score,
|
|
2590
|
-
}));
|
|
2591
|
-
const detectedKeys = new Set(canonicalDetected.map((conflict) => `${conflict.memory_a_id}::${conflict.memory_b_id}`));
|
|
2592
|
-
// SAFETY: openRows' shape matches the eight columns named in the SELECT
|
|
2593
|
-
// above.
|
|
2594
|
-
const openRows = db.prepare(`
|
|
2595
|
-
SELECT id, memory_a_id, memory_b_id, reason, score, status, detected_at, updated_at
|
|
2596
|
-
FROM memory_conflicts
|
|
2597
|
-
WHERE status = 'open'
|
|
2598
|
-
`).all();
|
|
2599
|
-
for (const row of openRows) {
|
|
2600
|
-
const key = `${row.memory_a_id}::${row.memory_b_id}`;
|
|
2601
|
-
const stale = !detectedKeys.has(key);
|
|
2602
|
-
// v1.11.0 residue: auto-resolve any open cross-tenant row. The insert
|
|
2603
|
-
// loop below (line 2089) and the refMap rebuild (line 2117) skip
|
|
2604
|
-
// cross-tenant pairs, but the resolve-stale loop previously left
|
|
2605
|
-
// re-detected cross-tenant rows lingering status='open'. The
|
|
2606
|
-
// sameTenant() helper is already built one block up; no extra query.
|
|
2607
|
-
const crossTenant = !sameTenant(row.memory_a_id, row.memory_b_id);
|
|
2608
|
-
if (stale || crossTenant) {
|
|
2609
|
-
db.prepare(`UPDATE memory_conflicts SET status = 'resolved', updated_at = ? WHERE id = ?`).run(detectedAt, row.id);
|
|
2610
|
-
}
|
|
2611
|
-
}
|
|
2612
|
-
for (const conflict of canonicalDetected) {
|
|
2613
|
-
// Skip cross-tenant pairs — never persist a conflict spanning tenants.
|
|
2614
|
-
if (!sameTenant(conflict.memory_a_id, conflict.memory_b_id))
|
|
2615
|
-
continue;
|
|
2616
|
-
db.prepare(`
|
|
2617
|
-
INSERT INTO memory_conflicts(memory_a_id, memory_b_id, reason, score, status, detected_at, updated_at)
|
|
2618
|
-
VALUES (?, ?, ?, ?, 'open', ?, ?)
|
|
2619
|
-
ON CONFLICT(memory_a_id, memory_b_id) DO UPDATE SET
|
|
2620
|
-
reason = excluded.reason,
|
|
2621
|
-
score = excluded.score,
|
|
2622
|
-
status = 'open',
|
|
2623
|
-
updated_at = excluded.updated_at
|
|
2624
|
-
`).run(conflict.memory_a_id, conflict.memory_b_id, conflict.reason, conflict.score, detectedAt, detectedAt);
|
|
2625
|
-
}
|
|
2626
|
-
// SAFETY: openConflicts' shape matches the two columns named above.
|
|
2627
|
-
const openConflicts = db.prepare(`
|
|
2628
|
-
SELECT memory_a_id, memory_b_id
|
|
2629
|
-
FROM memory_conflicts
|
|
2630
|
-
WHERE status = 'open'
|
|
2631
|
-
`).all();
|
|
2632
|
-
const refMap = new Map();
|
|
2633
|
-
for (const row of openConflicts) {
|
|
2634
|
-
// Skip cross-tenant pairs so a stale row never seeds a foreign id
|
|
2635
|
-
// into conflicts_with_json.
|
|
2636
|
-
if (!sameTenant(row.memory_a_id, row.memory_b_id))
|
|
2637
|
-
continue;
|
|
2638
|
-
if (!refMap.has(row.memory_a_id))
|
|
2639
|
-
refMap.set(row.memory_a_id, new Set());
|
|
2640
|
-
if (!refMap.has(row.memory_b_id))
|
|
2641
|
-
refMap.set(row.memory_b_id, new Set());
|
|
2642
|
-
refMap.get(row.memory_a_id).add(row.memory_b_id);
|
|
2643
|
-
refMap.get(row.memory_b_id).add(row.memory_a_id);
|
|
2644
|
-
}
|
|
2645
|
-
// SAFETY: memoryRows' shape matches the single `id` column selected
|
|
2646
|
-
// above.
|
|
2647
|
-
const memoryRows = db.prepare(`SELECT id FROM memories`).all();
|
|
2648
|
-
for (const memory of memoryRows) {
|
|
2649
|
-
const refs = Array.from(refMap.get(memory.id) ?? []).sort();
|
|
2650
|
-
db.prepare(`UPDATE memories SET conflicts_with_json = ?, updated_at = datetime('now') WHERE id = ?`).run(JSON.stringify(refs), memory.id);
|
|
2651
|
-
}
|
|
2652
|
-
db.exec('COMMIT');
|
|
2653
|
-
syncMirrorFiles(hippoRoot, db);
|
|
2654
|
-
}
|
|
2655
|
-
catch (error) {
|
|
2656
|
-
try {
|
|
2657
|
-
db.exec('ROLLBACK');
|
|
2658
|
-
}
|
|
2659
|
-
catch {
|
|
2660
|
-
// Ignore nested rollback failures.
|
|
2661
|
-
}
|
|
2662
|
-
throw error;
|
|
2663
|
-
}
|
|
2664
|
-
finally {
|
|
2665
|
-
closeHippoDb(db);
|
|
2666
|
-
}
|
|
2667
|
-
}
|
|
2668
|
-
/**
|
|
2669
|
-
* Resolve a conflict by keeping one memory and weakening the other.
|
|
2670
|
-
* Sets conflict status to 'resolved' and halves the loser's half-life.
|
|
2671
|
-
* If --forget is used, the loser is removed entirely (kind-aware: raw rows
|
|
2672
|
-
* are archived via archiveRawMemory, others deleted via deleteEntryCore —
|
|
2673
|
-
* AT1 fix for the pre-existing crash where a raw loser aborted the whole
|
|
2674
|
-
* resolve transaction against the append-only trigger). `opts.rejectLoserValue`
|
|
2675
|
-
* additionally tombstones the loser's normalized digest so it cannot be
|
|
2676
|
-
* re-asserted later.
|
|
2677
|
-
*
|
|
2678
|
-
* Every resolution path (weaken / forget / reject) emits a `conflict_resolve`
|
|
2679
|
-
* audit row (AT1 — previously resolveConflict wrote zero audit rows on any path).
|
|
2680
|
-
*
|
|
2681
|
-
* Returns the resolved conflict, or null if not found.
|
|
2682
|
-
*/
|
|
2683
|
-
export function resolveConflict(hippoRoot, conflictId, keepId, forgetLoser = false, tenantId, opts) {
|
|
2684
|
-
const db = openStore(hippoRoot);
|
|
2685
|
-
// When tenantId is set, the conflict lookup requires BOTH members in-tenant
|
|
2686
|
-
// and every memories mutation carries AND tenant_id = ?. A cross-tenant probe
|
|
2687
|
-
// then returns null, indistinguishable from a bad id. Omitted tenantId =
|
|
2688
|
-
// legacy unscoped behaviour (CLI direct mode, tests, consolidate.ts).
|
|
2689
|
-
const memScope = tenantId !== undefined ? ' AND tenant_id = ?' : '';
|
|
2690
|
-
const memArgs = tenantId !== undefined ? [tenantId] : [];
|
|
2691
|
-
try {
|
|
2692
|
-
// SAFETY: both branches select the same eight columns (aliased in the
|
|
2693
|
-
// tenanted branch) matching MemoryConflictRow's field set.
|
|
2694
|
-
const row = (tenantId !== undefined
|
|
2695
|
-
? db.prepare(`
|
|
2696
|
-
SELECT mc.id, mc.memory_a_id, mc.memory_b_id, mc.reason, mc.score,
|
|
2697
|
-
mc.status, mc.detected_at, mc.updated_at
|
|
2698
|
-
FROM memory_conflicts mc
|
|
2699
|
-
JOIN memories ma ON ma.id = mc.memory_a_id
|
|
2700
|
-
JOIN memories mb ON mb.id = mc.memory_b_id
|
|
2701
|
-
WHERE mc.id = ? AND ma.tenant_id = ? AND mb.tenant_id = ?
|
|
2702
|
-
`).get(conflictId, tenantId, tenantId)
|
|
2703
|
-
: db.prepare(`
|
|
2704
|
-
SELECT id, memory_a_id, memory_b_id, reason, score, status, detected_at, updated_at
|
|
2705
|
-
FROM memory_conflicts WHERE id = ?
|
|
2706
|
-
`).get(conflictId));
|
|
2707
|
-
if (!row)
|
|
2708
|
-
return null;
|
|
2709
|
-
const conflict = rowToMemoryConflict(row);
|
|
2710
|
-
if (conflict.status !== 'open')
|
|
2711
|
-
return null;
|
|
2712
|
-
const loserId = keepId === conflict.memory_a_id
|
|
2713
|
-
? conflict.memory_b_id
|
|
2714
|
-
: keepId === conflict.memory_b_id
|
|
2715
|
-
? conflict.memory_a_id
|
|
2716
|
-
: null;
|
|
2717
|
-
if (!loserId)
|
|
2718
|
-
return null;
|
|
2719
|
-
db.exec('BEGIN');
|
|
2720
|
-
// Mark conflict as resolved
|
|
2721
|
-
db.prepare(`UPDATE memory_conflicts SET status = 'resolved', updated_at = datetime('now') WHERE id = ?`)
|
|
2722
|
-
.run(conflictId);
|
|
2723
|
-
// AT1 (plan §5): removal (forgetLoser OR rejectLoserValue — a tombstoned
|
|
2724
|
-
// value cannot be left live) is now kind-aware. The old bare
|
|
2725
|
-
// `DELETE FROM memories WHERE id = ?` aborted the whole transaction when
|
|
2726
|
-
// the loser was kind='raw' (append-only trigger fires); route through
|
|
2727
|
-
// the same helpers the reject verb uses (both db-scoped, both compose
|
|
2728
|
-
// inside this BEGIN/COMMIT). loserRemoved / loserWasRaw drive both the
|
|
2729
|
-
// conflicts_with_json skip below and the post-commit mirror purge.
|
|
2730
|
-
let loserRemoved = false;
|
|
2731
|
-
let loserWasRaw = false;
|
|
2732
|
-
let rejectedDigest;
|
|
2733
|
-
// AT1 P1 fix (codex): same-tenant duplicates of the loser's content that
|
|
2734
|
-
// rejectLoserValue also removes (see below) — separate from loserId so
|
|
2735
|
-
// the audit + post-commit mirror purge can cover ALL of them, not just
|
|
2736
|
-
// loserId.
|
|
2737
|
-
const extraRemovedIds = [];
|
|
2738
|
-
const extraRemovedRawIds = [];
|
|
2739
|
-
const removeLoser = forgetLoser || opts?.rejectLoserValue === true;
|
|
2740
|
-
if (removeLoser) {
|
|
2741
|
-
// SAFETY: loserRow's shape matches the three columns named in the
|
|
2742
|
-
// SELECT above.
|
|
2743
|
-
const loserRow = db
|
|
2744
|
-
.prepare(`SELECT kind, content, tenant_id FROM memories WHERE id = ?${memScope}`)
|
|
2745
|
-
.get(loserId, ...memArgs);
|
|
2746
|
-
if (loserRow) {
|
|
2747
|
-
const actor = opts?.rejectedBy ?? 'cli';
|
|
2748
|
-
const reason = opts?.reason ?? `resolveConflict ${conflictId}: kept ${keepId}`;
|
|
2749
|
-
if (opts?.rejectLoserValue) {
|
|
2750
|
-
rejectedDigest = rejectionDigest(loserRow.content);
|
|
2751
|
-
insertRejectedValue(db, {
|
|
2752
|
-
tenantId: loserRow.tenant_id ?? 'default',
|
|
2753
|
-
digest: rejectedDigest,
|
|
2754
|
-
reason,
|
|
2755
|
-
rejectedBy: actor,
|
|
2756
|
-
rejectedAt: new Date().toISOString(),
|
|
2757
|
-
sourceMemoryId: loserId,
|
|
2758
|
-
normalizedChars: normalizeValueForRejection(loserRow.content).length,
|
|
2759
|
-
});
|
|
2760
|
-
// AT1 P1 fix (codex): reject-flow.ts's `rejectValue` removes ALL
|
|
2761
|
-
// live same-tenant rows whose normalized digest matches, not just
|
|
2762
|
-
// the one id passed — but this branch only ever removed loserId,
|
|
2763
|
-
// leaving same-TENANT duplicates live while their shared content
|
|
2764
|
-
// was tombstoned. Same O(N) scan pattern as reject-flow.ts (human-
|
|
2765
|
-
// triggered command, tenant's row count is human-scale). CRITICAL
|
|
2766
|
-
// BOUNDARY: tenant-scoped ONLY — tombstones are tenant-scoped by
|
|
2767
|
-
// design, so a same-content row in ANOTHER tenant is legitimately
|
|
2768
|
-
// live and must NOT be touched here. `keepId` is excluded even if
|
|
2769
|
-
// its content coincidentally matches: the human explicitly chose
|
|
2770
|
-
// to keep it in this same resolution, and this branch must not
|
|
2771
|
-
// undo that choice in the same transaction.
|
|
2772
|
-
const loserTenantId = loserRow.tenant_id ?? 'default';
|
|
2773
|
-
// SAFETY: dupRows' shape matches the three columns named in the
|
|
2774
|
-
// SELECT above.
|
|
2775
|
-
const dupRows = db
|
|
2776
|
-
.prepare(`SELECT id, kind, content FROM memories WHERE tenant_id = ? AND id != ? AND id != ?`)
|
|
2777
|
-
.all(loserTenantId, loserId, keepId);
|
|
2778
|
-
for (const dup of dupRows) {
|
|
2779
|
-
if (rejectionDigest(dup.content) !== rejectedDigest)
|
|
2780
|
-
continue;
|
|
2781
|
-
if (dup.kind === 'raw') {
|
|
2782
|
-
archiveRawMemory(db, dup.id, { reason, who: actor });
|
|
2783
|
-
extraRemovedRawIds.push(dup.id);
|
|
2784
|
-
}
|
|
2785
|
-
else {
|
|
2786
|
-
deleteEntryCore(db, dup.id, { actor, suppressForgetAudit: true });
|
|
2787
|
-
}
|
|
2788
|
-
extraRemovedIds.push(dup.id);
|
|
2789
|
-
}
|
|
2790
|
-
}
|
|
2791
|
-
if (loserRow.kind === 'raw') {
|
|
2792
|
-
archiveRawMemory(db, loserId, { reason, who: actor });
|
|
2793
|
-
loserWasRaw = true;
|
|
2794
|
-
}
|
|
2795
|
-
else {
|
|
2796
|
-
deleteEntryCore(db, loserId, { actor, suppressForgetAudit: true });
|
|
2797
|
-
}
|
|
2798
|
-
loserRemoved = true;
|
|
2799
|
-
}
|
|
2800
|
-
// loserRow undefined = tenant-scope mismatch (or already gone); matches
|
|
2801
|
-
// the old tenant-scoped DELETE's silent 0-rows-affected behavior.
|
|
2802
|
-
}
|
|
2803
|
-
else {
|
|
2804
|
-
// Halve the loser's half-life (weakens it over time)
|
|
2805
|
-
db.prepare(`UPDATE memories SET half_life_days = MAX(1, half_life_days / 2), updated_at = datetime('now') WHERE id = ?${memScope}`)
|
|
2806
|
-
.run(loserId, ...memArgs);
|
|
2807
|
-
}
|
|
2808
|
-
// Clean up conflicts_with references
|
|
2809
|
-
// SAFETY: keepRow's shape matches the single `conflicts_with_json`
|
|
2810
|
-
// column selected above.
|
|
2811
|
-
const keepRow = db.prepare(`SELECT conflicts_with_json FROM memories WHERE id = ?${memScope}`).get(keepId, ...memArgs);
|
|
2812
|
-
if (keepRow) {
|
|
2813
|
-
const refs = JSON.parse(keepRow.conflicts_with_json || '[]');
|
|
2814
|
-
const cleaned = refs.filter((r) => r !== loserId);
|
|
2815
|
-
db.prepare(`UPDATE memories SET conflicts_with_json = ?, updated_at = datetime('now') WHERE id = ?${memScope}`)
|
|
2816
|
-
.run(JSON.stringify(cleaned), keepId, ...memArgs);
|
|
2817
|
-
}
|
|
2818
|
-
if (!loserRemoved) {
|
|
2819
|
-
// SAFETY: loserRow's shape matches the single `conflicts_with_json`
|
|
2820
|
-
// column named in the SELECT below.
|
|
2821
|
-
const loserRow = db.prepare(`SELECT conflicts_with_json FROM memories WHERE id = ?${memScope}`).get(loserId, ...memArgs);
|
|
2822
|
-
if (loserRow) {
|
|
2823
|
-
const refs = JSON.parse(loserRow.conflicts_with_json || '[]');
|
|
2824
|
-
const cleaned = refs.filter((r) => r !== keepId);
|
|
2825
|
-
db.prepare(`UPDATE memories SET conflicts_with_json = ?, updated_at = datetime('now') WHERE id = ?${memScope}`)
|
|
2826
|
-
.run(JSON.stringify(cleaned), loserId, ...memArgs);
|
|
2827
|
-
}
|
|
2828
|
-
}
|
|
2829
|
-
// AT1: the missing audit (plan §5 — resolveConflict wrote ZERO audit_log
|
|
2830
|
-
// rows on any path before this). Every path — weaken, forget, reject —
|
|
2831
|
-
// lands exactly one conflict_resolve row.
|
|
2832
|
-
const conflictResolveMeta = {
|
|
2833
|
-
conflictId,
|
|
2834
|
-
keepId,
|
|
2835
|
-
loserId,
|
|
2836
|
-
disposition: loserRemoved ? (loserWasRaw ? 'archived_raw' : 'deleted') : 'weakened',
|
|
2837
|
-
rejected: Boolean(opts?.rejectLoserValue),
|
|
2838
|
-
// AT1 P1 fix: every row this call removed, not just loserId — the
|
|
2839
|
-
// same-tenant duplicate sweep above (extraRemovedIds) needs an
|
|
2840
|
-
// audit trail too.
|
|
2841
|
-
removedIds: loserRemoved ? [loserId, ...extraRemovedIds] : [],
|
|
2842
|
-
};
|
|
2843
|
-
// Assigned only when present so the serialized audit payload keeps
|
|
2844
|
-
// omitting the key, exactly as the pre-migration object literal did.
|
|
2845
|
-
if (rejectedDigest !== undefined)
|
|
2846
|
-
conflictResolveMeta.rejectedDigest = rejectedDigest;
|
|
2847
|
-
// Fresh spread literal: ConflictResolveMeta is a closed interface (no
|
|
2848
|
-
// index signature) and isn't directly assignable to audit()'s
|
|
2849
|
-
// Record<string, JsonValue> metadata param; a spread into a fresh
|
|
2850
|
-
// object literal satisfies it without widening the declared type above.
|
|
2851
|
-
audit(db, 'conflict_resolve', keepId, { ...conflictResolveMeta }, opts?.rejectedBy ?? 'cli', tenantId);
|
|
2852
|
-
db.exec('COMMIT');
|
|
2853
|
-
syncMirrorFiles(hippoRoot, db);
|
|
2854
|
-
// AT1 P1b fix: mirror purge + reaper stamp for EVERY removed loser, not
|
|
2855
|
-
// just the rejectLoserValue path. Pre-AT1, the plain forgetLoser path on
|
|
2856
|
-
// a raw loser crashed outright (bare DELETE FROM memories hit the
|
|
2857
|
-
// append-only trigger) — there is no legacy "successful forget, no
|
|
2858
|
-
// purge" behavior to preserve for that case. Post-AT1's kind-aware
|
|
2859
|
-
// removal (archiveRawMemory / deleteEntryCore above) makes plain
|
|
2860
|
-
// --forget succeed on every kind, but until this fix the mirror was
|
|
2861
|
-
// only purged when rejectLoserValue was ALSO set: a plain raw --forget
|
|
2862
|
-
// left its markdown mirror orphaned (the reaper still catches it
|
|
2863
|
-
// eventually, since archiveRawMemory's own raw_archive insert leaves
|
|
2864
|
-
// mirror_cleaned_at NULL) and a plain non-raw --forget left its mirror
|
|
2865
|
-
// orphaned FOREVER (no reaper exists for non-raw rows). Same post-commit
|
|
2866
|
-
// purge+reaper pattern as the reject verb (src/reject-flow.ts) and
|
|
2867
|
-
// api.archiveRaw — reusing removeEntryMirrors + raw_archive bookkeeping.
|
|
2868
|
-
if (loserRemoved) {
|
|
2869
|
-
// AT1 P1 fix: loop over loserId AND every same-tenant duplicate the
|
|
2870
|
-
// rejectLoserValue sweep above removed (extraRemovedIds) — previously
|
|
2871
|
-
// only loserId's mirror was purged, leaving duplicate mirrors orphaned
|
|
2872
|
-
// despite their rows being gone.
|
|
2873
|
-
for (const removedId of [loserId, ...extraRemovedIds]) {
|
|
2874
|
-
const isRaw = removedId === loserId ? loserWasRaw : extraRemovedRawIds.includes(removedId);
|
|
2875
|
-
// AT1 fix: purgeMirrorBestEffort retries once, then — for non-raw ids,
|
|
2876
|
-
// which cleanupArchivedMirrors' reaper never scans — reports the
|
|
2877
|
-
// EXPLICIT leftover path(s) instead of the false "will retry via
|
|
2878
|
-
// reaper" claim. See its own doc comment (store.ts, near
|
|
2879
|
-
// removeEntryMirrors) for the full rationale.
|
|
2880
|
-
const mirrorOk = purgeMirrorBestEffort(hippoRoot, removedId, isRaw, 'resolveConflict');
|
|
2881
|
-
if (mirrorOk && isRaw) {
|
|
2882
|
-
db.prepare(`UPDATE raw_archive SET mirror_cleaned_at = ? WHERE memory_id = ?`).run(new Date().toISOString(), removedId);
|
|
2883
|
-
}
|
|
2884
|
-
}
|
|
2885
|
-
}
|
|
2886
|
-
return { conflict: { ...conflict, status: 'resolved' }, loserId };
|
|
2887
|
-
}
|
|
2888
|
-
catch (error) {
|
|
2889
|
-
try {
|
|
2890
|
-
db.exec('ROLLBACK');
|
|
2891
|
-
}
|
|
2892
|
-
catch { /* ignore */ }
|
|
2893
|
-
throw error;
|
|
2894
|
-
}
|
|
2895
|
-
finally {
|
|
2896
|
-
closeHippoDb(db);
|
|
2897
|
-
}
|
|
2898
|
-
}
|
|
2899
|
-
// W1: the nine-column SELECT was cloned four times (plan rule 8); one
|
|
2900
|
-
// definition so a sixth caller can't drift from the other five.
|
|
2901
|
-
/** Column list shared by every session_handoffs SELECT; store-cards.ts reuses it for the card handoff lookup. */
|
|
2902
|
-
export const HANDOFF_COLUMNS = 'id, session_id, repo_root, task_id, summary, next_action, artifacts_json, scope, created_at, constraints_json, evidence_json, outcome, target_runtime, card_id';
|
|
2903
|
-
/**
|
|
2904
|
-
* Save a session handoff record. Returns the persisted handoff.
|
|
2905
|
-
*/
|
|
2906
|
-
export function saveSessionHandoff(hippoRoot, tenantId, handoff) {
|
|
2907
|
-
assertTenantId('saveSessionHandoff', tenantId);
|
|
2908
|
-
const db = openStore(hippoRoot);
|
|
2909
|
-
const now = new Date().toISOString();
|
|
2910
|
-
// v1.2: scope is wired through. Read-side default-deny in api.recall +
|
|
2911
|
-
// cmdRecall continuity excludes slack:private:* and 'unknown:legacy'.
|
|
2912
|
-
try {
|
|
2913
|
-
const result = db.prepare(`
|
|
2914
|
-
INSERT INTO session_handoffs(session_id, repo_root, task_id, summary, next_action, artifacts_json, scope, tenant_id, created_at, constraints_json, evidence_json, outcome, target_runtime, card_id)
|
|
2915
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2916
|
-
`).run(handoff.sessionId, handoff.repoRoot ?? null, handoff.taskId ?? null, handoff.summary, handoff.nextAction ?? null, JSON.stringify(handoff.artifacts ?? []), handoff.scope ?? null, tenantId, now, JSON.stringify(handoff.constraints ?? []), handoff.evidence ? JSON.stringify(handoff.evidence) : null, handoff.outcome ?? null, handoff.targetRuntime ?? null, handoff.cardId ?? null);
|
|
2917
|
-
const id = Number(result.lastInsertRowid ?? 0);
|
|
2918
|
-
// SAFETY: row's shape matches HANDOFF_COLUMNS.
|
|
2919
|
-
const row = db.prepare(`
|
|
2920
|
-
SELECT ${HANDOFF_COLUMNS}
|
|
2921
|
-
FROM session_handoffs
|
|
2922
|
-
WHERE id = ?
|
|
2923
|
-
`).get(id);
|
|
2924
|
-
if (!row) {
|
|
2925
|
-
throw new Error('Failed to reload saved session handoff');
|
|
2926
|
-
}
|
|
2927
|
-
return rowToSessionHandoff(row);
|
|
2928
|
-
}
|
|
2929
|
-
finally {
|
|
2930
|
-
closeHippoDb(db);
|
|
2931
|
-
}
|
|
2932
|
-
}
|
|
2933
|
-
/** Load the most recent handoff, optionally filtered by session ID. */
|
|
2934
|
-
export function loadLatestHandoff(hippoRoot, tenantId, sessionId, opts = {}) {
|
|
2935
|
-
assertTenantId('loadLatestHandoff', tenantId);
|
|
2936
|
-
const db = openStore(hippoRoot);
|
|
2937
|
-
try {
|
|
2938
|
-
const conditions = ['tenant_id = ?'];
|
|
2939
|
-
const params = [tenantId];
|
|
2940
|
-
if (sessionId) {
|
|
2941
|
-
conditions.push('session_id = ?');
|
|
2942
|
-
params.push(sessionId);
|
|
2943
|
-
}
|
|
2944
|
-
if (opts.excludeSessionId) {
|
|
2945
|
-
conditions.push('session_id != ?');
|
|
2946
|
-
params.push(opts.excludeSessionId);
|
|
2947
|
-
}
|
|
2948
|
-
if (opts.unfinishedOnly) {
|
|
2949
|
-
// codex P2: restrict to each session's newest revision first — stampHandoffOutcome
|
|
2950
|
-
// only stamps the newest row, so an older null-outcome revision must not resurrect.
|
|
2951
|
-
conditions.push(`id IN (SELECT MAX(id) FROM session_handoffs WHERE tenant_id = ? GROUP BY session_id)`);
|
|
2952
|
-
params.push(tenantId);
|
|
2953
|
-
conditions.push(`(outcome IS NULL OR outcome IN ('partial','failure'))`);
|
|
2954
|
-
}
|
|
2955
|
-
if (opts.maxAgeMs != null) {
|
|
2956
|
-
conditions.push('created_at >= ?');
|
|
2957
|
-
params.push(new Date(Date.now() - opts.maxAgeMs).toISOString());
|
|
2958
|
-
}
|
|
2959
|
-
if (opts.scopeFilter === 'default-deny') {
|
|
2960
|
-
// codex P2: admit scope before LIMIT 1, else a newer denied row hides an older eligible one.
|
|
2961
|
-
const placeholders = RECALL_DEFAULT_DENY_SCOPES.map(() => '?').join(', ');
|
|
2962
|
-
conditions.push(`(scope IS NULL OR (scope NOT IN (${placeholders}) AND scope NOT LIKE '%:private:%'))`);
|
|
2963
|
-
params.push(...RECALL_DEFAULT_DENY_SCOPES);
|
|
2964
|
-
}
|
|
2965
|
-
// SAFETY: row's shape matches HANDOFF_COLUMNS.
|
|
2966
|
-
const row = db.prepare(`
|
|
2967
|
-
SELECT ${HANDOFF_COLUMNS}
|
|
2968
|
-
FROM session_handoffs
|
|
2969
|
-
WHERE ${conditions.join(' AND ')}
|
|
2970
|
-
ORDER BY created_at DESC, id DESC
|
|
2971
|
-
LIMIT 1
|
|
2972
|
-
`).get(...params);
|
|
2973
|
-
return row ? rowToSessionHandoff(row) : null;
|
|
2974
|
-
}
|
|
2975
|
-
finally {
|
|
2976
|
-
closeHippoDb(db);
|
|
2977
|
-
}
|
|
2978
|
-
}
|
|
2979
|
-
/**
|
|
2980
|
-
* Load a specific handoff by its row ID.
|
|
2981
|
-
*/
|
|
2982
|
-
export function loadHandoffById(hippoRoot, tenantId, id) {
|
|
2983
|
-
assertTenantId('loadHandoffById', tenantId);
|
|
2984
|
-
const db = openStore(hippoRoot);
|
|
2985
|
-
try {
|
|
2986
|
-
// SAFETY: row's shape matches HANDOFF_COLUMNS.
|
|
2987
|
-
const row = db.prepare(`
|
|
2988
|
-
SELECT ${HANDOFF_COLUMNS}
|
|
2989
|
-
FROM session_handoffs
|
|
2990
|
-
WHERE id = ? AND tenant_id = ?
|
|
2991
|
-
`).get(id, tenantId);
|
|
2992
|
-
return row ? rowToSessionHandoff(row) : null;
|
|
2993
|
-
}
|
|
2994
|
-
finally {
|
|
2995
|
-
closeHippoDb(db);
|
|
2996
|
-
}
|
|
2997
|
-
}
|
|
2998
|
-
/** Stamp the outcome on a session's newest handoff, only if it has none yet. Returns rows changed. */
|
|
2999
|
-
export function stampHandoffOutcome(hippoRoot, tenantId, sessionId, outcome) {
|
|
3000
|
-
assertTenantId('stampHandoffOutcome', tenantId);
|
|
3001
|
-
const db = openStore(hippoRoot);
|
|
3002
|
-
try {
|
|
3003
|
-
const result = db.prepare(`
|
|
3004
|
-
UPDATE session_handoffs SET outcome = ?
|
|
3005
|
-
WHERE tenant_id = ? AND session_id = ? AND outcome IS NULL
|
|
3006
|
-
AND id = (
|
|
3007
|
-
SELECT id FROM session_handoffs
|
|
3008
|
-
WHERE tenant_id = ? AND session_id = ?
|
|
3009
|
-
ORDER BY created_at DESC, id DESC LIMIT 1
|
|
3010
|
-
)
|
|
3011
|
-
`).run(outcome, tenantId, sessionId, tenantId, sessionId);
|
|
3012
|
-
return Number(result.changes ?? 0);
|
|
3013
|
-
}
|
|
3014
|
-
finally {
|
|
3015
|
-
closeHippoDb(db);
|
|
3016
|
-
}
|
|
3017
|
-
}
|
|
3018
|
-
/** Auto-write a handoff at session-end (DF1 T3) from the session's active snapshot, else from `derived`, its transcript state.
|
|
3019
|
-
* @param evidence best-effort git state; outcome comes from the newest session_complete event.
|
|
3020
|
-
* @returns null when neither source is the session's, a newer handoff covers the snapshot, or the session's latest handoff was not read off its transcript. */
|
|
3021
|
-
export function writeSessionEndHandoff(hippoRoot, tenantId, sessionId, evidence, derived = null) {
|
|
3022
|
-
assertTenantId('writeSessionEndHandoff', tenantId);
|
|
3023
|
-
const active = loadActiveTaskSnapshot(hippoRoot, tenantId);
|
|
3024
|
-
const existing = loadLatestHandoff(hippoRoot, tenantId, sessionId);
|
|
3025
|
-
let snapshot;
|
|
3026
|
-
let handoffEvidence = evidence;
|
|
3027
|
-
if (active && active.session_id === sessionId) {
|
|
3028
|
-
// Strict '>': a same-millisecond tie must not swallow the session's only write (test 6e).
|
|
3029
|
-
if (existing && existing.updatedAt > active.updated_at)
|
|
3030
|
-
return null;
|
|
3031
|
-
snapshot = active;
|
|
3032
|
-
}
|
|
3033
|
-
else {
|
|
3034
|
-
// Only an earlier transcript read gives way; `hippo handoff create` and unmarked older handoffs keep winning.
|
|
3035
|
-
if (!derived || (existing && existing.evidence?.derivedFrom !== 'transcript'))
|
|
3036
|
-
return null;
|
|
3037
|
-
snapshot = { ...derived, scope: null };
|
|
3038
|
-
handoffEvidence = { ...evidence, derivedFrom: 'transcript' };
|
|
3039
|
-
}
|
|
3040
|
-
const db = openHippoDb(hippoRoot);
|
|
3041
|
-
let outcome = null;
|
|
3042
|
-
try {
|
|
3043
|
-
// SAFETY: row's shape matches the single `content` column below.
|
|
3044
|
-
const completeEvent = db.prepare(`
|
|
3045
|
-
SELECT content FROM session_events
|
|
3046
|
-
WHERE tenant_id = ? AND session_id = ? AND event_type = 'session_complete'
|
|
3047
|
-
ORDER BY created_at DESC, id DESC LIMIT 1
|
|
3048
|
-
`).get(tenantId, sessionId);
|
|
3049
|
-
if (isHandoffOutcome(completeEvent?.content))
|
|
3050
|
-
outcome = completeEvent.content;
|
|
3051
|
-
}
|
|
3052
|
-
finally {
|
|
3053
|
-
closeHippoDb(db);
|
|
3054
|
-
}
|
|
3055
|
-
// codex P2: same-task refresh carries forward envelope fields nobody cleared,
|
|
3056
|
-
// rather than dropping them when the snapshot rewrite has no opinion on them.
|
|
3057
|
-
// codex P1: a scope mismatch must not leak private metadata into an unscoped envelope.
|
|
3058
|
-
const carryForward = existing != null && existing.taskId === snapshot.task
|
|
3059
|
-
&& (existing.scope ?? null) === (snapshot.scope ?? null);
|
|
3060
|
-
return saveSessionHandoff(hippoRoot, tenantId, {
|
|
3061
|
-
version: 1,
|
|
3062
|
-
sessionId,
|
|
3063
|
-
repoRoot: carryForward ? existing.repoRoot : undefined,
|
|
3064
|
-
taskId: snapshot.task,
|
|
3065
|
-
summary: snapshot.summary,
|
|
3066
|
-
nextAction: snapshot.next_step,
|
|
3067
|
-
artifacts: carryForward ? existing.artifacts : [],
|
|
3068
|
-
scope: snapshot.scope,
|
|
3069
|
-
evidence: handoffEvidence,
|
|
3070
|
-
outcome,
|
|
3071
|
-
constraints: carryForward ? existing.constraints : undefined,
|
|
3072
|
-
targetRuntime: carryForward ? existing.targetRuntime : undefined,
|
|
3073
|
-
cardId: carryForward ? existing.cardId : undefined,
|
|
3074
|
-
});
|
|
3075
|
-
}
|
|
3076
|
-
// ---------------------------------------------------------------------------
|
|
3077
|
-
// v0.30 / E1 of DAG live-coupling — dirty-flag helpers for the existing
|
|
3078
|
-
// DAG layer's level-2 summaries.
|
|
3079
|
-
//
|
|
3080
|
-
// Used by E2 (child-write propagation in invalidation.ts / writeEntry /
|
|
3081
|
-
// forgetMemory / archiveRawMemory) to mark a summary dirty when one of its
|
|
3082
|
-
// children changes, and by E3's sleep-cycle rebuildDirtySummaries phase to
|
|
3083
|
-
// enumerate candidates without scanning every memory row.
|
|
3084
|
-
// ---------------------------------------------------------------------------
|
|
3085
|
-
/**
|
|
3086
|
-
* Load summaries flagged dirty for the given tenant. Sorted by latest_at
|
|
3087
|
-
* DESC (NULLS LAST) so E3's rebuild cap (HIPPO_DAG_REBUILD_CAP, default 20)
|
|
3088
|
-
* takes the most-recently-changed summaries first.
|
|
3089
|
-
*
|
|
3090
|
-
* Returns full MemoryEntry shape via MEMORY_SELECT_COLUMNS + rowToEntry
|
|
3091
|
-
* (v28 fields are part of the standard read path).
|
|
3092
|
-
*/
|
|
3093
|
-
export function loadDirtySummaries(hippoRoot, tenantId) {
|
|
3094
|
-
assertTenantId('loadDirtySummaries', tenantId);
|
|
3095
|
-
const db = openStore(hippoRoot);
|
|
3096
|
-
try {
|
|
3097
|
-
// SAFETY: this query selects exactly MEMORY_SELECT_COLUMNS, matching
|
|
3098
|
-
// MemoryRow's field set.
|
|
3099
|
-
const rows = db.prepare(`
|
|
3100
|
-
SELECT ${MEMORY_SELECT_COLUMNS}
|
|
3101
|
-
FROM memories
|
|
3102
|
-
WHERE summary_dirty = 1
|
|
3103
|
-
AND tenant_id = ?
|
|
3104
|
-
AND kind != 'archived'
|
|
3105
|
-
ORDER BY latest_at DESC NULLS LAST, id ASC
|
|
3106
|
-
`).all(tenantId);
|
|
3107
|
-
return rows.map(rowToEntry);
|
|
3108
|
-
}
|
|
3109
|
-
finally {
|
|
3110
|
-
closeHippoDb(db);
|
|
3111
|
-
}
|
|
3112
|
-
}
|
|
3113
|
-
/**
|
|
3114
|
-
* v0.30 / E2 — in-transaction variant of markSummaryDirty. Takes an open
|
|
3115
|
-
* db (caller is responsible for any SAVEPOINT/BEGIN). Used by E2's hook
|
|
3116
|
-
* sites: writeEntryDbOnly, api.supersede CAS, deleteEntry, archiveRawMemory,
|
|
3117
|
-
* batchWriteAndDelete. Each child mutation's dirty-mark is atomic with the
|
|
3118
|
-
* mutation itself (where the mutation IS in a SAVEPOINT/BEGIN — deleteEntry
|
|
3119
|
-
* is the exception, acceptably non-atomic by design).
|
|
3120
|
-
*
|
|
3121
|
-
* EXPORTED (required for cross-module use by api.ts + raw-archive.ts).
|
|
3122
|
-
* Risk of misuse (caller without open tx) is mitigated by the InTx
|
|
3123
|
-
* suffix + the DatabaseSyncLike typed param. Public surface for end users
|
|
3124
|
-
* stays at the markSummaryDirty (own-connection) variant.
|
|
3125
|
-
*
|
|
3126
|
-
* Same idempotency contract: 0->1 transition only, audit row only on
|
|
3127
|
-
* transition, no-op on non-summary / archived / unknown id / cross-tenant.
|
|
3128
|
-
*/
|
|
3129
|
-
export function markSummaryDirtyInTx(db, summaryId, tenantId, actor) {
|
|
3130
|
-
// v0.30 / E5: widened dag_level=2 -> IN (2, 3). RETURNING dag_level reads
|
|
3131
|
-
// actual level in same round trip so audit metadata stays accurate without
|
|
3132
|
-
// a SELECT-before-UPDATE extra DB op on this hot path (5 caller sites).
|
|
3133
|
-
// SAFETY: result's shape matches the single `dag_level` column returned
|
|
3134
|
-
// above.
|
|
3135
|
-
const result = db.prepare(`
|
|
3136
|
-
UPDATE memories
|
|
3137
|
-
SET summary_dirty = 1
|
|
3138
|
-
WHERE id = ?
|
|
3139
|
-
AND tenant_id = ?
|
|
3140
|
-
AND dag_level IN (2, 3)
|
|
3141
|
-
AND summary_dirty = 0
|
|
3142
|
-
AND kind != 'archived'
|
|
3143
|
-
RETURNING dag_level
|
|
3144
|
-
`).get(summaryId, tenantId);
|
|
3145
|
-
if (result) {
|
|
3146
|
-
audit(db, 'summary_marked_dirty', summaryId, { dag_level: result.dag_level, source: 'E2' }, actor, tenantId);
|
|
3147
|
-
}
|
|
3148
|
-
}
|
|
3149
|
-
/**
|
|
3150
|
-
* Mark a summary as dirty. Idempotent (re-marking dirty is a no-op + no
|
|
3151
|
-
* second audit row). Tenant-scoped to prevent cross-tenant writes via
|
|
3152
|
-
* parent-lookup. Called by E2 from invalidation.ts / writeEntry /
|
|
3153
|
-
* forgetMemory / archiveRawMemory whenever a child is invalidated,
|
|
3154
|
-
* superseded, forgotten, or archived.
|
|
3155
|
-
*
|
|
3156
|
-
* Quietly no-ops if the target row doesn't exist or isn't a level-2
|
|
3157
|
-
* summary (E5 will widen the dag_level guard to IN (2, 3) when level-3
|
|
3158
|
-
* build path lands). Emits a 'summary_marked_dirty' audit row on actual
|
|
3159
|
-
* state transitions (0 -> 1) via the audit() helper, which try/catches
|
|
3160
|
-
* for missing audit_log (the v27 self-heal scenario).
|
|
3161
|
-
*/
|
|
3162
|
-
export function markSummaryDirty(hippoRoot, summaryId, tenantId, actor = 'cli') {
|
|
3163
|
-
assertTenantId('markSummaryDirty', tenantId);
|
|
3164
|
-
const db = openStore(hippoRoot);
|
|
3165
|
-
try {
|
|
3166
|
-
// v0.30 / E5: widened dag_level=2 -> IN (2, 3). RETURNING dag_level reads
|
|
3167
|
-
// actual level in same round trip.
|
|
3168
|
-
// SAFETY: result's shape matches the single `dag_level` column returned
|
|
3169
|
-
// above.
|
|
3170
|
-
const result = db.prepare(`
|
|
3171
|
-
UPDATE memories
|
|
3172
|
-
SET summary_dirty = 1
|
|
3173
|
-
WHERE id = ?
|
|
3174
|
-
AND tenant_id = ?
|
|
3175
|
-
AND dag_level IN (2, 3)
|
|
3176
|
-
AND summary_dirty = 0
|
|
3177
|
-
AND kind != 'archived'
|
|
3178
|
-
RETURNING dag_level
|
|
3179
|
-
`).get(summaryId, tenantId);
|
|
3180
|
-
if (result) {
|
|
3181
|
-
// audit() wraps appendAuditEvent in try/catch (v27 heal scenario).
|
|
3182
|
-
// metadata.source=E1 leaves a breadcrumb so E2-E5 debugging can
|
|
3183
|
-
// distinguish dirty-marks across the arc's wiring layers.
|
|
3184
|
-
audit(db, 'summary_marked_dirty', summaryId, { dag_level: result.dag_level, source: 'E1' }, actor, tenantId);
|
|
3185
|
-
}
|
|
3186
|
-
}
|
|
3187
|
-
finally {
|
|
3188
|
-
closeHippoDb(db);
|
|
3189
|
-
}
|
|
3190
|
-
}
|
|
3191
|
-
// ---------------------------------------------------------------------------
|
|
3192
|
-
// v0.30 / E3 of DAG live-coupling — sleep-cycle rebuild surface.
|
|
3193
|
-
//
|
|
3194
|
-
// loadAllDirtySummaries / loadChildrenOfSummary / applyRebuildResult /
|
|
3195
|
-
// clearSummaryDirtyAfterBuild live HERE (not in dag.ts) because they need
|
|
3196
|
-
// module-private MEMORY_SELECT_COLUMNS, MemoryRow, rowToEntry, audit,
|
|
3197
|
-
// syncFtsRow, assertTenantId. dag.ts owns only the thin orchestrator
|
|
3198
|
-
// rebuildDirtySummaries() that calls into these.
|
|
3199
|
-
// ---------------------------------------------------------------------------
|
|
3200
|
-
/**
|
|
3201
|
-
* v0.30 / E5 — host-wide loader for L2 topic summaries without an L3 parent.
|
|
3202
|
-
* Used by consolidate phase 1.9 (buildEntityProfiles) to cluster L2s into
|
|
3203
|
-
* L3 entity profiles. Mirrors loadAllDirtySummaries pattern (E3): SQL-level
|
|
3204
|
-
* filter is cheaper than reusing in-memory `survivors` (which doesn't
|
|
3205
|
-
* contain L2s freshly created by phase 1.7 buildDag).
|
|
3206
|
-
*
|
|
3207
|
-
* Returns entries with tenantId attached so per-cluster writes stay
|
|
3208
|
-
* tenant-scoped via summary.tenantId.
|
|
3209
|
-
*/
|
|
3210
|
-
export function loadAllL2Summaries(hippoRoot) {
|
|
3211
|
-
const db = openStore(hippoRoot);
|
|
3212
|
-
try {
|
|
3213
|
-
// SAFETY: this query selects exactly MEMORY_SELECT_COLUMNS, matching
|
|
3214
|
-
// MemoryRow's field set.
|
|
3215
|
-
const rows = db.prepare(`
|
|
3216
|
-
SELECT ${MEMORY_SELECT_COLUMNS}
|
|
3217
|
-
FROM memories
|
|
3218
|
-
WHERE dag_level = 2
|
|
3219
|
-
AND dag_parent_id IS NULL
|
|
3220
|
-
AND kind != 'archived'
|
|
3221
|
-
AND superseded_by IS NULL
|
|
3222
|
-
ORDER BY created ASC, id ASC
|
|
3223
|
-
`).all();
|
|
3224
|
-
return rows.map(rowToEntry);
|
|
3225
|
-
}
|
|
3226
|
-
finally {
|
|
3227
|
-
closeHippoDb(db);
|
|
3228
|
-
}
|
|
3229
|
-
}
|
|
3230
|
-
/**
|
|
3231
|
-
* v0.30 / E3 — host-wide variant of loadDirtySummaries. Iterates all tenants
|
|
3232
|
-
* in one query so consolidate.ts (host-wide per L106-109) does not need a
|
|
3233
|
-
* per-tenant loop. Each returned MemoryEntry carries its own tenantId (via
|
|
3234
|
-
* rowToEntry), so per-summary children + rebuild UPDATE stay tenant-scoped.
|
|
3235
|
-
*
|
|
3236
|
-
* Sort: latest_at DESC NULLS LAST, id ASC — same as per-tenant variant so
|
|
3237
|
-
* HIPPO_DAG_REBUILD_CAP takes most-recently-changed summaries first.
|
|
3238
|
-
*/
|
|
3239
|
-
export function loadAllDirtySummaries(hippoRoot) {
|
|
3240
|
-
const db = openStore(hippoRoot);
|
|
3241
|
-
try {
|
|
3242
|
-
// SAFETY: this query selects exactly MEMORY_SELECT_COLUMNS, matching
|
|
3243
|
-
// MemoryRow's field set.
|
|
3244
|
-
const rows = db.prepare(`
|
|
3245
|
-
SELECT ${MEMORY_SELECT_COLUMNS}
|
|
3246
|
-
FROM memories
|
|
3247
|
-
WHERE summary_dirty = 1
|
|
3248
|
-
AND kind != 'archived'
|
|
3249
|
-
ORDER BY latest_at DESC NULLS LAST, id ASC
|
|
3250
|
-
`).all();
|
|
3251
|
-
return rows.map(rowToEntry);
|
|
3252
|
-
}
|
|
3253
|
-
finally {
|
|
3254
|
-
closeHippoDb(db);
|
|
3255
|
-
}
|
|
3256
|
-
}
|
|
3257
|
-
/**
|
|
3258
|
-
* v0.30 / E3 — load live children of a DAG summary. Used by
|
|
3259
|
-
* rebuildDirtySummaries to regenerate content from the CURRENT child set
|
|
3260
|
-
* (not the children at create-time). Skips archived. Tenant-scoped
|
|
3261
|
-
* (defence in depth — dag_parent_id is unique-ish but tenant guard is
|
|
3262
|
-
* cheap). created column is TEXT NOT NULL since db.ts schema v1.
|
|
3263
|
-
*/
|
|
3264
|
-
export function loadChildrenOfSummary(hippoRoot, summaryId, tenantId) {
|
|
3265
|
-
assertTenantId('loadChildrenOfSummary', tenantId);
|
|
3266
|
-
const db = openStore(hippoRoot);
|
|
3267
|
-
try {
|
|
3268
|
-
// SAFETY: this query selects exactly MEMORY_SELECT_COLUMNS, matching
|
|
3269
|
-
// MemoryRow's field set.
|
|
3270
|
-
const rows = db.prepare(`
|
|
3271
|
-
SELECT ${MEMORY_SELECT_COLUMNS}
|
|
3272
|
-
FROM memories
|
|
3273
|
-
WHERE dag_parent_id = ?
|
|
3274
|
-
AND tenant_id = ?
|
|
3275
|
-
AND kind != 'archived'
|
|
3276
|
-
AND superseded_by IS NULL
|
|
3277
|
-
ORDER BY created ASC
|
|
3278
|
-
`).all(summaryId, tenantId);
|
|
3279
|
-
return rows.map(rowToEntry);
|
|
3280
|
-
}
|
|
3281
|
-
finally {
|
|
3282
|
-
closeHippoDb(db);
|
|
3283
|
-
}
|
|
3284
|
-
}
|
|
3285
|
-
/**
|
|
3286
|
-
* v0.30 / E3 — apply a rebuild result to a dirty summary. Atomic: one
|
|
3287
|
-
* prepared UPDATE statement plus syncFtsRow inside one SAVEPOINT.
|
|
3288
|
-
* WHERE includes `AND summary_dirty = 1` so concurrent sleep's race-loser
|
|
3289
|
-
* becomes a no-op (no rebuild_count bump, no audit row).
|
|
3290
|
-
*
|
|
3291
|
-
* Returns `{ changed, refused }`. `changed` is true when this call's UPDATE
|
|
3292
|
-
* (content or metadata-only) affected a row; false on race-loss / unknown id
|
|
3293
|
-
* / archived / wrong dag_level. `refused` is true only when a tombstone hit
|
|
3294
|
-
* suppressed the content write AND the metadata UPDATE still landed — see
|
|
3295
|
-
* the return-semantics comment below for the full contract.
|
|
3296
|
-
*/
|
|
3297
|
-
export function applyRebuildResult(hippoRoot, summary, patch) {
|
|
3298
|
-
assertTenantId('applyRebuildResult', summary.tenantId);
|
|
3299
|
-
const db = openStore(hippoRoot);
|
|
3300
|
-
try {
|
|
3301
|
-
db.exec('SAVEPOINT rebuild_summary');
|
|
3302
|
-
try {
|
|
3303
|
-
const nowIso = new Date().toISOString();
|
|
3304
|
-
// AT1 P1a fix (docs/plans/2026-08-15-at1-rejected-value-tombstone.md):
|
|
3305
|
-
// applyRebuildResult's bumpRebuildCount branch wrote patch.content via
|
|
3306
|
-
// a direct UPDATE, bypassing the rejection guard entirely (the guard
|
|
3307
|
-
// lives in upsertEntryRow's INSERT path, which this function never
|
|
3308
|
-
// calls). A rebuild that regenerates byte-identical content to an
|
|
3309
|
-
// already-rejected value (e.g. deterministic summarization of an
|
|
3310
|
-
// unchanged child set) would silently re-assert it every sleep cycle.
|
|
3311
|
-
// Check BEFORE choosing which UPDATE to run — only the
|
|
3312
|
-
// bumpRebuildCount branch ever writes content, so a miss or a
|
|
3313
|
-
// zero-child call is a no-op here (one indexed point query, guarded
|
|
3314
|
-
// path only).
|
|
3315
|
-
const tombstone = patch.bumpRebuildCount
|
|
3316
|
-
? findRejectedValue(db, summary.tenantId, rejectionDigest(patch.content))
|
|
3317
|
-
: null;
|
|
3318
|
-
// On a hit: do NOT write the new content. Fall through to the SAME
|
|
3319
|
-
// metadata-only behavior the zero-child branch already has —
|
|
3320
|
-
// descendant_count/earliest_at/latest_at update + summary_dirty
|
|
3321
|
-
// cleared, no content write, no rebuild_count bump. Clearing dirty
|
|
3322
|
-
// (rather than leaving it set) is deliberate: leaving it dirty would
|
|
3323
|
-
// make every following sleep cycle re-attempt and re-refuse the
|
|
3324
|
-
// identical rebuild forever (the DAG-loop this fix closes).
|
|
3325
|
-
const applyContentWrite = patch.bumpRebuildCount && !tombstone;
|
|
3326
|
-
// ONE prepared UPDATE per branch. Test #8 inspects the SQL string.
|
|
3327
|
-
// v0.30 / E5: widened dag_level=2 -> IN (2, 3) on both branches.
|
|
3328
|
-
const sql = applyContentWrite
|
|
3329
|
-
? `UPDATE memories
|
|
3330
|
-
SET content = ?,
|
|
3331
|
-
descendant_count = ?,
|
|
3332
|
-
earliest_at = ?,
|
|
3333
|
-
latest_at = ?,
|
|
3334
|
-
last_rebuilt_at = ?,
|
|
3335
|
-
rebuild_count = COALESCE(rebuild_count, 0) + 1,
|
|
3336
|
-
summary_dirty = 0
|
|
3337
|
-
WHERE id = ?
|
|
3338
|
-
AND tenant_id = ?
|
|
3339
|
-
AND dag_level IN (2, 3)
|
|
3340
|
-
AND summary_dirty = 1
|
|
3341
|
-
AND kind != 'archived'`
|
|
3342
|
-
: `UPDATE memories
|
|
3343
|
-
SET descendant_count = ?,
|
|
3344
|
-
earliest_at = ?,
|
|
3345
|
-
latest_at = ?,
|
|
3346
|
-
summary_dirty = 0
|
|
3347
|
-
WHERE id = ?
|
|
3348
|
-
AND tenant_id = ?
|
|
3349
|
-
AND dag_level IN (2, 3)
|
|
3350
|
-
AND summary_dirty = 1
|
|
3351
|
-
AND kind != 'archived'`;
|
|
3352
|
-
const result = applyContentWrite
|
|
3353
|
-
? db.prepare(sql).run(patch.content, patch.descendant_count, patch.earliest_at, patch.latest_at, nowIso, summary.id, summary.tenantId)
|
|
3354
|
-
: db.prepare(sql).run(patch.descendant_count, patch.earliest_at, patch.latest_at, summary.id, summary.tenantId);
|
|
3355
|
-
// Return-value semantics (v0.30/T4 split): `changed` reflects whether
|
|
3356
|
-
// THIS call's UPDATE (content or metadata-only) affected a row — NOT
|
|
3357
|
-
// whether patch.content specifically landed. On a refusal, metadata
|
|
3358
|
-
// still applies, so changed=true even though content did not change.
|
|
3359
|
-
// This preserves the pre-T4 no-infinite-retry choice: the caller
|
|
3360
|
-
// (dag.ts rebuildDirtySummaries) treats changed=false as "race lost,
|
|
3361
|
-
// silently retry next cycle" — returning false on a refusal would
|
|
3362
|
-
// retry the same doomed LLM rebuild forever, so changed=true settles
|
|
3363
|
-
// this cycle (dirty cleared) regardless of refusal.
|
|
3364
|
-
// `refused` is the T4 addition: true only when a tombstone hit AND
|
|
3365
|
-
// the metadata UPDATE landed (changed=true) — a refusal that loses
|
|
3366
|
-
// the race to a concurrent writer reports refused=false too, since
|
|
3367
|
-
// nothing from this call took effect. Before T4, a refusal also
|
|
3368
|
-
// counted toward the caller's `rebuilt` stat because `changed` alone
|
|
3369
|
-
// could not distinguish it; the caller now increments `refused`
|
|
3370
|
-
// instead of `rebuilt` when this is true, so the stat reflects what
|
|
3371
|
-
// happened without changing dirty-clearing or retry behavior.
|
|
3372
|
-
const changed = (result.changes ?? 0) > 0;
|
|
3373
|
-
const refused = Boolean(tombstone) && changed;
|
|
3374
|
-
if (tombstone && changed) {
|
|
3375
|
-
// refused === true here (same condition, narrowed for the tombstone.*
|
|
3376
|
-
// access below). Best-effort refusal audit, written INLINE inside
|
|
3377
|
-
// this still-open SAVEPOINT — nothing here rolls back on a refusal
|
|
3378
|
-
// (the metadata UPDATE above already committed to this savepoint), so the
|
|
3379
|
-
// post-rollback auditRejectionRefusal helper (writeEntry/supersede's
|
|
3380
|
-
// tool) is the wrong one here; a direct audit() call is correct and
|
|
3381
|
-
// commits with the rest of this savepoint.
|
|
3382
|
-
audit(db, 'reject_refusal', summary.id, { digest: tombstone.digest, reason: tombstone.reason }, patch.actor, summary.tenantId);
|
|
3383
|
-
log.warn(`applyRebuildResult: refused rebuild content for ${summary.id} — matches a rejected value ` +
|
|
3384
|
-
`(digest ${tombstone.digest.slice(0, 12)}...); metadata updated, content unchanged`);
|
|
3385
|
-
}
|
|
3386
|
-
if (changed) {
|
|
3387
|
-
// FTS sync — bare UPDATE on memories does NOT update memories_fts.
|
|
3388
|
-
// R1 HIGH must-fix from plan-eng-r1. Construct the patched entry in
|
|
3389
|
-
// memory and reuse the existing syncFtsRow helper (delete-then-insert).
|
|
3390
|
-
// earliest_at/latest_at preserve null semantics (R2 must-fix).
|
|
3391
|
-
// AT1: content stays summary.content (unchanged) when the write was
|
|
3392
|
-
// refused — applyContentWrite is false, so patch.content was never
|
|
3393
|
-
// written to the row FTS must mirror.
|
|
3394
|
-
const patchedEntry = {
|
|
3395
|
-
...summary,
|
|
3396
|
-
content: applyContentWrite ? patch.content : summary.content,
|
|
3397
|
-
descendant_count: patch.descendant_count,
|
|
3398
|
-
earliest_at: patch.earliest_at,
|
|
3399
|
-
latest_at: patch.latest_at,
|
|
3400
|
-
summary_dirty: 0,
|
|
3401
|
-
last_rebuilt_at: applyContentWrite ? nowIso : summary.last_rebuilt_at,
|
|
3402
|
-
rebuild_count: applyContentWrite
|
|
3403
|
-
? (summary.rebuild_count ?? 0) + 1
|
|
3404
|
-
: summary.rebuild_count,
|
|
3405
|
-
};
|
|
3406
|
-
syncFtsRow(db, patchedEntry);
|
|
3407
|
-
audit(db, 'summary_rebuilt', summary.id, {
|
|
3408
|
-
// v0.30 / E5: read actual level from the summary in scope
|
|
3409
|
-
// (NOT hardcoded 2). L2 -> 2, L3 -> 3.
|
|
3410
|
-
dag_level: summary.dag_level,
|
|
3411
|
-
source: 'E3-rebuild',
|
|
3412
|
-
zero_children: patch.zeroChildren,
|
|
3413
|
-
descendant_count: patch.descendant_count,
|
|
3414
|
-
}, patch.actor, summary.tenantId);
|
|
3415
|
-
}
|
|
3416
|
-
db.exec('RELEASE SAVEPOINT rebuild_summary');
|
|
3417
|
-
return { changed, refused };
|
|
3418
|
-
}
|
|
3419
|
-
catch (e) {
|
|
3420
|
-
try {
|
|
3421
|
-
db.exec('ROLLBACK TO SAVEPOINT rebuild_summary');
|
|
3422
|
-
db.exec('RELEASE SAVEPOINT rebuild_summary');
|
|
3423
|
-
}
|
|
3424
|
-
catch {
|
|
3425
|
-
// Ignore rollback failures — throw below is what matters.
|
|
3426
|
-
}
|
|
3427
|
-
throw e;
|
|
3428
|
-
}
|
|
3429
|
-
}
|
|
3430
|
-
finally {
|
|
3431
|
-
closeHippoDb(db);
|
|
3432
|
-
}
|
|
3433
|
-
}
|
|
3434
|
-
/**
|
|
3435
|
-
* v0.30 / E3 — clear summary_dirty on a freshly-built summary. Called by
|
|
3436
|
-
* buildDag immediately after the child-link loop finishes. Without this,
|
|
3437
|
-
* each member's writeEntry call fires markSummaryDirtyInTx on the just-
|
|
3438
|
-
* created parent (E2 hook at store.ts:1214), and the same sleep cycle's
|
|
3439
|
-
* E3 rebuild phase would re-rebuild every new summary (2x LLM cost).
|
|
3440
|
-
*
|
|
3441
|
-
* Idempotent: no-op + no audit if summary isn't dirty. Audit
|
|
3442
|
-
* source='buildDag-clean' distinguishes from E3-rebuild source.
|
|
3443
|
-
*/
|
|
3444
|
-
export function clearSummaryDirtyAfterBuild(hippoRoot, summaryId, tenantId, actor = 'cli', source = 'buildDag-clean') {
|
|
3445
|
-
assertTenantId('clearSummaryDirtyAfterBuild', tenantId);
|
|
3446
|
-
const db = openStore(hippoRoot);
|
|
3447
|
-
try {
|
|
3448
|
-
// v0.30 / E5: widened dag_level=2 -> IN (2, 3). RETURNING dag_level reads
|
|
3449
|
-
// actual level so audit metadata stays accurate without an extra SELECT.
|
|
3450
|
-
// SAFETY: result's shape matches the single `dag_level` column returned
|
|
3451
|
-
// below.
|
|
3452
|
-
const result = db.prepare(`
|
|
3453
|
-
UPDATE memories
|
|
3454
|
-
SET summary_dirty = 0
|
|
3455
|
-
WHERE id = ?
|
|
3456
|
-
AND tenant_id = ?
|
|
3457
|
-
AND dag_level IN (2, 3)
|
|
3458
|
-
AND summary_dirty = 1
|
|
3459
|
-
AND kind != 'archived'
|
|
3460
|
-
RETURNING dag_level
|
|
3461
|
-
`).get(summaryId, tenantId);
|
|
3462
|
-
if (result) {
|
|
3463
|
-
// v0.30 / E5: source param distinguishes buildDag-clean (L2) from
|
|
3464
|
-
// buildEntityProfiles-clean (L3) and any future build path.
|
|
3465
|
-
audit(db, 'summary_marked_clean', summaryId, { dag_level: result.dag_level, source }, actor, tenantId);
|
|
3466
|
-
}
|
|
3467
|
-
}
|
|
3468
|
-
finally {
|
|
3469
|
-
closeHippoDb(db);
|
|
3470
|
-
}
|
|
3471
|
-
}
|
|
3472
|
-
export { getHippoDbPath };
|
|
3473
|
-
//# sourceMappingURL=store.js.map
|