hippo-memory 1.62.0 → 1.63.1

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.
Files changed (379) hide show
  1. package/README.md +11 -2
  2. package/dist/agent-memories/apply.d.ts +2 -0
  3. package/dist/agent-memories/apply.js +23 -1
  4. package/dist/agent-memories/claude-code.d.ts +2 -1
  5. package/dist/agent-memories/claude-code.js +4 -4
  6. package/dist/agent-memories/report.d.ts +1 -0
  7. package/dist/agent-memories/report.js +2 -2
  8. package/dist/agent-memories/sync.js +37 -23
  9. package/dist/ambient-store.d.ts +4 -4
  10. package/dist/ambient-store.js +4 -3
  11. package/dist/api/assemble.d.ts +7 -10
  12. package/dist/api/assemble.js +7 -12
  13. package/dist/api/audit.d.ts +2 -2
  14. package/dist/api/audit.js +2 -2
  15. package/dist/api/auth.d.ts +6 -6
  16. package/dist/api/auth.js +5 -7
  17. package/dist/api/context-select.d.ts +4 -3
  18. package/dist/api/context-select.js +9 -11
  19. package/dist/api/context-types.d.ts +12 -17
  20. package/dist/api/context.d.ts +3 -2
  21. package/dist/api/context.js +20 -20
  22. package/dist/api/dormant.js +1 -1
  23. package/dist/api/drill-down.d.ts +4 -4
  24. package/dist/api/drill-down.js +22 -10
  25. package/dist/api/outcome.d.ts +8 -13
  26. package/dist/api/outcome.js +13 -17
  27. package/dist/api/promote.d.ts +5 -8
  28. package/dist/api/quarantine.js +3 -2
  29. package/dist/api/recall-types.d.ts +43 -55
  30. package/dist/api/recall.d.ts +3 -3
  31. package/dist/api/recall.js +7 -18
  32. package/dist/api/remember.d.ts +2 -2
  33. package/dist/api/sleep.d.ts +7 -35
  34. package/dist/api/sleep.js +3 -2
  35. package/dist/api/tokens.d.ts +2 -2
  36. package/dist/api/tokens.js +2 -2
  37. package/dist/api/types.d.ts +6 -10
  38. package/dist/api/types.js +2 -4
  39. package/dist/audit-prune.d.ts +4 -6
  40. package/dist/audit-prune.js +3 -5
  41. package/dist/audit.js +11 -33
  42. package/dist/auth.d.ts +8 -9
  43. package/dist/auth.js +5 -7
  44. package/dist/autolearn.d.ts +1 -1
  45. package/dist/autolearn.js +1 -1
  46. package/dist/availability.js +3 -5
  47. package/dist/capture/command.d.ts +5 -4
  48. package/dist/capture/command.js +8 -22
  49. package/dist/capture/compact.d.ts +1 -1
  50. package/dist/capture/compact.js +9 -13
  51. package/dist/capture/extract.js +37 -115
  52. package/dist/capture-error.d.ts +1 -1
  53. package/dist/capture-error.js +1 -1
  54. package/dist/churn-git.d.ts +1 -1
  55. package/dist/churn-git.js +1 -1
  56. package/dist/cli/audit.js +3 -4
  57. package/dist/cli/auth.js +3 -6
  58. package/dist/cli/curate.d.ts +1 -1
  59. package/dist/cli/curate.js +8 -24
  60. package/dist/cli/dag.js +5 -9
  61. package/dist/cli/decisions.js +10 -21
  62. package/dist/cli/explain.js +2 -1
  63. package/dist/cli/goals.js +1 -1
  64. package/dist/cli/init.js +1 -1
  65. package/dist/cli/playbooks.js +4 -9
  66. package/dist/cli/projects.js +3 -1
  67. package/dist/cli/recall.js +2 -1
  68. package/dist/cli/remember.js +6 -18
  69. package/dist/cli/session-hooks.js +21 -40
  70. package/dist/cli/setup.d.ts +1 -1
  71. package/dist/cli/setup.js +2 -6
  72. package/dist/cli/shared.js +3 -3
  73. package/dist/cli/slack.js +1 -1
  74. package/dist/cli/sleep.js +27 -1
  75. package/dist/cli/status.d.ts +4 -4
  76. package/dist/cli/status.js +7 -12
  77. package/dist/cli/transfer.js +8 -13
  78. package/dist/cli/usage.d.ts +1 -1
  79. package/dist/cli/usage.js +10 -7
  80. package/dist/cli.d.ts +1 -1
  81. package/dist/cli.js +4 -9
  82. package/dist/compaction-record.d.ts +2 -0
  83. package/dist/compaction-record.js +89 -68
  84. package/dist/compare.d.ts +11 -16
  85. package/dist/compare.js +11 -16
  86. package/dist/config.d.ts +19 -18
  87. package/dist/config.js +90 -67
  88. package/dist/connectors/github/backfill.d.ts +2 -2
  89. package/dist/connectors/github/backfill.js +8 -15
  90. package/dist/connectors/github/cli-impl.js +3 -8
  91. package/dist/connectors/github/deletion.d.ts +5 -12
  92. package/dist/connectors/github/deletion.js +5 -12
  93. package/dist/connectors/github/dlq.d.ts +6 -9
  94. package/dist/connectors/github/dlq.js +2 -3
  95. package/dist/connectors/github/ingest.d.ts +5 -7
  96. package/dist/connectors/github/ingest.js +8 -12
  97. package/dist/connectors/github/octokit-client.d.ts +3 -5
  98. package/dist/connectors/github/octokit-client.js +5 -6
  99. package/dist/connectors/github/signature.d.ts +9 -39
  100. package/dist/connectors/github/signature.js +9 -39
  101. package/dist/connectors/github/tenant-routing.d.ts +1 -1
  102. package/dist/connectors/github/tenant-routing.js +1 -1
  103. package/dist/connectors/github/transform.js +2 -2
  104. package/dist/connectors/github/types.d.ts +2 -10
  105. package/dist/connectors/github/types.js +1 -3
  106. package/dist/connectors/slack/deletion.d.ts +3 -8
  107. package/dist/connectors/slack/deletion.js +3 -8
  108. package/dist/connectors/slack/dlq.d.ts +1 -1
  109. package/dist/connectors/slack/ingest.d.ts +1 -1
  110. package/dist/connectors/slack/ingest.js +7 -16
  111. package/dist/connectors/slack/signature.d.ts +1 -1
  112. package/dist/connectors/slack/tenant-routing.d.ts +3 -5
  113. package/dist/connectors/slack/tenant-routing.js +3 -5
  114. package/dist/connectors/slack/transform.d.ts +5 -6
  115. package/dist/connectors/slack/transform.js +5 -6
  116. package/dist/connectors/slack/types.d.ts +2 -6
  117. package/dist/connectors/slack/types.js +1 -3
  118. package/dist/connectors/slack/web-client.js +10 -3
  119. package/dist/connectors/slack/workspaces.d.ts +3 -5
  120. package/dist/connectors/slack/workspaces.js +3 -5
  121. package/dist/consolidate/conflicts.js +3 -14
  122. package/dist/consolidate/decay.js +9 -29
  123. package/dist/consolidate/llm-passes.js +4 -5
  124. package/dist/consolidate/merge.js +8 -23
  125. package/dist/consolidate/run.d.ts +3 -8
  126. package/dist/consolidate/run.js +21 -26
  127. package/dist/consolidate/sleep.js +7 -18
  128. package/dist/consolidate/traces.js +9 -21
  129. package/dist/customer-notes.d.ts +5 -7
  130. package/dist/customer-notes.js +6 -9
  131. package/dist/dag.d.ts +10 -21
  132. package/dist/dag.js +23 -73
  133. package/dist/db/continuity.d.ts +5 -0
  134. package/dist/db/continuity.js +43 -25
  135. package/dist/db/meta.d.ts +1 -0
  136. package/dist/db/meta.js +4 -3
  137. package/dist/db/migrate.d.ts +9 -0
  138. package/dist/db/migrate.js +110 -52
  139. package/dist/db/migrations/v14.js +1 -1
  140. package/dist/db/migrations/v15.js +1 -2
  141. package/dist/db/migrations/v16.js +3 -4
  142. package/dist/db/migrations/v17.js +2 -3
  143. package/dist/db/migrations/v19.js +1 -1
  144. package/dist/db/migrations/v20.js +1 -1
  145. package/dist/db/migrations/v21.js +2 -6
  146. package/dist/db/migrations/v22.js +2 -4
  147. package/dist/db/migrations/v23.js +1 -1
  148. package/dist/db/migrations/v24.js +4 -6
  149. package/dist/db/migrations/v25.js +2 -3
  150. package/dist/db/migrations/v26.js +3 -3
  151. package/dist/db/migrations/v27.js +2 -10
  152. package/dist/db/migrations/v28.js +5 -8
  153. package/dist/db/migrations/v29.js +3 -4
  154. package/dist/db/migrations/v30.js +2 -2
  155. package/dist/db/migrations/v31.js +1 -1
  156. package/dist/db/migrations/v32.js +1 -1
  157. package/dist/db/migrations/v33.js +3 -3
  158. package/dist/db/migrations/v34.js +1 -1
  159. package/dist/db/migrations/v35.js +3 -4
  160. package/dist/db/migrations/v36.js +3 -4
  161. package/dist/db/migrations/v37.js +5 -5
  162. package/dist/db/migrations/v38.js +7 -8
  163. package/dist/db/migrations/v39.js +1 -1
  164. package/dist/db/migrations/v40.js +4 -16
  165. package/dist/db/migrations/v41.js +3 -4
  166. package/dist/db/migrations/v42.js +3 -4
  167. package/dist/db/migrations/v45.js +1 -1
  168. package/dist/db/migrations/v46.js +1 -1
  169. package/dist/db/migrations/v47.js +1 -1
  170. package/dist/db/migrations/v48.js +1 -1
  171. package/dist/db.d.ts +1 -1
  172. package/dist/db.js +1 -1
  173. package/dist/decisions.d.ts +2 -2
  174. package/dist/decisions.js +6 -6
  175. package/dist/dedupe.js +86 -61
  176. package/dist/delivery-recorder.js +154 -135
  177. package/dist/doctor.js +140 -105
  178. package/dist/dormant.js +1 -4
  179. package/dist/embedding-provider.d.ts +4 -8
  180. package/dist/embedding-provider.js +4 -8
  181. package/dist/embeddings.js +55 -47
  182. package/dist/env.d.ts +1 -1
  183. package/dist/env.js +12 -12
  184. package/dist/escape.d.ts +5 -0
  185. package/dist/escape.js +10 -0
  186. package/dist/eval-stats.d.ts +1 -2
  187. package/dist/eval-stats.js +1 -2
  188. package/dist/eval-suite.js +27 -21
  189. package/dist/extract.js +4 -9
  190. package/dist/failure-log.d.ts +3 -3
  191. package/dist/failure-log.js +1 -1
  192. package/dist/forward-claim-detector.d.ts +2 -4
  193. package/dist/forward-claim-detector.js +6 -11
  194. package/dist/goals.d.ts +3 -3
  195. package/dist/goals.js +5 -6
  196. package/dist/graph/read.d.ts +2 -2
  197. package/dist/graph/read.js +5 -6
  198. package/dist/graph/types.d.ts +8 -8
  199. package/dist/graph/write.d.ts +7 -14
  200. package/dist/graph/write.js +16 -23
  201. package/dist/graph-extract.d.ts +7 -8
  202. package/dist/graph-extract.js +62 -72
  203. package/dist/graph-recall.d.ts +2 -2
  204. package/dist/graph-recall.js +55 -49
  205. package/dist/graph-stream.d.ts +5 -6
  206. package/dist/graph-stream.js +66 -57
  207. package/dist/graph-view.d.ts +2 -2
  208. package/dist/graph-view.js +7 -7
  209. package/dist/half-life-migration.d.ts +1 -2
  210. package/dist/half-life-migration.js +2 -3
  211. package/dist/hooks/codex-session.js +1 -1
  212. package/dist/hooks/codex-wrapper.d.ts +1 -1
  213. package/dist/hooks/codex-wrapper.js +3 -2
  214. package/dist/hooks/json-hooks.d.ts +2 -2
  215. package/dist/hooks/json-hooks.js +5 -4
  216. package/dist/hooks/opencode.d.ts +1 -1
  217. package/dist/hooks/opencode.js +5 -4
  218. package/dist/hooks/shared.d.ts +3 -7
  219. package/dist/hooks/shared.js +1 -8
  220. package/dist/http-util.d.ts +2 -3
  221. package/dist/http-util.js +3 -0
  222. package/dist/importers/core.d.ts +2 -9
  223. package/dist/importers/core.js +15 -30
  224. package/dist/importers/sources.js +2 -1
  225. package/dist/importers/vault.js +2 -20
  226. package/dist/incidents.d.ts +1 -1
  227. package/dist/incidents.js +1 -1
  228. package/dist/instruction-detect.d.ts +1 -1
  229. package/dist/instruction-detect.js +1 -1
  230. package/dist/invalidation.d.ts +3 -0
  231. package/dist/invalidation.js +160 -114
  232. package/dist/json.d.ts +5 -0
  233. package/dist/json.js +4 -0
  234. package/dist/judgment.js +1 -2
  235. package/dist/local-embedding.js +1 -1
  236. package/dist/mcp/admin-tools.js +7 -17
  237. package/dist/mcp/format.js +1 -1
  238. package/dist/mcp/framing.js +3 -6
  239. package/dist/mcp/protocol.d.ts +2 -5
  240. package/dist/mcp/protocol.js +1 -3
  241. package/dist/mcp/recall-tools.js +12 -15
  242. package/dist/mcp/request.js +4 -3
  243. package/dist/mcp/session-state.js +2 -3
  244. package/dist/mcp/stdio.js +2 -1
  245. package/dist/mcp/tools.js +9 -6
  246. package/dist/memory-value-weights.d.ts +5 -8
  247. package/dist/memory-value-weights.js +5 -8
  248. package/dist/memory-value.d.ts +13 -13
  249. package/dist/memory-value.js +26 -37
  250. package/dist/memory.d.ts +20 -22
  251. package/dist/memory.js +24 -48
  252. package/dist/multihop.d.ts +1 -1
  253. package/dist/multihop.js +3 -2
  254. package/dist/owner-validation.d.ts +4 -5
  255. package/dist/owner-validation.js +4 -5
  256. package/dist/physics.d.ts +4 -4
  257. package/dist/physics.js +7 -9
  258. package/dist/policies.d.ts +9 -10
  259. package/dist/policies.js +12 -14
  260. package/dist/postinstall.js +3 -6
  261. package/dist/predictions/planning-fallacy.d.ts +9 -14
  262. package/dist/predictions/planning-fallacy.js +10 -16
  263. package/dist/predictions/store.d.ts +15 -23
  264. package/dist/predictions/store.js +36 -33
  265. package/dist/processes.d.ts +2 -7
  266. package/dist/processes.js +3 -3
  267. package/dist/project-briefs.d.ts +2 -3
  268. package/dist/project-briefs.js +9 -13
  269. package/dist/project-identity.d.ts +22 -9
  270. package/dist/project-identity.js +47 -12
  271. package/dist/project-merge.d.ts +17 -2
  272. package/dist/project-merge.js +109 -27
  273. package/dist/project-remote.d.ts +12 -0
  274. package/dist/project-remote.js +138 -0
  275. package/dist/prompt-recall.js +1 -2
  276. package/dist/rate-limit.d.ts +1 -1
  277. package/dist/rate-limit.js +1 -1
  278. package/dist/raw-archive.d.ts +9 -0
  279. package/dist/raw-archive.js +60 -53
  280. package/dist/recall-history.d.ts +19 -20
  281. package/dist/recall-history.js +24 -42
  282. package/dist/recall-pipeline.js +4 -28
  283. package/dist/recall-scope.d.ts +7 -8
  284. package/dist/recall-scope.js +7 -8
  285. package/dist/recall-trace.d.ts +5 -9
  286. package/dist/recall-trace.js +6 -10
  287. package/dist/refine-llm.d.ts +1 -1
  288. package/dist/refine-llm.js +2 -2
  289. package/dist/reject-flow.d.ts +3 -4
  290. package/dist/reject-flow.js +122 -117
  291. package/dist/rejection.d.ts +5 -6
  292. package/dist/rejection.js +7 -15
  293. package/dist/rerankers/clef.d.ts +1 -1
  294. package/dist/rerankers/jev.d.ts +1 -2
  295. package/dist/rerankers/jev.js +4 -5
  296. package/dist/rerankers/llm.d.ts +1 -2
  297. package/dist/rerankers/llm.js +1 -2
  298. package/dist/rerankers/types.d.ts +1 -2
  299. package/dist/rrf.d.ts +2 -2
  300. package/dist/rrf.js +2 -2
  301. package/dist/search/bm25-search.d.ts +1 -1
  302. package/dist/search/bm25-search.js +2 -1
  303. package/dist/search/boosts.js +2 -1
  304. package/dist/search/hybrid.d.ts +1 -1
  305. package/dist/search/hybrid.js +2 -1
  306. package/dist/search/physics-search.d.ts +1 -1
  307. package/dist/search/physics-search.js +2 -1
  308. package/dist/search/types.d.ts +2 -0
  309. package/dist/search/types.js +3 -1
  310. package/dist/secret-detect.d.ts +4 -5
  311. package/dist/secret-detect.js +6 -10
  312. package/dist/server/auth.js +5 -5
  313. package/dist/server/client-ip.js +1 -1
  314. package/dist/server/cursor.js +2 -1
  315. package/dist/server/mcp-http.js +4 -4
  316. package/dist/server/request.d.ts +3 -6
  317. package/dist/server/request.js +6 -7
  318. package/dist/server/routes/admin.js +5 -4
  319. package/dist/server/routes/customer-notes.js +6 -5
  320. package/dist/server/routes/decisions.js +4 -3
  321. package/dist/server/routes/incidents.js +7 -5
  322. package/dist/server/routes/memories.js +7 -7
  323. package/dist/server/routes/policies.js +3 -2
  324. package/dist/server/routes/predictions.js +12 -15
  325. package/dist/server/routes/processes.js +3 -2
  326. package/dist/server/routes/project-briefs.js +8 -7
  327. package/dist/server/routes/recall.js +95 -93
  328. package/dist/server/routes/skills.js +6 -5
  329. package/dist/server/types.d.ts +1 -1
  330. package/dist/server/validation.d.ts +1 -2
  331. package/dist/server/validation.js +7 -14
  332. package/dist/server-detect.js +72 -58
  333. package/dist/server.d.ts +2 -2
  334. package/dist/server.js +131 -117
  335. package/dist/shared.d.ts +17 -17
  336. package/dist/shared.js +93 -97
  337. package/dist/skills.d.ts +3 -3
  338. package/dist/skills.js +8 -8
  339. package/dist/store/audit-event.d.ts +2 -2
  340. package/dist/store/audit-event.js +1 -1
  341. package/dist/store/candidates.d.ts +2 -2
  342. package/dist/store/candidates.js +4 -3
  343. package/dist/store/conflicts.js +30 -22
  344. package/dist/store/delete-and-batch.d.ts +11 -14
  345. package/dist/store/delete-and-batch.js +40 -91
  346. package/dist/store/entry-reads.d.ts +17 -25
  347. package/dist/store/entry-reads.js +59 -40
  348. package/dist/store/entry-row.d.ts +6 -24
  349. package/dist/store/entry-row.js +6 -24
  350. package/dist/store/entry-writes.d.ts +6 -7
  351. package/dist/store/entry-writes.js +13 -11
  352. package/dist/store/handoffs.d.ts +1 -1
  353. package/dist/store/handoffs.js +7 -10
  354. package/dist/store/index-and-stats.d.ts +2 -6
  355. package/dist/store/index-and-stats.js +4 -10
  356. package/dist/store/mirrors.d.ts +6 -19
  357. package/dist/store/mirrors.js +14 -39
  358. package/dist/store/open.js +11 -36
  359. package/dist/store/rows.d.ts +5 -11
  360. package/dist/store/rows.js +6 -11
  361. package/dist/store/search-rows.d.ts +17 -34
  362. package/dist/store/search-rows.js +31 -59
  363. package/dist/store/sessions.d.ts +4 -5
  364. package/dist/store/sessions.js +5 -6
  365. package/dist/store/summaries.d.ts +13 -17
  366. package/dist/store/summaries.js +26 -70
  367. package/dist/support-bundle.js +4 -8
  368. package/dist/tenant.d.ts +1 -5
  369. package/dist/token-ledger.d.ts +1 -1
  370. package/dist/token-ledger.js +3 -5
  371. package/dist/trace.js +1 -3
  372. package/dist/version.d.ts +1 -1
  373. package/dist/version.js +1 -1
  374. package/dist/working-memory.d.ts +1 -1
  375. package/extensions/openclaw-plugin/README.md +3 -1
  376. package/extensions/openclaw-plugin/openclaw.plugin.json +2 -2
  377. package/extensions/openclaw-plugin/package.json +3 -3
  378. package/openclaw.plugin.json +2 -2
  379. package/package.json +2 -2
package/dist/dag.js CHANGED
@@ -87,13 +87,9 @@ export async function generateDagSummary(label, factContents, opts) {
87
87
  return null;
88
88
  }
89
89
  }
90
- // Hardening follow-up (mirrors consolidate.ts's mergeCandidatesByTenant,
91
- // T1): partition unparented facts by tenantId BEFORE clustering so a
92
- // cluster can never mix facts from different tenants into one
93
- // LLM-synthesized summary. Map preserves insertion order, so
94
- // single-tenant stores (every row 'default') get exactly one partition
95
- // and iterate in the same order as before this fix — byte-identical
96
- // behavior there.
90
+ // Partition BEFORE clustering so one LLM summary never mixes facts from different tenants.
91
+ // Map keeps insertion order, so a single-tenant store iterates in its original order.
92
+ // buildEntityProfiles partitions its L2s with this same key.
97
93
  function partitionFactsByTenant(unparented) {
98
94
  const unparentedByTenant = new Map();
99
95
  for (const fact of unparented) {
@@ -123,9 +119,8 @@ function createClusterSummaryEntry(summary, cluster, home) {
123
119
  baseHalfLifeDays: home.baseHalfLifeDays,
124
120
  });
125
121
  summaryEntry.origin_project = home.originProject;
126
- // Schema v25: cache descendant_count + earliest/latest_at on the summary
127
- // row so DAG-aware recall (docs/plans/2026-05-05-dag-recall.md Task 2)
128
- // can reason about scope without walking the children.
122
+ // Cache descendant_count + earliest/latest_at on the summary row so
123
+ // DAG-aware recall can reason about scope without walking the children.
129
124
  summaryEntry.descendant_count = cluster.members.length;
130
125
  summaryEntry.earliest_at = memberCreatedAts[0];
131
126
  summaryEntry.latest_at = memberCreatedAts[memberCreatedAts.length - 1];
@@ -137,21 +132,8 @@ async function summarizeCluster(hippoRoot, cluster, home, opts, result) {
137
132
  if (!summary)
138
133
  return;
139
134
  const summaryEntry = createClusterSummaryEntry(summary, cluster, home);
140
- // AT1 (plan §3 containment): a refused LLM-synthesized summary skips
141
- // ONLY this cluster — the sleep cycle continues to the next one. The
142
- // member re-parenting writes below never run for a skipped cluster
143
- // (there is no summary id to parent them under).
144
- //
145
- // The tombstone check itself is tenant-scoped for free: writeEntry ->
146
- // writeEntryDbOnly -> upsertEntryRow calls
147
- // checkRejectionGuard(db, entry.tenantId ?? 'default', ...)
148
- // (store/entry-writes.ts), reading tenantId off the entry being written. Now
149
- // that summaryEntry carries home.tenantId instead of the implicit
150
- // 'default', the guard consults that tenant's tombstones — no
151
- // separate check needed here (unlike consolidate.ts's merge pass,
152
- // which pre-checks via findRejectedValue because it writes through
153
- // batchWriteAndDelete's bypassRejectionGuard path instead of
154
- // writeEntry).
135
+ // A refused summary skips only this cluster. writeEntry's guard reads tenantId off the entry
136
+ // (home.tenantId), so it already checks this tenant's tombstones: no separate check here.
155
137
  try {
156
138
  writeEntry(hippoRoot, summaryEntry);
157
139
  }
@@ -169,11 +151,8 @@ async function summarizeCluster(hippoRoot, cluster, home, opts, result) {
169
151
  writeEntry(hippoRoot, updated);
170
152
  result.factsLinked++;
171
153
  }
172
- // v0.30 / E3 — cancel the cascade of dirty-marks fired by member
173
- // writeEntry calls (E2 hook on writeEntryDbOnly in store/entry-writes.ts).
174
- // The summary we just built IS fresh, no rebuild needed. Without this,
175
- // E3 in the SAME sleep cycle would re-rebuild every new summary
176
- // (2x LLM cost). plan-eng-r1 HIGH must-fix.
154
+ // Member writes just marked this fresh summary dirty; clear it, or the same sleep cycle's
155
+ // rebuild pass would re-rebuild every new summary at twice the LLM cost.
177
156
  clearSummaryDirtyAfterBuild(hippoRoot, summaryEntry.id, summaryEntry.tenantId, 'buildDag');
178
157
  }
179
158
  export async function buildDag(hippoRoot, facts, opts) {
@@ -248,21 +227,17 @@ async function rebuildOneSummary(hippoRoot, summary, opts, result) {
248
227
  // success, silently skip
249
228
  }
250
229
  /**
251
- * v0.30 / E3 — sleep-cycle phase that drains the dirty L2 summary queue.
230
+ * Sleep-cycle phase that drains the dirty L2 summary queue.
252
231
  * Thin orchestrator; the heavy lifting lives in store.ts (load + apply)
253
232
  * and dag.ts:generateDagSummary (LLM call).
254
233
  *
255
- * Per-summary try/catch isolation (plan-eng-r1 MED must-fix) — one
256
- * throwing rebuild does NOT abort the rest of the queue.
234
+ * Per-summary try/catch: one throwing rebuild does NOT abort the rest of the queue.
257
235
  *
258
236
  * Race-loser handling: applyRebuildResult's UPDATE WHERE includes
259
237
  * AND summary_dirty=1, so concurrent sleep's second writer returns
260
238
  * changed=false. Silent skip (neither rebuilt++ nor refused++ nor failed++).
261
239
  *
262
- * T4: applyRebuildResult returns { changed, refused } — a tombstone hit
263
- * (rebuild content matches a previously-rejected value) increments
264
- * `refused`, not `rebuilt`. Dirty still clears either way; only the stat
265
- * split changed (docs/plans/2026-08-15-hardening-at1-followups.md T4).
240
+ * A tombstone hit counts as `refused`, not `rebuilt`; dirty clears either way.
266
241
  */
267
242
  export async function rebuildDirtySummaries(hippoRoot, opts) {
268
243
  const cap = opts.cap ?? 20;
@@ -291,33 +266,14 @@ export async function rebuildDirtySummaries(hippoRoot, opts) {
291
266
  await rebuildOneSummary(hippoRoot, summary, opts, result);
292
267
  }
293
268
  catch (err) {
294
- // Per-summary failure isolation — one throw doesn't abort the queue.
295
- // independent-review MED #2 fold: log enough to triage in production
296
- // (audit() wraps its own writes try/catch per store/audit-event.ts, so a
297
- // throw here is exotic: SQLite I/O error, prepare failure, etc).
269
+ // One throw doesn't abort the queue; log enough to triage an exotic
270
+ // failure (SQLite I/O, prepare), since audit() already catches its own.
298
271
  result.failed++;
299
272
  log.error(`rebuildDirtySummaries: summary ${summary.id} (tenant ${summary.tenantId}) failed: ${err instanceof Error ? err.message : String(err)}`);
300
273
  }
301
274
  }
302
275
  return result;
303
276
  }
304
- // independent-review HIGH #1 fold: cluster ONLY within-tenant.
305
- // clusterFacts has no tenant awareness; without this partition step a
306
- // multi-tenant host could form a cluster spanning tenants and produce
307
- // a single L3 with tenantId='default' that doesn't belong to either
308
- // child tenant. Fix: bucket by tenantId, run clusterFacts per-tenant,
309
- // pass tenantId to createMemory.
310
- function partitionL2sByTenant(unparented) {
311
- const byTenant = new Map();
312
- for (const l2 of unparented) {
313
- const tid = l2.tenantId ?? 'default';
314
- const key = derivationPartitionKey(tid, l2.scope, l2.origin_project);
315
- const list = byTenant.get(key) ?? [];
316
- list.push(l2);
317
- byTenant.set(key, list);
318
- }
319
- return byTenant;
320
- }
321
277
  /** The L3 profile entry for one cluster of L2 summaries. */
322
278
  function createProfileEntry(summary, cluster, home) {
323
279
  const memberCreatedAts = cluster.members.map((m) => m.created).sort();
@@ -327,7 +283,7 @@ function createProfileEntry(summary, cluster, home) {
327
283
  tags: [...cluster.entityTags, ...neverAutoShareTags(cluster.members), 'dag-entity-profile'],
328
284
  confidence: 'inferred',
329
285
  dag_level: 3,
330
- tenantId: home.tenantId, // HIGH #1 fold: thread tenant explicitly
286
+ tenantId: home.tenantId,
331
287
  scope: home.scope,
332
288
  baseHalfLifeDays: home.baseHalfLifeDays,
333
289
  });
@@ -346,9 +302,7 @@ async function profileCluster(hippoRoot, cluster, home, opts, result) {
346
302
  return;
347
303
  }
348
304
  const profileEntry = createProfileEntry(summary, cluster, home);
349
- // AT1 (plan §3 containment): per-cluster catch — skip this cluster,
350
- // count, log once. The member re-linking writes below never run for a
351
- // skipped cluster (mirrors buildDag above).
305
+ // A refused profile skips only this cluster; the re-linking below never runs for it.
352
306
  try {
353
307
  writeEntry(hippoRoot, profileEntry);
354
308
  }
@@ -366,21 +320,17 @@ async function profileCluster(hippoRoot, cluster, home, opts, result) {
366
320
  writeEntry(hippoRoot, updated);
367
321
  result.l2sLinked++;
368
322
  }
369
- // E3 born-dirty cancellation, same dance as buildDag L161-168 but for
370
- // L3. Pass source='buildEntityProfiles-clean' to distinguish in audit.
371
- // Args: (root, id, tenantId, actor, source).
323
+ // Re-linking just marked the fresh L3 dirty; clear it so this cycle's rebuild skips it.
324
+ // The distinct source tags the audit row.
372
325
  clearSummaryDirtyAfterBuild(hippoRoot, profileEntry.id, home.tenantId, 'buildEntityProfiles', 'buildEntityProfiles-clean');
373
326
  }
374
327
  /**
375
- * v0.30 / E5 — build L3 entity profiles by clustering L2 summaries with
328
+ * Build L3 entity profiles by clustering L2 summaries with
376
329
  * shared entity tags. Threshold 2+ L2s per entity. Mirrors buildDag L1->L2
377
330
  * pattern, one level up.
378
331
  *
379
- * Born-dirty cancellation (E3 lesson): after linking L2 children to the new
380
- * L3 (each link write fires E2 hook on L3 via widened markSummaryDirtyInTx),
381
- * call clearSummaryDirtyAfterBuild with source='buildEntityProfiles-clean'
382
- * so E3 sleep-cycle rebuild doesn't re-rebuild the freshly-built L3 this
383
- * same cycle.
332
+ * Born-dirty cancellation: each L2 link write marks the new L3 dirty, so clear it
333
+ * or the same sleep cycle's rebuild re-rebuilds the freshly built L3.
384
334
  */
385
335
  export async function buildEntityProfiles(hippoRoot, l2Summaries, opts) {
386
336
  const result = {
@@ -393,9 +343,9 @@ export async function buildEntityProfiles(hippoRoot, l2Summaries, opts) {
393
343
  const baseHalfLifeDays = loadConfig(hippoRoot).defaultHalfLifeDays;
394
344
  // Only L2 with no L3 parent yet (avoid re-clustering already-profiled L2s).
395
345
  const unparented = l2Summaries.filter((s) => s.dag_level === 2 && !s.dag_parent_id);
396
- for (const [, tenantL2s] of partitionL2sByTenant(unparented)) {
346
+ for (const [, tenantL2s] of partitionFactsByTenant(unparented)) {
397
347
  const home = {
398
- tenantId: tenantL2s[0].tenantId ?? 'default',
348
+ tenantId: tenantL2s[0].tenantId,
399
349
  scope: derivationScope(tenantL2s[0].scope),
400
350
  originProject: tenantL2s[0].origin_project,
401
351
  baseHalfLifeDays,
@@ -1,5 +1,10 @@
1
1
  import type { DatabaseSyncLike } from './sqlite.js';
2
2
  export declare const MEMORY_QUARANTINE_DDL = "\n CREATE TABLE IF NOT EXISTS memory_quarantine (\n tenant_id TEXT NOT NULL DEFAULT 'default',\n memory_id TEXT NOT NULL,\n original_scope TEXT,\n reason TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','approved','rejected')),\n quarantined_at TEXT NOT NULL,\n decided_at TEXT,\n decided_by TEXT,\n PRIMARY KEY (tenant_id, memory_id)\n ) WITHOUT ROWID;\n CREATE INDEX IF NOT EXISTS idx_memory_quarantine_status\n ON memory_quarantine(tenant_id, status, quarantined_at DESC);\n";
3
3
  export declare function ensureContinuityTables(db: DatabaseSyncLike): void;
4
+ export declare const MEMORIES_FTS_DDL = "CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(id UNINDEXED, content, tags)";
5
+ /** Names of every table and index a `CREATE ... IF NOT EXISTS` in `ddl` makes. */
6
+ export declare function createdObjectNames(ddl: string): string[];
7
+ /** Every object runMigrations re-asserts on a stamped store; the open fast path requires all of them. */
8
+ export declare const REQUIRED_SCHEMA_OBJECTS: readonly string[];
4
9
  export declare function ensureContinuityIndexes(db: DatabaseSyncLike): void;
5
10
  //# sourceMappingURL=continuity.d.ts.map
@@ -1,3 +1,4 @@
1
+ import { META_TABLE_DDL } from './meta.js';
1
2
  // Shared by migration v48 and the stamped-store self-heal below.
2
3
  export const MEMORY_QUARANTINE_DDL = `
3
4
  CREATE TABLE IF NOT EXISTS memory_quarantine (
@@ -112,34 +113,51 @@ const CARD_COMMENTS_DDL = `
112
113
  tenant_id TEXT NOT NULL DEFAULT 'default'
113
114
  )
114
115
  `;
115
- // Before the loop on stamped stores: a table lost after its migration stamped (2026-08-15
116
- // incident) is never re-migrated, and v4/v16/v22 ALTER or read it. Fresh stores use the chain.
116
+ // Before the loop on stamped stores: a table lost after its migration stamped
117
+ // is never re-migrated, and v4/v16/v22 ALTER or read it. Fresh stores use the chain.
117
118
  export function ensureContinuityTables(db) {
118
- db.exec(TASK_SNAPSHOTS_DDL);
119
- db.exec(SESSION_EVENTS_DDL);
120
- db.exec(SESSION_HANDOFFS_DDL);
121
- db.exec(CARDS_DDL);
122
- db.exec(CARD_DEPS_DDL);
123
- db.exec(CARD_RUNS_DDL);
124
- db.exec(CARD_COMMENTS_DDL);
125
- db.exec(MEMORY_QUARANTINE_DDL);
119
+ for (const ddl of CONTINUITY_TABLE_DDL)
120
+ db.exec(ddl);
126
121
  }
122
+ const CONTINUITY_TABLE_DDL = [
123
+ TASK_SNAPSHOTS_DDL,
124
+ SESSION_EVENTS_DDL,
125
+ SESSION_HANDOFFS_DDL,
126
+ CARDS_DDL,
127
+ CARD_DEPS_DDL,
128
+ CARD_RUNS_DDL,
129
+ CARD_COMMENTS_DDL,
130
+ MEMORY_QUARANTINE_DDL,
131
+ ];
132
+ const CONTINUITY_INDEX_DDL = [
133
+ `CREATE INDEX IF NOT EXISTS idx_task_snapshots_status_updated ON task_snapshots(status, updated_at DESC, id DESC)`,
134
+ `CREATE INDEX IF NOT EXISTS idx_task_snapshots_tenant_status ON task_snapshots(tenant_id, status, updated_at DESC)`,
135
+ `CREATE INDEX IF NOT EXISTS idx_task_snapshots_tenant_scope ON task_snapshots(tenant_id, scope, status)`,
136
+ `CREATE INDEX IF NOT EXISTS idx_session_events_session_created ON session_events(session_id, created_at DESC, id DESC)`,
137
+ `CREATE INDEX IF NOT EXISTS idx_session_events_task_created ON session_events(task, created_at DESC, id DESC)`,
138
+ `CREATE INDEX IF NOT EXISTS idx_session_events_tenant_session ON session_events(tenant_id, session_id, created_at DESC, id DESC)`,
139
+ `CREATE INDEX IF NOT EXISTS idx_session_handoffs_session ON session_handoffs(session_id, created_at DESC)`,
140
+ `CREATE INDEX IF NOT EXISTS idx_session_handoffs_tenant_session ON session_handoffs(tenant_id, session_id, created_at DESC)`,
141
+ `CREATE INDEX IF NOT EXISTS idx_session_handoffs_tenant_outcome ON session_handoffs(tenant_id, outcome, created_at DESC)`,
142
+ `CREATE INDEX IF NOT EXISTS idx_cards_tenant_status ON cards(tenant_id, status, updated_at DESC)`,
143
+ `CREATE INDEX IF NOT EXISTS idx_card_deps_tenant_child ON card_deps(tenant_id, child)`,
144
+ `CREATE INDEX IF NOT EXISTS idx_card_deps_tenant_parent ON card_deps(tenant_id, parent)`,
145
+ `CREATE INDEX IF NOT EXISTS idx_card_runs_tenant_card ON card_runs(tenant_id, card, started DESC)`,
146
+ `CREATE INDEX IF NOT EXISTS idx_card_comments_tenant_card ON card_comments(tenant_id, card_id, created_at DESC)`,
147
+ `CREATE INDEX IF NOT EXISTS idx_session_handoffs_tenant_card ON session_handoffs(tenant_id, card_id, created_at DESC)`,
148
+ ];
149
+ // Lives with the other re-asserted DDL so the required-object list below derives from one place.
150
+ export const MEMORIES_FTS_DDL = `CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(id UNINDEXED, content, tags)`;
151
+ const CREATED_NAME = /CREATE\s+(?:VIRTUAL\s+)?(?:TABLE|INDEX)\s+IF\s+NOT\s+EXISTS\s+(\w+)/gi;
152
+ /** Names of every table and index a `CREATE ... IF NOT EXISTS` in `ddl` makes. */
153
+ export function createdObjectNames(ddl) {
154
+ return [...ddl.matchAll(CREATED_NAME)].map((m) => m[1]);
155
+ }
156
+ /** Every object runMigrations re-asserts on a stamped store; the open fast path requires all of them. */
157
+ export const REQUIRED_SCHEMA_OBJECTS = Object.freeze([META_TABLE_DDL, ...CONTINUITY_TABLE_DDL, ...CONTINUITY_INDEX_DDL, MEMORIES_FTS_DDL].flatMap(createdObjectNames));
127
158
  // After the loop: tenant_id (v16) and scope (v23) do not exist yet on a genuine old store.
128
159
  export function ensureContinuityIndexes(db) {
129
- db.exec(`CREATE INDEX IF NOT EXISTS idx_task_snapshots_status_updated ON task_snapshots(status, updated_at DESC, id DESC)`);
130
- db.exec(`CREATE INDEX IF NOT EXISTS idx_task_snapshots_tenant_status ON task_snapshots(tenant_id, status, updated_at DESC)`);
131
- db.exec(`CREATE INDEX IF NOT EXISTS idx_task_snapshots_tenant_scope ON task_snapshots(tenant_id, scope, status)`);
132
- db.exec(`CREATE INDEX IF NOT EXISTS idx_session_events_session_created ON session_events(session_id, created_at DESC, id DESC)`);
133
- db.exec(`CREATE INDEX IF NOT EXISTS idx_session_events_task_created ON session_events(task, created_at DESC, id DESC)`);
134
- db.exec(`CREATE INDEX IF NOT EXISTS idx_session_events_tenant_session ON session_events(tenant_id, session_id, created_at DESC, id DESC)`);
135
- db.exec(`CREATE INDEX IF NOT EXISTS idx_session_handoffs_session ON session_handoffs(session_id, created_at DESC)`);
136
- db.exec(`CREATE INDEX IF NOT EXISTS idx_session_handoffs_tenant_session ON session_handoffs(tenant_id, session_id, created_at DESC)`);
137
- db.exec(`CREATE INDEX IF NOT EXISTS idx_session_handoffs_tenant_outcome ON session_handoffs(tenant_id, outcome, created_at DESC)`);
138
- db.exec(`CREATE INDEX IF NOT EXISTS idx_cards_tenant_status ON cards(tenant_id, status, updated_at DESC)`);
139
- db.exec(`CREATE INDEX IF NOT EXISTS idx_card_deps_tenant_child ON card_deps(tenant_id, child)`);
140
- db.exec(`CREATE INDEX IF NOT EXISTS idx_card_deps_tenant_parent ON card_deps(tenant_id, parent)`);
141
- db.exec(`CREATE INDEX IF NOT EXISTS idx_card_runs_tenant_card ON card_runs(tenant_id, card, started DESC)`);
142
- db.exec(`CREATE INDEX IF NOT EXISTS idx_card_comments_tenant_card ON card_comments(tenant_id, card_id, created_at DESC)`);
143
- db.exec(`CREATE INDEX IF NOT EXISTS idx_session_handoffs_tenant_card ON session_handoffs(tenant_id, card_id, created_at DESC)`);
160
+ for (const ddl of CONTINUITY_INDEX_DDL)
161
+ db.exec(ddl);
144
162
  }
145
163
  //# sourceMappingURL=continuity.js.map
package/dist/db/meta.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { DatabaseSyncLike } from './sqlite.js';
2
+ export declare const META_TABLE_DDL = "\n CREATE TABLE IF NOT EXISTS meta (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL\n );\n ";
2
3
  export declare function ensureMetaTable(db: DatabaseSyncLike): void;
3
4
  export declare function getSchemaVersion(db: DatabaseSyncLike): number;
4
5
  export declare function setSchemaVersion(db: DatabaseSyncLike, version: number): void;
package/dist/db/meta.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import { tableExists } from './tables.js';
2
- export function ensureMetaTable(db) {
3
- db.exec(`
2
+ export const META_TABLE_DDL = `
4
3
  CREATE TABLE IF NOT EXISTS meta (
5
4
  key TEXT PRIMARY KEY,
6
5
  value TEXT NOT NULL
7
6
  );
8
- `);
7
+ `;
8
+ export function ensureMetaTable(db) {
9
+ db.exec(META_TABLE_DDL);
9
10
  }
10
11
  export function getSchemaVersion(db) {
11
12
  if (!tableExists(db, 'meta'))
@@ -5,5 +5,14 @@ export declare class IncompatibleBinaryError extends Error {
5
5
  }
6
6
  /** Refuse a store stamped for a newer binary. Fails closed: runMigrations creates meta first, so a failed read is a real error. */
7
7
  export declare function assertBinaryCompatible(db: DatabaseSyncLike): void;
8
+ /** The fast path's single probe: versions, the binary minimum, the FTS flag, and counts of the meta defaults and required objects. */
9
+ export declare const SCHEMA_PROBE_SQL: string;
8
10
  export declare function runMigrations(db: DatabaseSyncLike, hippoRoot?: string, busyWaitMs?: number): void;
11
+ /** Row counts of `memories` and its full-text index, which differ once the index drifts; null when either table is missing. */
12
+ export declare function ftsRowCounts(db: DatabaseSyncLike): {
13
+ memories: number;
14
+ fts: number;
15
+ } | null;
16
+ /** Re-syncs `memories_fts` with `memories` when their counts differ and returns whether it did; sleep runs it because a fast open does not. */
17
+ export declare function repairFtsDrift(db: DatabaseSyncLike): boolean;
9
18
  //# sourceMappingURL=migrate.d.ts.map
@@ -4,7 +4,7 @@ import { importLegacyEmbeddingIndex } from '../vector-store.js';
4
4
  import { execWithBusyRetry } from './busy.js';
5
5
  import { tableExists } from './tables.js';
6
6
  import { ensureMetaTable, getMeta, getSchemaVersion, setMeta, setSchemaVersion } from './meta.js';
7
- import { ensureContinuityIndexes, ensureContinuityTables } from './continuity.js';
7
+ import { MEMORIES_FTS_DDL, REQUIRED_SCHEMA_OBJECTS, ensureContinuityIndexes, ensureContinuityTables } from './continuity.js';
8
8
  import { CURRENT_SCHEMA_VERSION, MIGRATIONS } from './migrations/index.js';
9
9
  export function getCurrentSchemaVersion() {
10
10
  return CURRENT_SCHEMA_VERSION;
@@ -12,15 +12,64 @@ export function getCurrentSchemaVersion() {
12
12
  /** Thrown by {@link assertBinaryCompatible}; doctor uses it to pick the upgrade fix over a generic permissions fix. */
13
13
  export class IncompatibleBinaryError extends Error {
14
14
  }
15
- /** Refuse a store stamped for a newer binary. Fails closed: runMigrations creates meta first, so a failed read is a real error. */
16
- export function assertBinaryCompatible(db) {
17
- const minRequired = getMeta(db, 'min_compatible_binary');
15
+ function assertMinBinary(minRequired) {
18
16
  if (minRequired && compareSemver(minRequired, PACKAGE_VERSION) > 0) {
19
17
  throw new IncompatibleBinaryError(`hippo-memory: this database requires hippo-memory >= ${minRequired}, but the running binary is ${PACKAGE_VERSION}. ` +
20
18
  `Upgrade hippo-memory to open it; an older binary does not know this schema and could expose private rows or damage the store.`);
21
19
  }
22
20
  }
21
+ /** Refuse a store stamped for a newer binary. Fails closed: runMigrations creates meta first, so a failed read is a real error. */
22
+ export function assertBinaryCompatible(db) {
23
+ assertMinBinary(getMeta(db, 'min_compatible_binary'));
24
+ }
25
+ const META_DEFAULTS = [
26
+ ['schema_version', String(CURRENT_SCHEMA_VERSION)],
27
+ ['last_retrieval_ids', '[]'],
28
+ ['last_trace_id', ''],
29
+ ['total_remembered', '0'],
30
+ ['total_recalled', '0'],
31
+ ['total_forgotten', '0'],
32
+ ['fts5_available', '0'],
33
+ ];
34
+ // Names come from our own constants and match \w+, so inlining them as literals is safe.
35
+ const sqlList = (names) => names.map((n) => `'${n}'`).join(', ');
36
+ /** The fast path's single probe: versions, the binary minimum, the FTS flag, and counts of the meta defaults and required objects. */
37
+ export const SCHEMA_PROBE_SQL = `SELECT
38
+ (SELECT user_version FROM pragma_user_version) AS user_version,
39
+ (SELECT value FROM meta WHERE key = 'schema_version') AS schema_version,
40
+ (SELECT value FROM meta WHERE key = 'min_compatible_binary') AS min_binary,
41
+ (SELECT value FROM meta WHERE key = 'fts5_available') AS fts5,
42
+ (SELECT COUNT(*) FROM meta WHERE key IN (${sqlList(META_DEFAULTS.map(([key]) => key))})) AS meta_keys,
43
+ (SELECT COUNT(*) FROM sqlite_master WHERE type IN ('table', 'index') AND name IN (${sqlList(REQUIRED_SCHEMA_OBJECTS)})) AS objects`;
44
+ /** True when the store is current and whole, so the open can skip every DDL statement. Runs the binary check either way. */
45
+ function schemaIsCurrent(db) {
46
+ let probe;
47
+ try {
48
+ // SAFETY: the SELECT names exactly these six columns and always returns one row.
49
+ probe = db.prepare(SCHEMA_PROBE_SQL).get();
50
+ }
51
+ catch (err) {
52
+ // A store with no meta table yet is the slow path's job; anything else is a real error.
53
+ if (err instanceof Error && /no such table: meta/.test(err.message))
54
+ return false;
55
+ throw err;
56
+ }
57
+ if (!probe)
58
+ return false;
59
+ assertMinBinary(probe.min_binary);
60
+ return Number(probe.user_version) === CURRENT_SCHEMA_VERSION
61
+ && Number(probe.schema_version) === CURRENT_SCHEMA_VERSION
62
+ && Number(probe.meta_keys) === META_DEFAULTS.length
63
+ && Number(probe.objects) === REQUIRED_SCHEMA_OBJECTS.length
64
+ && probe.fts5 === '1';
65
+ }
23
66
  export function runMigrations(db, hippoRoot, busyWaitMs) {
67
+ if (!schemaIsCurrent(db))
68
+ migrateAndHeal(db, hippoRoot, busyWaitMs);
69
+ if (hippoRoot)
70
+ importLegacyVectors(db, hippoRoot);
71
+ }
72
+ function migrateAndHeal(db, hippoRoot, busyWaitMs) {
24
73
  ensureMetaTable(db);
25
74
  // Before anything writes, so a stale binary never repairs or migrates a store it does not understand.
26
75
  assertBinaryCompatible(db);
@@ -30,36 +79,46 @@ export function runMigrations(db, hippoRoot, busyWaitMs) {
30
79
  for (const migration of MIGRATIONS) {
31
80
  if (migration.version <= currentVersion)
32
81
  continue;
33
- execWithBusyRetry(db, 'BEGIN IMMEDIATE', busyWaitMs);
34
- try {
35
- // A newer binary may have migrated and raised the minimum while we waited for the lock.
36
- assertBinaryCompatible(db);
37
- // Re-read under the write lock: another process may have applied this
38
- // migration while we waited, and re-running one is not idempotent.
39
- const applied = getSchemaVersion(db);
40
- if (applied >= migration.version) {
41
- db.exec('COMMIT');
42
- currentVersion = applied;
43
- continue;
44
- }
45
- migration.up(db, { hippoRoot });
46
- setSchemaVersion(db, migration.version);
47
- db.exec('COMMIT');
48
- currentVersion = migration.version;
49
- }
50
- catch (error) {
51
- try {
52
- db.exec('ROLLBACK');
53
- }
54
- catch { /* already rolled back; keep the original error */ }
55
- throw error;
56
- }
82
+ currentVersion = applyMigration(db, migration, hippoRoot, busyWaitMs);
57
83
  }
84
+ healUserVersion(db, currentVersion, busyWaitMs);
58
85
  ensureContinuityIndexes(db);
59
86
  ensureMetaDefaults(db);
60
87
  ensureOptionalFts(db);
61
- if (hippoRoot)
62
- importLegacyVectors(db, hippoRoot);
88
+ }
89
+ /** Applies one migration under the write lock and returns the version the store is now at. */
90
+ function applyMigration(db, migration, hippoRoot, busyWaitMs) {
91
+ execWithBusyRetry(db, 'BEGIN IMMEDIATE', busyWaitMs);
92
+ try {
93
+ // A newer binary may have migrated and raised the minimum while we waited for the lock.
94
+ assertBinaryCompatible(db);
95
+ // Re-read under the write lock: another process may have applied this
96
+ // migration while we waited, and re-running one is not idempotent.
97
+ const applied = getSchemaVersion(db);
98
+ if (applied >= migration.version) {
99
+ db.exec('COMMIT');
100
+ return applied;
101
+ }
102
+ migration.up(db, { hippoRoot });
103
+ setSchemaVersion(db, migration.version);
104
+ db.exec('COMMIT');
105
+ return migration.version;
106
+ }
107
+ catch (error) {
108
+ try {
109
+ db.exec('ROLLBACK');
110
+ }
111
+ catch { /* already rolled back; keep the original error */ }
112
+ throw error;
113
+ }
114
+ }
115
+ // The fast path requires user_version as well, and a store stamped only in meta would otherwise never reach it.
116
+ function healUserVersion(db, version, busyWaitMs) {
117
+ // SAFETY: PRAGMA user_version returns one row with one integer column.
118
+ const row = db.prepare('PRAGMA user_version').get();
119
+ const target = Math.max(0, Math.trunc(version));
120
+ if (Number(row?.user_version ?? 0) < target)
121
+ execWithBusyRetry(db, `PRAGMA user_version = ${target}`, busyWaitMs);
63
122
  }
64
123
  // A failed import leaves embeddings.json in place for the next open to retry; the store still opens.
65
124
  function importLegacyVectors(db, hippoRoot) {
@@ -71,27 +130,18 @@ function importLegacyVectors(db, hippoRoot) {
71
130
  }
72
131
  }
73
132
  function ensureMetaDefaults(db) {
74
- const defaults = [
75
- ['schema_version', String(CURRENT_SCHEMA_VERSION)],
76
- ['last_retrieval_ids', '[]'],
77
- ['last_trace_id', ''],
78
- ['total_remembered', '0'],
79
- ['total_recalled', '0'],
80
- ['total_forgotten', '0'],
81
- ['fts5_available', '0'],
82
- ];
83
133
  // Read-first: an already-current store has all 7 keys, so this is one
84
134
  // SELECT and zero writes instead of 7 no-op INSERT OR IGNOREs, each of
85
135
  // which takes a RESERVED lock even when nothing changes.
86
- const keys = defaults.map(([key]) => key);
136
+ const keys = META_DEFAULTS.map(([key]) => key);
87
137
  const present = new Set(db
88
138
  .prepare(`SELECT key FROM meta WHERE key IN (${keys.map(() => '?').join(',')})`)
89
139
  .all(...keys)
90
140
  .map((r) => r.key));
91
- if (present.size === defaults.length)
141
+ if (present.size === META_DEFAULTS.length)
92
142
  return;
93
143
  const stmt = db.prepare(`INSERT OR IGNORE INTO meta(key, value) VALUES (?, ?)`);
94
- for (const [key, value] of defaults) {
144
+ for (const [key, value] of META_DEFAULTS) {
95
145
  if (!present.has(key))
96
146
  stmt.run(key, value);
97
147
  }
@@ -99,8 +149,8 @@ function ensureMetaDefaults(db) {
99
149
  function ensureOptionalFts(db) {
100
150
  let available = false;
101
151
  try {
102
- db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(id UNINDEXED, content, tags)`);
103
- backfillFtsIndex(db);
152
+ db.exec(MEMORIES_FTS_DDL);
153
+ repairFtsDrift(db);
104
154
  available = true;
105
155
  }
106
156
  catch (err) {
@@ -112,16 +162,23 @@ function ensureOptionalFts(db) {
112
162
  if (getMeta(db, 'fts5_available') !== flag)
113
163
  setMeta(db, 'fts5_available', flag);
114
164
  }
115
- function backfillFtsIndex(db) {
116
- // SAFETY: this get() result's shape matches the single aliased `c` column
117
- // named in the SELECT above.
118
- const memCount = db.prepare(`SELECT COUNT(*) AS c FROM memories`).get()?.c ?? 0;
119
- // SAFETY: this get() result's shape matches the single aliased `c` column named in the SELECT above.
165
+ /** Row counts of `memories` and its full-text index, which differ once the index drifts; null when either table is missing. */
166
+ export function ftsRowCounts(db) {
167
+ if (!tableExists(db, 'memories_fts') || !tableExists(db, 'memories'))
168
+ return null;
169
+ // SAFETY: this get() result's shape matches the single aliased `c` column named in the SELECT.
170
+ const memories = db.prepare(`SELECT COUNT(*) AS c FROM memories`).get()?.c ?? 0;
120
171
  // memories_fts_docsize is FTS5's cheaper one-row-per-document shadow table; fall back for a foreign-built index.
121
172
  const ftsTable = tableExists(db, 'memories_fts_docsize') ? 'memories_fts_docsize' : 'memories_fts';
122
- const ftsCount = db.prepare(`SELECT COUNT(*) AS c FROM ${ftsTable}`).get()?.c ?? 0;
123
- if (memCount === ftsCount)
124
- return;
173
+ // SAFETY: this get() result's shape matches the single aliased `c` column named in the SELECT.
174
+ const fts = db.prepare(`SELECT COUNT(*) AS c FROM ${ftsTable}`).get()?.c ?? 0;
175
+ return { memories: Number(memories), fts: Number(fts) };
176
+ }
177
+ /** Re-syncs `memories_fts` with `memories` when their counts differ and returns whether it did; sleep runs it because a fast open does not. */
178
+ export function repairFtsDrift(db) {
179
+ const counts = ftsRowCounts(db);
180
+ if (counts === null || counts.memories === counts.fts)
181
+ return false;
125
182
  db.exec(`
126
183
  INSERT INTO memories_fts(id, content, tags)
127
184
  SELECT m.id, m.content, m.tags_json
@@ -134,5 +191,6 @@ function backfillFtsIndex(db) {
134
191
  DELETE FROM memories_fts
135
192
  WHERE id NOT IN (SELECT id FROM memories)
136
193
  `);
194
+ return true;
137
195
  }
138
196
  //# sourceMappingURL=migrate.js.map
@@ -2,7 +2,7 @@ import { tableHasColumn } from '../tables.js';
2
2
  export const v14 = {
3
3
  version: 14,
4
4
  up: (db) => {
5
- // A3 provenance envelope: kind, scope, owner, artifact_ref.
5
+ // Provenance envelope: kind, scope, owner, artifact_ref.
6
6
  // SQLite ALTER TABLE ADD COLUMN cannot add CHECK; CHECK enforcement lives
7
7
  // in INSERT/UPDATE triggers added later in this migration.
8
8
  if (!tableHasColumn(db, 'memories', 'kind')) {
@@ -1,8 +1,7 @@
1
1
  export const v15 = {
2
2
  version: 15,
3
3
  up: (db) => {
4
- // A3 hardening (post-review): close the NULL-kind bypass and add raw_archive
5
- // dedup safety. Both findings landed in /review on commits 41b1f4d..6456e7d.
4
+ // Close the NULL-kind bypass and add raw_archive dedup safety.
6
5
  //
7
6
  // (1) Original v14 triggers used `WHEN NEW.kind IS NOT NULL AND NEW.kind NOT IN (...)`.
8
7
  // A direct INSERT/UPDATE setting kind=NULL bypassed the CHECK substitute. Replace
@@ -2,9 +2,8 @@ import { tableHasColumn } from '../tables.js';
2
2
  export const v16 = {
3
3
  version: 16,
4
4
  up: (db) => {
5
- // A5 stub auth: add tenant_id to all data tables. Single-tenant per deployment;
6
- // multi-tenant enforcement deferred to v2 (full A5). The columns are needed now
7
- // so future B-track tables don't have to backfill.
5
+ // Stub auth: add tenant_id to all data tables. Single-tenant per deployment for now;
6
+ // the columns land early so later tables don't have to backfill.
8
7
  if (!tableHasColumn(db, 'memories', 'tenant_id')) {
9
8
  db.exec(`ALTER TABLE memories ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default'`);
10
9
  }
@@ -26,7 +25,7 @@ export const v16 = {
26
25
  db.exec(`CREATE INDEX IF NOT EXISTS idx_working_memory_tenant ON working_memory(tenant_id, importance DESC, created_at DESC)`);
27
26
  db.exec(`CREATE INDEX IF NOT EXISTS idx_consolidation_runs_tenant_ts ON consolidation_runs(tenant_id, timestamp DESC)`);
28
27
  db.exec(`CREATE INDEX IF NOT EXISTS idx_task_snapshots_tenant_status ON task_snapshots(tenant_id, status, updated_at DESC)`);
29
- // A5 stub auth: api_keys (scrypt-hashed; plaintext returned to caller exactly once)
28
+ // Stub auth: api_keys (scrypt-hashed; plaintext returned to caller exactly once)
30
29
  // and audit_log (append-only mutation trail). Both carry tenant_id from day 1 so
31
30
  // future multi-tenant enforcement is a config flip, not a re-migration.
32
31
  db.exec(`
@@ -1,8 +1,7 @@
1
1
  export const v17 = {
2
2
  version: 17,
3
3
  up: (db) => {
4
- // E1.3 Slack ingestion: idempotency log, per-channel backfill cursors, DLQ.
5
- // See docs/plans/2026-04-29-e1.3-slack-ingestion.md.
4
+ // Slack ingestion: idempotency log, per-channel backfill cursors, DLQ.
6
5
  db.exec(`
7
6
  CREATE TABLE IF NOT EXISTS slack_event_log (
8
7
  event_id TEXT PRIMARY KEY,
@@ -31,7 +30,7 @@ export const v17 = {
31
30
  )
32
31
  `);
33
32
  db.exec(`CREATE INDEX IF NOT EXISTS idx_slack_dlq_tenant_received ON slack_dlq(tenant_id, received_at)`);
34
- // Multi-tenant routing seam (review patch #6). Empty by default — single-
33
+ // Multi-tenant routing seam. Empty by default: single-
35
34
  // tenant deployments resolve via HIPPO_TENANT fallback. Multi-workspace
36
35
  // deployments populate this table to map team_id → tenant_id.
37
36
  db.exec(`
@@ -2,7 +2,7 @@ import { tableHasColumn } from '../tables.js';
2
2
  export const v19 = {
3
3
  version: 19,
4
4
  up: (db) => {
5
- // v0.39 commit 3 (Slack hardening): widen slack_dlq with bucketing,
5
+ // Slack hardening: widen slack_dlq with bucketing,
6
6
  // retry tracking, and the signature/timestamp pair that lets `hippo
7
7
  // slack dlq replay` re-verify before re-running ingest. ALTER ADD
8
8
  // COLUMN with DEFAULT is non-destructive — legacy rows take the
@@ -1,7 +1,7 @@
1
1
  export const v20 = {
2
2
  version: 20,
3
3
  up: (db) => {
4
- // v0.39 commit 4 (GDPR Path A backfill): redact every existing
4
+ // GDPR Path A backfill: redact every existing
5
5
  // raw_archive.payload_json so historical archives match the new
6
6
  // metadata-only contract from src/raw-archive.ts. Read each row, parse
7
7
  // the existing JSON to extract tenant_id and kind (best effort), then