hippo-memory 1.44.0 → 1.46.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.
Files changed (554) hide show
  1. package/README.md +68 -24
  2. package/bin/hippo.js +3 -1
  3. package/dist/ablation.d.ts +10 -1
  4. package/dist/ablation.js +17 -1
  5. package/dist/api.d.ts +81 -22
  6. package/dist/api.js +258 -49
  7. package/dist/audit.d.ts +2 -2
  8. package/dist/audit.js +9 -0
  9. package/dist/autolearn.d.ts +1 -1
  10. package/dist/autolearn.js +2 -1
  11. package/dist/capture-error.d.ts +20 -0
  12. package/dist/capture-error.js +82 -0
  13. package/dist/capture.d.ts +25 -8
  14. package/dist/capture.js +100 -5
  15. package/dist/cli.d.ts +9 -1
  16. package/dist/cli.js +531 -119
  17. package/dist/client.d.ts +4 -21
  18. package/dist/client.js +3 -112
  19. package/dist/config.d.ts +20 -0
  20. package/dist/config.js +37 -1
  21. package/dist/consolidate.d.ts +9 -0
  22. package/dist/consolidate.js +133 -46
  23. package/dist/dag.d.ts +1 -0
  24. package/dist/dag.js +10 -5
  25. package/dist/dashboard.js +7 -3
  26. package/dist/db.js +69 -24
  27. package/dist/dedupe.d.ts +1 -0
  28. package/dist/dedupe.js +4 -7
  29. package/dist/doctor.d.ts +34 -0
  30. package/dist/doctor.js +174 -0
  31. package/dist/dormant.d.ts +91 -0
  32. package/dist/dormant.js +121 -0
  33. package/dist/embedding-provider.js +2 -1
  34. package/dist/embeddings.js +81 -8
  35. package/dist/eval-stats.d.ts +123 -0
  36. package/dist/eval-stats.js +187 -0
  37. package/dist/extract.d.ts +2 -0
  38. package/dist/extract.js +9 -4
  39. package/dist/half-life-migration.d.ts +55 -0
  40. package/dist/half-life-migration.js +111 -0
  41. package/dist/hooks.d.ts +4 -0
  42. package/dist/hooks.js +47 -0
  43. package/dist/mcp/server.d.ts +6 -0
  44. package/dist/mcp/server.js +90 -48
  45. package/dist/memory.d.ts +18 -1
  46. package/dist/memory.js +33 -5
  47. package/dist/physics-config.js +5 -1
  48. package/dist/recall-scope.d.ts +24 -0
  49. package/dist/recall-scope.js +41 -0
  50. package/dist/refine-llm.js +3 -2
  51. package/dist/reject-flow.d.ts +3 -3
  52. package/dist/reject-flow.js +10 -3
  53. package/dist/rerankers/jev.js +3 -2
  54. package/dist/rerankers/llm.js +3 -2
  55. package/dist/search.d.ts +4 -4
  56. package/dist/search.js +26 -21
  57. package/dist/server.d.ts +5 -0
  58. package/dist/server.js +49 -29
  59. package/dist/shared.js +1 -1
  60. package/dist/store.d.ts +29 -11
  61. package/dist/store.js +197 -70
  62. package/dist/token-ledger.d.ts +119 -0
  63. package/dist/token-ledger.js +181 -0
  64. package/dist/version.d.ts +4 -6
  65. package/dist/version.js +4 -7
  66. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  67. package/extensions/openclaw-plugin/package.json +1 -1
  68. package/openclaw.plugin.json +1 -1
  69. package/package.json +7 -3
  70. package/dist/ablation.d.ts.map +0 -1
  71. package/dist/ablation.js.map +0 -1
  72. package/dist/ambient.d.ts.map +0 -1
  73. package/dist/ambient.js.map +0 -1
  74. package/dist/api.d.ts.map +0 -1
  75. package/dist/api.js.map +0 -1
  76. package/dist/audit-prune.d.ts.map +0 -1
  77. package/dist/audit-prune.js.map +0 -1
  78. package/dist/audit.d.ts.map +0 -1
  79. package/dist/audit.js.map +0 -1
  80. package/dist/auth.d.ts.map +0 -1
  81. package/dist/auth.js.map +0 -1
  82. package/dist/autolearn.d.ts.map +0 -1
  83. package/dist/autolearn.js.map +0 -1
  84. package/dist/availability.d.ts.map +0 -1
  85. package/dist/availability.js.map +0 -1
  86. package/dist/benchmarks/e1.3/incident-recall-eval.js +0 -78
  87. package/dist/benchmarks/e1.3/incident-recall-eval.js.map +0 -1
  88. package/dist/benchmarks/e1.3/slack-1000-event-smoke.js +0 -103
  89. package/dist/benchmarks/e1.3/slack-1000-event-smoke.js.map +0 -1
  90. package/dist/capture.d.ts.map +0 -1
  91. package/dist/capture.js.map +0 -1
  92. package/dist/card-detail.d.ts.map +0 -1
  93. package/dist/card-detail.js.map +0 -1
  94. package/dist/card.d.ts.map +0 -1
  95. package/dist/card.js.map +0 -1
  96. package/dist/cli.d.ts.map +0 -1
  97. package/dist/cli.js.map +0 -1
  98. package/dist/client.d.ts.map +0 -1
  99. package/dist/client.js.map +0 -1
  100. package/dist/compare.d.ts.map +0 -1
  101. package/dist/compare.js.map +0 -1
  102. package/dist/config.d.ts.map +0 -1
  103. package/dist/config.js.map +0 -1
  104. package/dist/connectors/github/backfill.d.ts.map +0 -1
  105. package/dist/connectors/github/backfill.js.map +0 -1
  106. package/dist/connectors/github/cli-impl.d.ts.map +0 -1
  107. package/dist/connectors/github/cli-impl.js.map +0 -1
  108. package/dist/connectors/github/deletion.d.ts.map +0 -1
  109. package/dist/connectors/github/deletion.js.map +0 -1
  110. package/dist/connectors/github/dlq.d.ts.map +0 -1
  111. package/dist/connectors/github/dlq.js.map +0 -1
  112. package/dist/connectors/github/idempotency.d.ts.map +0 -1
  113. package/dist/connectors/github/idempotency.js.map +0 -1
  114. package/dist/connectors/github/ingest.d.ts.map +0 -1
  115. package/dist/connectors/github/ingest.js.map +0 -1
  116. package/dist/connectors/github/octokit-client.d.ts.map +0 -1
  117. package/dist/connectors/github/octokit-client.js.map +0 -1
  118. package/dist/connectors/github/ratelimit.d.ts.map +0 -1
  119. package/dist/connectors/github/ratelimit.js.map +0 -1
  120. package/dist/connectors/github/scope.d.ts.map +0 -1
  121. package/dist/connectors/github/scope.js.map +0 -1
  122. package/dist/connectors/github/signature.d.ts.map +0 -1
  123. package/dist/connectors/github/signature.js.map +0 -1
  124. package/dist/connectors/github/tenant-routing.d.ts.map +0 -1
  125. package/dist/connectors/github/tenant-routing.js.map +0 -1
  126. package/dist/connectors/github/transform.d.ts.map +0 -1
  127. package/dist/connectors/github/transform.js.map +0 -1
  128. package/dist/connectors/github/types.d.ts.map +0 -1
  129. package/dist/connectors/github/types.js.map +0 -1
  130. package/dist/connectors/slack/backfill.d.ts.map +0 -1
  131. package/dist/connectors/slack/backfill.js.map +0 -1
  132. package/dist/connectors/slack/deletion.d.ts.map +0 -1
  133. package/dist/connectors/slack/deletion.js.map +0 -1
  134. package/dist/connectors/slack/dlq.d.ts.map +0 -1
  135. package/dist/connectors/slack/dlq.js.map +0 -1
  136. package/dist/connectors/slack/idempotency.d.ts.map +0 -1
  137. package/dist/connectors/slack/idempotency.js.map +0 -1
  138. package/dist/connectors/slack/ingest.d.ts.map +0 -1
  139. package/dist/connectors/slack/ingest.js.map +0 -1
  140. package/dist/connectors/slack/ratelimit.d.ts.map +0 -1
  141. package/dist/connectors/slack/ratelimit.js.map +0 -1
  142. package/dist/connectors/slack/scope.d.ts.map +0 -1
  143. package/dist/connectors/slack/scope.js.map +0 -1
  144. package/dist/connectors/slack/signature.d.ts.map +0 -1
  145. package/dist/connectors/slack/signature.js.map +0 -1
  146. package/dist/connectors/slack/tenant-routing.d.ts.map +0 -1
  147. package/dist/connectors/slack/tenant-routing.js.map +0 -1
  148. package/dist/connectors/slack/transform.d.ts.map +0 -1
  149. package/dist/connectors/slack/transform.js.map +0 -1
  150. package/dist/connectors/slack/types.d.ts.map +0 -1
  151. package/dist/connectors/slack/types.js.map +0 -1
  152. package/dist/connectors/slack/web-client.d.ts.map +0 -1
  153. package/dist/connectors/slack/web-client.js.map +0 -1
  154. package/dist/connectors/slack/workspaces.d.ts.map +0 -1
  155. package/dist/connectors/slack/workspaces.js.map +0 -1
  156. package/dist/consolidate.d.ts.map +0 -1
  157. package/dist/consolidate.js.map +0 -1
  158. package/dist/correction-latency.d.ts.map +0 -1
  159. package/dist/correction-latency.js.map +0 -1
  160. package/dist/customer-notes.d.ts.map +0 -1
  161. package/dist/customer-notes.js.map +0 -1
  162. package/dist/dag.d.ts.map +0 -1
  163. package/dist/dag.js.map +0 -1
  164. package/dist/dashboard.d.ts.map +0 -1
  165. package/dist/dashboard.js.map +0 -1
  166. package/dist/db.d.ts.map +0 -1
  167. package/dist/db.js.map +0 -1
  168. package/dist/decisions.d.ts.map +0 -1
  169. package/dist/decisions.js.map +0 -1
  170. package/dist/dedupe.d.ts.map +0 -1
  171. package/dist/dedupe.js.map +0 -1
  172. package/dist/embedding-provider.d.ts.map +0 -1
  173. package/dist/embedding-provider.js.map +0 -1
  174. package/dist/embeddings.d.ts.map +0 -1
  175. package/dist/embeddings.js.map +0 -1
  176. package/dist/eval-suite.d.ts.map +0 -1
  177. package/dist/eval-suite.js.map +0 -1
  178. package/dist/eval.d.ts.map +0 -1
  179. package/dist/eval.js.map +0 -1
  180. package/dist/extensions/openclaw-plugin/index.js.map +0 -1
  181. package/dist/extract.d.ts.map +0 -1
  182. package/dist/extract.js.map +0 -1
  183. package/dist/forward-claim-detector.d.ts.map +0 -1
  184. package/dist/forward-claim-detector.js.map +0 -1
  185. package/dist/goals.d.ts.map +0 -1
  186. package/dist/goals.js.map +0 -1
  187. package/dist/graph-extract.d.ts.map +0 -1
  188. package/dist/graph-extract.js.map +0 -1
  189. package/dist/graph-recall.d.ts.map +0 -1
  190. package/dist/graph-recall.js.map +0 -1
  191. package/dist/graph-stream.d.ts.map +0 -1
  192. package/dist/graph-stream.js.map +0 -1
  193. package/dist/graph-view.d.ts.map +0 -1
  194. package/dist/graph-view.js.map +0 -1
  195. package/dist/graph.d.ts.map +0 -1
  196. package/dist/graph.js.map +0 -1
  197. package/dist/handoff.d.ts.map +0 -1
  198. package/dist/handoff.js.map +0 -1
  199. package/dist/hooks.d.ts.map +0 -1
  200. package/dist/hooks.js.map +0 -1
  201. package/dist/importers.d.ts.map +0 -1
  202. package/dist/importers.js.map +0 -1
  203. package/dist/incidents.d.ts.map +0 -1
  204. package/dist/incidents.js.map +0 -1
  205. package/dist/index.d.ts.map +0 -1
  206. package/dist/index.js.map +0 -1
  207. package/dist/invalidation.d.ts.map +0 -1
  208. package/dist/invalidation.js.map +0 -1
  209. package/dist/mcp/framing.d.ts.map +0 -1
  210. package/dist/mcp/framing.js.map +0 -1
  211. package/dist/mcp/server.d.ts.map +0 -1
  212. package/dist/mcp/server.js.map +0 -1
  213. package/dist/memory-value-weights.d.ts.map +0 -1
  214. package/dist/memory-value-weights.js.map +0 -1
  215. package/dist/memory-value.d.ts.map +0 -1
  216. package/dist/memory-value.js.map +0 -1
  217. package/dist/memory.d.ts.map +0 -1
  218. package/dist/memory.js.map +0 -1
  219. package/dist/multihop.d.ts.map +0 -1
  220. package/dist/multihop.js.map +0 -1
  221. package/dist/owner-validation.d.ts.map +0 -1
  222. package/dist/owner-validation.js.map +0 -1
  223. package/dist/path-context.d.ts.map +0 -1
  224. package/dist/path-context.js.map +0 -1
  225. package/dist/physics-config.d.ts.map +0 -1
  226. package/dist/physics-config.js.map +0 -1
  227. package/dist/physics-state.d.ts.map +0 -1
  228. package/dist/physics-state.js.map +0 -1
  229. package/dist/physics.d.ts.map +0 -1
  230. package/dist/physics.js.map +0 -1
  231. package/dist/policies.d.ts.map +0 -1
  232. package/dist/policies.js.map +0 -1
  233. package/dist/postinstall.d.ts.map +0 -1
  234. package/dist/postinstall.js.map +0 -1
  235. package/dist/predictions.d.ts.map +0 -1
  236. package/dist/predictions.js.map +0 -1
  237. package/dist/processes.d.ts.map +0 -1
  238. package/dist/processes.js.map +0 -1
  239. package/dist/project-briefs.d.ts.map +0 -1
  240. package/dist/project-briefs.js.map +0 -1
  241. package/dist/project-identity.d.ts.map +0 -1
  242. package/dist/project-identity.js.map +0 -1
  243. package/dist/provenance-coverage.d.ts.map +0 -1
  244. package/dist/provenance-coverage.js.map +0 -1
  245. package/dist/rate-limit.d.ts.map +0 -1
  246. package/dist/rate-limit.js.map +0 -1
  247. package/dist/raw-archive-mirror-cleanup.d.ts.map +0 -1
  248. package/dist/raw-archive-mirror-cleanup.js.map +0 -1
  249. package/dist/raw-archive.d.ts.map +0 -1
  250. package/dist/raw-archive.js.map +0 -1
  251. package/dist/recall-history.d.ts.map +0 -1
  252. package/dist/recall-history.js.map +0 -1
  253. package/dist/recall-scope.d.ts.map +0 -1
  254. package/dist/recall-scope.js.map +0 -1
  255. package/dist/recall-trace.d.ts.map +0 -1
  256. package/dist/recall-trace.js.map +0 -1
  257. package/dist/refine-llm.d.ts.map +0 -1
  258. package/dist/refine-llm.js.map +0 -1
  259. package/dist/reject-flow.d.ts.map +0 -1
  260. package/dist/reject-flow.js.map +0 -1
  261. package/dist/rejection.d.ts.map +0 -1
  262. package/dist/rejection.js.map +0 -1
  263. package/dist/replay.d.ts.map +0 -1
  264. package/dist/replay.js.map +0 -1
  265. package/dist/rerankers/cross-encoder.d.ts.map +0 -1
  266. package/dist/rerankers/cross-encoder.js.map +0 -1
  267. package/dist/rerankers/index.d.ts.map +0 -1
  268. package/dist/rerankers/index.js.map +0 -1
  269. package/dist/rerankers/jev.d.ts.map +0 -1
  270. package/dist/rerankers/jev.js.map +0 -1
  271. package/dist/rerankers/llm.d.ts.map +0 -1
  272. package/dist/rerankers/llm.js.map +0 -1
  273. package/dist/rerankers/types.d.ts.map +0 -1
  274. package/dist/rerankers/types.js.map +0 -1
  275. package/dist/rrf.d.ts.map +0 -1
  276. package/dist/rrf.js.map +0 -1
  277. package/dist/salience.d.ts.map +0 -1
  278. package/dist/salience.js.map +0 -1
  279. package/dist/scheduler.d.ts.map +0 -1
  280. package/dist/scheduler.js.map +0 -1
  281. package/dist/scope.d.ts.map +0 -1
  282. package/dist/scope.js.map +0 -1
  283. package/dist/search.d.ts.map +0 -1
  284. package/dist/search.js.map +0 -1
  285. package/dist/secret-detect.d.ts.map +0 -1
  286. package/dist/secret-detect.js.map +0 -1
  287. package/dist/server-detect.d.ts.map +0 -1
  288. package/dist/server-detect.js.map +0 -1
  289. package/dist/server.d.ts.map +0 -1
  290. package/dist/server.js.map +0 -1
  291. package/dist/shared.d.ts.map +0 -1
  292. package/dist/shared.js.map +0 -1
  293. package/dist/skills.d.ts.map +0 -1
  294. package/dist/skills.js.map +0 -1
  295. package/dist/sleep-redact.d.ts +0 -58
  296. package/dist/sleep-redact.d.ts.map +0 -1
  297. package/dist/sleep-redact.js +0 -80
  298. package/dist/sleep-redact.js.map +0 -1
  299. package/dist/src/ablation.js +0 -138
  300. package/dist/src/ablation.js.map +0 -1
  301. package/dist/src/ambient.js +0 -148
  302. package/dist/src/ambient.js.map +0 -1
  303. package/dist/src/api.js +0 -2109
  304. package/dist/src/api.js.map +0 -1
  305. package/dist/src/audit-prune.js +0 -109
  306. package/dist/src/audit-prune.js.map +0 -1
  307. package/dist/src/audit.js +0 -213
  308. package/dist/src/audit.js.map +0 -1
  309. package/dist/src/auth.js +0 -97
  310. package/dist/src/auth.js.map +0 -1
  311. package/dist/src/autolearn.js +0 -174
  312. package/dist/src/autolearn.js.map +0 -1
  313. package/dist/src/availability.js +0 -94
  314. package/dist/src/availability.js.map +0 -1
  315. package/dist/src/capture.js +0 -1346
  316. package/dist/src/capture.js.map +0 -1
  317. package/dist/src/card-detail.js +0 -15
  318. package/dist/src/card-detail.js.map +0 -1
  319. package/dist/src/card.js +0 -19
  320. package/dist/src/card.js.map +0 -1
  321. package/dist/src/cli.js +0 -9387
  322. package/dist/src/cli.js.map +0 -1
  323. package/dist/src/client.js +0 -237
  324. package/dist/src/client.js.map +0 -1
  325. package/dist/src/compare.js +0 -122
  326. package/dist/src/compare.js.map +0 -1
  327. package/dist/src/config.js +0 -135
  328. package/dist/src/config.js.map +0 -1
  329. package/dist/src/connectors/github/backfill.js +0 -281
  330. package/dist/src/connectors/github/backfill.js.map +0 -1
  331. package/dist/src/connectors/github/cli-impl.js +0 -234
  332. package/dist/src/connectors/github/cli-impl.js.map +0 -1
  333. package/dist/src/connectors/github/deletion.js +0 -85
  334. package/dist/src/connectors/github/deletion.js.map +0 -1
  335. package/dist/src/connectors/github/dlq.js +0 -190
  336. package/dist/src/connectors/github/dlq.js.map +0 -1
  337. package/dist/src/connectors/github/idempotency.js +0 -27
  338. package/dist/src/connectors/github/idempotency.js.map +0 -1
  339. package/dist/src/connectors/github/ingest.js +0 -156
  340. package/dist/src/connectors/github/ingest.js.map +0 -1
  341. package/dist/src/connectors/github/octokit-client.js +0 -70
  342. package/dist/src/connectors/github/octokit-client.js.map +0 -1
  343. package/dist/src/connectors/github/ratelimit.js +0 -31
  344. package/dist/src/connectors/github/ratelimit.js.map +0 -1
  345. package/dist/src/connectors/github/scope.js +0 -13
  346. package/dist/src/connectors/github/scope.js.map +0 -1
  347. package/dist/src/connectors/github/signature.js +0 -81
  348. package/dist/src/connectors/github/signature.js.map +0 -1
  349. package/dist/src/connectors/github/tenant-routing.js +0 -69
  350. package/dist/src/connectors/github/tenant-routing.js.map +0 -1
  351. package/dist/src/connectors/github/transform.js +0 -103
  352. package/dist/src/connectors/github/transform.js.map +0 -1
  353. package/dist/src/connectors/github/types.js +0 -100
  354. package/dist/src/connectors/github/types.js.map +0 -1
  355. package/dist/src/connectors/slack/backfill.js +0 -78
  356. package/dist/src/connectors/slack/backfill.js.map +0 -1
  357. package/dist/src/connectors/slack/deletion.js +0 -59
  358. package/dist/src/connectors/slack/deletion.js.map +0 -1
  359. package/dist/src/connectors/slack/dlq.js +0 -224
  360. package/dist/src/connectors/slack/dlq.js.map +0 -1
  361. package/dist/src/connectors/slack/idempotency.js +0 -31
  362. package/dist/src/connectors/slack/idempotency.js.map +0 -1
  363. package/dist/src/connectors/slack/ingest.js +0 -109
  364. package/dist/src/connectors/slack/ingest.js.map +0 -1
  365. package/dist/src/connectors/slack/ratelimit.js +0 -18
  366. package/dist/src/connectors/slack/ratelimit.js.map +0 -1
  367. package/dist/src/connectors/slack/scope.js +0 -13
  368. package/dist/src/connectors/slack/scope.js.map +0 -1
  369. package/dist/src/connectors/slack/signature.js +0 -27
  370. package/dist/src/connectors/slack/signature.js.map +0 -1
  371. package/dist/src/connectors/slack/tenant-routing.js +0 -41
  372. package/dist/src/connectors/slack/tenant-routing.js.map +0 -1
  373. package/dist/src/connectors/slack/transform.js +0 -47
  374. package/dist/src/connectors/slack/transform.js.map +0 -1
  375. package/dist/src/connectors/slack/types.js +0 -35
  376. package/dist/src/connectors/slack/types.js.map +0 -1
  377. package/dist/src/connectors/slack/web-client.js +0 -43
  378. package/dist/src/connectors/slack/web-client.js.map +0 -1
  379. package/dist/src/connectors/slack/workspaces.js +0 -62
  380. package/dist/src/connectors/slack/workspaces.js.map +0 -1
  381. package/dist/src/consolidate.js +0 -978
  382. package/dist/src/consolidate.js.map +0 -1
  383. package/dist/src/correction-latency.js +0 -74
  384. package/dist/src/correction-latency.js.map +0 -1
  385. package/dist/src/customer-notes.js +0 -325
  386. package/dist/src/customer-notes.js.map +0 -1
  387. package/dist/src/dag.js +0 -352
  388. package/dist/src/dag.js.map +0 -1
  389. package/dist/src/dashboard.js +0 -258
  390. package/dist/src/dashboard.js.map +0 -1
  391. package/dist/src/db.js +0 -2731
  392. package/dist/src/db.js.map +0 -1
  393. package/dist/src/decisions.js +0 -304
  394. package/dist/src/decisions.js.map +0 -1
  395. package/dist/src/dedupe.js +0 -141
  396. package/dist/src/dedupe.js.map +0 -1
  397. package/dist/src/embedding-provider.js +0 -314
  398. package/dist/src/embedding-provider.js.map +0 -1
  399. package/dist/src/embeddings.js +0 -543
  400. package/dist/src/embeddings.js.map +0 -1
  401. package/dist/src/eval-suite.js +0 -294
  402. package/dist/src/eval-suite.js.map +0 -1
  403. package/dist/src/eval.js +0 -187
  404. package/dist/src/eval.js.map +0 -1
  405. package/dist/src/extract.js +0 -117
  406. package/dist/src/extract.js.map +0 -1
  407. package/dist/src/forward-claim-detector.js +0 -117
  408. package/dist/src/forward-claim-detector.js.map +0 -1
  409. package/dist/src/goals.js +0 -399
  410. package/dist/src/goals.js.map +0 -1
  411. package/dist/src/graph-extract.js +0 -314
  412. package/dist/src/graph-extract.js.map +0 -1
  413. package/dist/src/graph-recall.js +0 -277
  414. package/dist/src/graph-recall.js.map +0 -1
  415. package/dist/src/graph-stream.js +0 -176
  416. package/dist/src/graph-stream.js.map +0 -1
  417. package/dist/src/graph-view.js +0 -310
  418. package/dist/src/graph-view.js.map +0 -1
  419. package/dist/src/graph.js +0 -762
  420. package/dist/src/graph.js.map +0 -1
  421. package/dist/src/handoff.js +0 -65
  422. package/dist/src/handoff.js.map +0 -1
  423. package/dist/src/hooks.js +0 -926
  424. package/dist/src/hooks.js.map +0 -1
  425. package/dist/src/importers.js +0 -881
  426. package/dist/src/importers.js.map +0 -1
  427. package/dist/src/incidents.js +0 -336
  428. package/dist/src/incidents.js.map +0 -1
  429. package/dist/src/index.js +0 -32
  430. package/dist/src/index.js.map +0 -1
  431. package/dist/src/invalidation.js +0 -129
  432. package/dist/src/invalidation.js.map +0 -1
  433. package/dist/src/mcp/framing.js +0 -45
  434. package/dist/src/mcp/framing.js.map +0 -1
  435. package/dist/src/mcp/server.js +0 -1298
  436. package/dist/src/mcp/server.js.map +0 -1
  437. package/dist/src/memory-value-weights.js +0 -31
  438. package/dist/src/memory-value-weights.js.map +0 -1
  439. package/dist/src/memory-value.js +0 -261
  440. package/dist/src/memory-value.js.map +0 -1
  441. package/dist/src/memory.js +0 -425
  442. package/dist/src/memory.js.map +0 -1
  443. package/dist/src/multihop.js +0 -35
  444. package/dist/src/multihop.js.map +0 -1
  445. package/dist/src/owner-validation.js +0 -56
  446. package/dist/src/owner-validation.js.map +0 -1
  447. package/dist/src/path-context.js +0 -48
  448. package/dist/src/path-context.js.map +0 -1
  449. package/dist/src/physics-config.js +0 -26
  450. package/dist/src/physics-config.js.map +0 -1
  451. package/dist/src/physics-state.js +0 -172
  452. package/dist/src/physics-state.js.map +0 -1
  453. package/dist/src/physics.js +0 -378
  454. package/dist/src/physics.js.map +0 -1
  455. package/dist/src/policies.js +0 -414
  456. package/dist/src/policies.js.map +0 -1
  457. package/dist/src/postinstall.js +0 -101
  458. package/dist/src/postinstall.js.map +0 -1
  459. package/dist/src/predictions.js +0 -629
  460. package/dist/src/predictions.js.map +0 -1
  461. package/dist/src/processes.js +0 -349
  462. package/dist/src/processes.js.map +0 -1
  463. package/dist/src/project-briefs.js +0 -492
  464. package/dist/src/project-briefs.js.map +0 -1
  465. package/dist/src/project-identity.js +0 -200
  466. package/dist/src/project-identity.js.map +0 -1
  467. package/dist/src/provenance-coverage.js +0 -23
  468. package/dist/src/provenance-coverage.js.map +0 -1
  469. package/dist/src/rate-limit.js +0 -60
  470. package/dist/src/rate-limit.js.map +0 -1
  471. package/dist/src/raw-archive-mirror-cleanup.js +0 -55
  472. package/dist/src/raw-archive-mirror-cleanup.js.map +0 -1
  473. package/dist/src/raw-archive.js +0 -91
  474. package/dist/src/raw-archive.js.map +0 -1
  475. package/dist/src/recall-history.js +0 -235
  476. package/dist/src/recall-history.js.map +0 -1
  477. package/dist/src/recall-scope.js +0 -89
  478. package/dist/src/recall-scope.js.map +0 -1
  479. package/dist/src/recall-trace.js +0 -186
  480. package/dist/src/recall-trace.js.map +0 -1
  481. package/dist/src/refine-llm.js +0 -156
  482. package/dist/src/refine-llm.js.map +0 -1
  483. package/dist/src/reject-flow.js +0 -209
  484. package/dist/src/reject-flow.js.map +0 -1
  485. package/dist/src/rejection.js +0 -160
  486. package/dist/src/rejection.js.map +0 -1
  487. package/dist/src/replay.js +0 -122
  488. package/dist/src/replay.js.map +0 -1
  489. package/dist/src/rerankers/cross-encoder.js +0 -137
  490. package/dist/src/rerankers/cross-encoder.js.map +0 -1
  491. package/dist/src/rerankers/index.js +0 -20
  492. package/dist/src/rerankers/index.js.map +0 -1
  493. package/dist/src/rerankers/jev.js +0 -119
  494. package/dist/src/rerankers/jev.js.map +0 -1
  495. package/dist/src/rerankers/llm.js +0 -80
  496. package/dist/src/rerankers/llm.js.map +0 -1
  497. package/dist/src/rerankers/types.js +0 -2
  498. package/dist/src/rerankers/types.js.map +0 -1
  499. package/dist/src/rrf.js +0 -61
  500. package/dist/src/rrf.js.map +0 -1
  501. package/dist/src/salience.js +0 -74
  502. package/dist/src/salience.js.map +0 -1
  503. package/dist/src/scheduler.js +0 -77
  504. package/dist/src/scheduler.js.map +0 -1
  505. package/dist/src/scope.js +0 -35
  506. package/dist/src/scope.js.map +0 -1
  507. package/dist/src/search.js +0 -1003
  508. package/dist/src/search.js.map +0 -1
  509. package/dist/src/secret-detect.js +0 -95
  510. package/dist/src/secret-detect.js.map +0 -1
  511. package/dist/src/server-detect.js +0 -231
  512. package/dist/src/server-detect.js.map +0 -1
  513. package/dist/src/server.js +0 -3255
  514. package/dist/src/server.js.map +0 -1
  515. package/dist/src/shared.js +0 -502
  516. package/dist/src/shared.js.map +0 -1
  517. package/dist/src/skills.js +0 -346
  518. package/dist/src/skills.js.map +0 -1
  519. package/dist/src/sleep-redact.js +0 -80
  520. package/dist/src/sleep-redact.js.map +0 -1
  521. package/dist/src/sso.js +0 -22
  522. package/dist/src/sso.js.map +0 -1
  523. package/dist/src/stdin.js +0 -41
  524. package/dist/src/stdin.js.map +0 -1
  525. package/dist/src/store.js +0 -3749
  526. package/dist/src/store.js.map +0 -1
  527. package/dist/src/tenant.js +0 -17
  528. package/dist/src/tenant.js.map +0 -1
  529. package/dist/src/trace.js +0 -72
  530. package/dist/src/trace.js.map +0 -1
  531. package/dist/src/version.js +0 -40
  532. package/dist/src/version.js.map +0 -1
  533. package/dist/src/working-memory.js +0 -157
  534. package/dist/src/working-memory.js.map +0 -1
  535. package/dist/src/yaml.js +0 -107
  536. package/dist/src/yaml.js.map +0 -1
  537. package/dist/sso.d.ts +0 -13
  538. package/dist/sso.d.ts.map +0 -1
  539. package/dist/sso.js +0 -22
  540. package/dist/sso.js.map +0 -1
  541. package/dist/stdin.d.ts.map +0 -1
  542. package/dist/stdin.js.map +0 -1
  543. package/dist/store.d.ts.map +0 -1
  544. package/dist/store.js.map +0 -1
  545. package/dist/tenant.d.ts.map +0 -1
  546. package/dist/tenant.js.map +0 -1
  547. package/dist/trace.d.ts.map +0 -1
  548. package/dist/trace.js.map +0 -1
  549. package/dist/version.d.ts.map +0 -1
  550. package/dist/version.js.map +0 -1
  551. package/dist/working-memory.d.ts.map +0 -1
  552. package/dist/working-memory.js.map +0 -1
  553. package/dist/yaml.d.ts.map +0 -1
  554. package/dist/yaml.js.map +0 -1
package/dist/src/db.js DELETED
@@ -1,2731 +0,0 @@
1
- import * as fs from 'fs';
2
- import * as os from 'os';
3
- import * as path from 'path';
4
- import { createRequire } from 'module';
5
- import { createPhysicsTable } from './physics-state.js';
6
- import { cleanupArchivedMirrors } from './raw-archive-mirror-cleanup.js';
7
- import { PACKAGE_VERSION, compareSemver } from './version.js';
8
- import { deriveOriginProject, originFromSource, isGlobalStoreRoot } from './project-identity.js';
9
- const require = createRequire(import.meta.url);
10
- // SAFETY: node:sqlite's DatabaseSync constructor genuinely has this shape at
11
- // runtime (Node's built-in synchronous SQLite module); there are no bundled
12
- // types for it here, so this require + cast is the module's documented boundary.
13
- const { DatabaseSync } = require('node:sqlite');
14
- const CURRENT_SCHEMA_VERSION = 43;
15
- const MIGRATIONS = [
16
- {
17
- version: 1,
18
- up: (db) => {
19
- db.exec(`
20
- CREATE TABLE IF NOT EXISTS memories (
21
- id TEXT PRIMARY KEY,
22
- created TEXT NOT NULL,
23
- last_retrieved TEXT NOT NULL,
24
- retrieval_count INTEGER NOT NULL,
25
- strength REAL NOT NULL,
26
- half_life_days REAL NOT NULL,
27
- layer TEXT NOT NULL,
28
- tags_json TEXT NOT NULL,
29
- emotional_valence TEXT NOT NULL,
30
- schema_fit REAL NOT NULL,
31
- source TEXT NOT NULL,
32
- outcome_score REAL,
33
- conflicts_with_json TEXT NOT NULL,
34
- pinned INTEGER NOT NULL,
35
- confidence TEXT NOT NULL,
36
- content TEXT NOT NULL,
37
- updated_at TEXT NOT NULL DEFAULT (datetime('now'))
38
- );
39
-
40
- CREATE INDEX IF NOT EXISTS idx_memories_layer_created ON memories(layer, created);
41
- CREATE INDEX IF NOT EXISTS idx_memories_last_retrieved ON memories(last_retrieved);
42
-
43
- CREATE TABLE IF NOT EXISTS consolidation_runs (
44
- id INTEGER PRIMARY KEY AUTOINCREMENT,
45
- timestamp TEXT NOT NULL,
46
- decayed INTEGER NOT NULL,
47
- merged INTEGER NOT NULL,
48
- removed INTEGER NOT NULL
49
- );
50
- `);
51
- },
52
- },
53
- {
54
- version: 2,
55
- up: (db) => {
56
- db.exec(`
57
- CREATE TABLE IF NOT EXISTS task_snapshots (
58
- id INTEGER PRIMARY KEY AUTOINCREMENT,
59
- task TEXT NOT NULL,
60
- summary TEXT NOT NULL,
61
- next_step TEXT NOT NULL,
62
- status TEXT NOT NULL,
63
- source TEXT NOT NULL,
64
- created_at TEXT NOT NULL,
65
- updated_at TEXT NOT NULL
66
- );
67
-
68
- CREATE INDEX IF NOT EXISTS idx_task_snapshots_status_updated
69
- ON task_snapshots(status, updated_at DESC, id DESC);
70
- `);
71
- },
72
- },
73
- {
74
- version: 3,
75
- up: (db) => {
76
- db.exec(`
77
- CREATE TABLE IF NOT EXISTS memory_conflicts (
78
- id INTEGER PRIMARY KEY AUTOINCREMENT,
79
- memory_a_id TEXT NOT NULL,
80
- memory_b_id TEXT NOT NULL,
81
- reason TEXT NOT NULL,
82
- score REAL NOT NULL DEFAULT 0,
83
- status TEXT NOT NULL,
84
- detected_at TEXT NOT NULL,
85
- updated_at TEXT NOT NULL,
86
- UNIQUE(memory_a_id, memory_b_id)
87
- );
88
-
89
- CREATE INDEX IF NOT EXISTS idx_memory_conflicts_status_updated
90
- ON memory_conflicts(status, updated_at DESC, id DESC);
91
- `);
92
- },
93
- },
94
- {
95
- version: 4,
96
- up: (db) => {
97
- if (!tableHasColumn(db, 'task_snapshots', 'session_id')) {
98
- db.exec(`ALTER TABLE task_snapshots ADD COLUMN session_id TEXT`);
99
- }
100
- db.exec(`
101
- CREATE TABLE IF NOT EXISTS session_events (
102
- id INTEGER PRIMARY KEY AUTOINCREMENT,
103
- session_id TEXT NOT NULL,
104
- task TEXT,
105
- event_type TEXT NOT NULL,
106
- content TEXT NOT NULL,
107
- source TEXT NOT NULL,
108
- metadata_json TEXT NOT NULL,
109
- created_at TEXT NOT NULL
110
- );
111
-
112
- CREATE INDEX IF NOT EXISTS idx_session_events_session_created
113
- ON session_events(session_id, created_at DESC, id DESC);
114
-
115
- CREATE INDEX IF NOT EXISTS idx_session_events_task_created
116
- ON session_events(task, created_at DESC, id DESC);
117
- `);
118
- },
119
- },
120
- {
121
- version: 5,
122
- up: (db) => {
123
- db.exec(`
124
- CREATE TABLE IF NOT EXISTS session_handoffs (
125
- id INTEGER PRIMARY KEY AUTOINCREMENT,
126
- session_id TEXT NOT NULL,
127
- repo_root TEXT,
128
- task_id TEXT,
129
- summary TEXT NOT NULL,
130
- next_action TEXT,
131
- artifacts_json TEXT NOT NULL DEFAULT '[]',
132
- created_at TEXT NOT NULL
133
- );
134
-
135
- CREATE INDEX IF NOT EXISTS idx_session_handoffs_session
136
- ON session_handoffs(session_id, created_at DESC);
137
- `);
138
- },
139
- },
140
- {
141
- version: 6,
142
- up: (db) => {
143
- db.exec(`
144
- CREATE TABLE IF NOT EXISTS working_memory (
145
- id INTEGER PRIMARY KEY AUTOINCREMENT,
146
- scope TEXT NOT NULL,
147
- session_id TEXT,
148
- task_id TEXT,
149
- importance REAL NOT NULL DEFAULT 0,
150
- content TEXT NOT NULL,
151
- metadata_json TEXT NOT NULL DEFAULT '{}',
152
- created_at TEXT NOT NULL,
153
- updated_at TEXT NOT NULL
154
- );
155
-
156
- CREATE INDEX IF NOT EXISTS idx_working_memory_scope
157
- ON working_memory(scope, importance DESC, created_at DESC);
158
-
159
- CREATE INDEX IF NOT EXISTS idx_working_memory_session
160
- ON working_memory(session_id, created_at DESC);
161
- `);
162
- },
163
- },
164
- {
165
- version: 7,
166
- up: (db) => {
167
- if (!tableHasColumn(db, 'memories', 'outcome_positive')) {
168
- db.exec(`ALTER TABLE memories ADD COLUMN outcome_positive INTEGER NOT NULL DEFAULT 0`);
169
- }
170
- if (!tableHasColumn(db, 'memories', 'outcome_negative')) {
171
- db.exec(`ALTER TABLE memories ADD COLUMN outcome_negative INTEGER NOT NULL DEFAULT 0`);
172
- }
173
- },
174
- },
175
- {
176
- version: 8,
177
- up: (db) => {
178
- createPhysicsTable(db);
179
- },
180
- },
181
- {
182
- version: 9,
183
- up: (db) => {
184
- if (!tableHasColumn(db, 'memories', 'parents_json')) {
185
- db.exec(`ALTER TABLE memories ADD COLUMN parents_json TEXT NOT NULL DEFAULT '[]'`);
186
- }
187
- if (!tableHasColumn(db, 'memories', 'starred')) {
188
- db.exec(`ALTER TABLE memories ADD COLUMN starred INTEGER NOT NULL DEFAULT 0`);
189
- }
190
- },
191
- },
192
- {
193
- version: 10,
194
- up: (db) => {
195
- if (!tableHasColumn(db, 'memories', 'trace_outcome')) {
196
- db.exec(`ALTER TABLE memories ADD COLUMN trace_outcome TEXT`);
197
- }
198
- if (!tableHasColumn(db, 'memories', 'source_session_id')) {
199
- db.exec(`ALTER TABLE memories ADD COLUMN source_session_id TEXT`);
200
- }
201
- db.exec(`
202
- CREATE INDEX IF NOT EXISTS idx_memories_source_session_id
203
- ON memories(source_session_id) WHERE source_session_id IS NOT NULL
204
- `);
205
- },
206
- },
207
- {
208
- version: 11,
209
- up: (db) => {
210
- if (!tableHasColumn(db, 'memories', 'valid_from')) {
211
- db.exec(`ALTER TABLE memories ADD COLUMN valid_from TEXT`);
212
- db.exec(`UPDATE memories SET valid_from = created WHERE valid_from IS NULL`);
213
- }
214
- if (!tableHasColumn(db, 'memories', 'superseded_by')) {
215
- db.exec(`ALTER TABLE memories ADD COLUMN superseded_by TEXT`);
216
- }
217
- db.exec(`CREATE INDEX IF NOT EXISTS idx_memories_current ON memories(layer, created) WHERE superseded_by IS NULL`);
218
- },
219
- },
220
- {
221
- version: 12,
222
- up: (db) => {
223
- if (!tableHasColumn(db, 'memories', 'extracted_from')) {
224
- db.exec(`ALTER TABLE memories ADD COLUMN extracted_from TEXT`);
225
- }
226
- db.exec(`CREATE INDEX IF NOT EXISTS idx_memories_extracted_from ON memories(extracted_from) WHERE extracted_from IS NOT NULL`);
227
- },
228
- },
229
- {
230
- version: 13,
231
- up: (db) => {
232
- if (!tableHasColumn(db, 'memories', 'dag_level')) {
233
- db.exec(`ALTER TABLE memories ADD COLUMN dag_level INTEGER NOT NULL DEFAULT 0`);
234
- }
235
- if (!tableHasColumn(db, 'memories', 'dag_parent_id')) {
236
- db.exec(`ALTER TABLE memories ADD COLUMN dag_parent_id TEXT`);
237
- }
238
- db.exec(`UPDATE memories SET dag_level = 1 WHERE extracted_from IS NOT NULL AND dag_level = 0`);
239
- db.exec(`CREATE INDEX IF NOT EXISTS idx_memories_dag_parent ON memories(dag_parent_id) WHERE dag_parent_id IS NOT NULL`);
240
- },
241
- },
242
- {
243
- version: 14,
244
- up: (db) => {
245
- // A3 provenance envelope: kind, scope, owner, artifact_ref.
246
- // SQLite ALTER TABLE ADD COLUMN cannot add CHECK; CHECK enforcement lives
247
- // in INSERT/UPDATE triggers added later in this migration.
248
- if (!tableHasColumn(db, 'memories', 'kind')) {
249
- db.exec(`ALTER TABLE memories ADD COLUMN kind TEXT DEFAULT 'distilled'`);
250
- }
251
- if (!tableHasColumn(db, 'memories', 'scope')) {
252
- db.exec(`ALTER TABLE memories ADD COLUMN scope TEXT`);
253
- db.exec(`CREATE INDEX IF NOT EXISTS idx_memories_scope ON memories(scope) WHERE scope IS NOT NULL`);
254
- }
255
- if (!tableHasColumn(db, 'memories', 'owner')) {
256
- db.exec(`ALTER TABLE memories ADD COLUMN owner TEXT`);
257
- }
258
- if (!tableHasColumn(db, 'memories', 'artifact_ref')) {
259
- db.exec(`ALTER TABLE memories ADD COLUMN artifact_ref TEXT`);
260
- }
261
- // Backfill kind for any rows where it's NULL (pre-migration data).
262
- db.exec(`UPDATE memories SET kind = 'superseded' WHERE kind IS NULL AND superseded_by IS NOT NULL`);
263
- db.exec(`UPDATE memories SET kind = 'distilled' WHERE kind IS NULL`);
264
- // raw_archive: legitimate path for kind='raw' removal (used by archiveRawMemory).
265
- db.exec(`
266
- CREATE TABLE IF NOT EXISTS raw_archive (
267
- id INTEGER PRIMARY KEY AUTOINCREMENT,
268
- memory_id TEXT NOT NULL,
269
- archived_at TEXT NOT NULL,
270
- reason TEXT NOT NULL,
271
- archived_by TEXT,
272
- payload_json TEXT NOT NULL
273
- )
274
- `);
275
- db.exec(`CREATE INDEX IF NOT EXISTS idx_raw_archive_memory_id ON raw_archive(memory_id)`);
276
- // Append-only invariant: kind='raw' rows cannot be deleted directly.
277
- // Use raw_archive flow: archive-then-update-then-delete (see src/raw-archive.ts).
278
- db.exec(`
279
- CREATE TRIGGER IF NOT EXISTS trg_memories_raw_append_only
280
- BEFORE DELETE ON memories
281
- WHEN OLD.kind = 'raw'
282
- BEGIN
283
- SELECT RAISE(ABORT, 'raw is append-only');
284
- END
285
- `);
286
- // CHECK substitute: ALTER TABLE cannot add CHECK, so enforce kind allowed-set
287
- // via INSERT/UPDATE triggers.
288
- db.exec(`
289
- CREATE TRIGGER IF NOT EXISTS trg_memories_kind_check_insert
290
- BEFORE INSERT ON memories
291
- WHEN NEW.kind IS NOT NULL AND NEW.kind NOT IN ('raw','distilled','superseded','archived')
292
- BEGIN
293
- SELECT RAISE(ABORT, 'invalid kind: must be raw|distilled|superseded|archived');
294
- END
295
- `);
296
- db.exec(`
297
- CREATE TRIGGER IF NOT EXISTS trg_memories_kind_check_update
298
- BEFORE UPDATE ON memories
299
- WHEN NEW.kind IS NOT NULL AND NEW.kind NOT IN ('raw','distilled','superseded','archived')
300
- BEGIN
301
- SELECT RAISE(ABORT, 'invalid kind: must be raw|distilled|superseded|archived');
302
- END
303
- `);
304
- },
305
- },
306
- {
307
- version: 15,
308
- up: (db) => {
309
- // A3 hardening (post-review): close the NULL-kind bypass and add raw_archive
310
- // dedup safety. Both findings landed in /review on commits 41b1f4d..6456e7d.
311
- //
312
- // (1) Original v14 triggers used `WHEN NEW.kind IS NOT NULL AND NEW.kind NOT IN (...)`.
313
- // A direct INSERT/UPDATE setting kind=NULL bypassed the CHECK substitute. Replace
314
- // with `WHEN NEW.kind IS NULL OR NEW.kind NOT IN (...)` so NULL is rejected too.
315
- // (2) Add UNIQUE(memory_id, archived_at) to raw_archive so re-archiving the same id
316
- // in the same instant cannot produce ambiguous audit rows. Per-id history is still
317
- // allowed (different timestamps).
318
- db.exec(`DROP TRIGGER IF EXISTS trg_memories_kind_check_insert`);
319
- db.exec(`DROP TRIGGER IF EXISTS trg_memories_kind_check_update`);
320
- db.exec(`
321
- CREATE TRIGGER trg_memories_kind_check_insert
322
- BEFORE INSERT ON memories
323
- WHEN NEW.kind IS NULL OR NEW.kind NOT IN ('raw','distilled','superseded','archived')
324
- BEGIN
325
- SELECT RAISE(ABORT, 'invalid kind: must be raw|distilled|superseded|archived (not null)');
326
- END
327
- `);
328
- db.exec(`
329
- CREATE TRIGGER trg_memories_kind_check_update
330
- BEFORE UPDATE ON memories
331
- WHEN NEW.kind IS NULL OR NEW.kind NOT IN ('raw','distilled','superseded','archived')
332
- BEGIN
333
- SELECT RAISE(ABORT, 'invalid kind: must be raw|distilled|superseded|archived (not null)');
334
- END
335
- `);
336
- // Defensive: any rows that somehow have NULL kind get fixed (shouldn't exist post-v14
337
- // backfill, but cheap insurance).
338
- db.exec(`UPDATE memories SET kind = 'distilled' WHERE kind IS NULL`);
339
- // raw_archive uniqueness. SQLite cannot ADD CONSTRAINT, but a partial unique index
340
- // on (memory_id, archived_at) is equivalent for INSERT-time enforcement.
341
- db.exec(`
342
- CREATE UNIQUE INDEX IF NOT EXISTS idx_raw_archive_id_at
343
- ON raw_archive(memory_id, archived_at)
344
- `);
345
- },
346
- },
347
- {
348
- version: 16,
349
- up: (db) => {
350
- // A5 stub auth: add tenant_id to all data tables. Single-tenant per deployment;
351
- // multi-tenant enforcement deferred to v2 (full A5). The columns are needed now
352
- // so future B-track tables don't have to backfill.
353
- if (!tableHasColumn(db, 'memories', 'tenant_id')) {
354
- db.exec(`ALTER TABLE memories ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default'`);
355
- }
356
- if (!tableHasColumn(db, 'working_memory', 'tenant_id')) {
357
- db.exec(`ALTER TABLE working_memory ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default'`);
358
- }
359
- if (!tableHasColumn(db, 'consolidation_runs', 'tenant_id')) {
360
- db.exec(`ALTER TABLE consolidation_runs ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default'`);
361
- }
362
- if (!tableHasColumn(db, 'task_snapshots', 'tenant_id')) {
363
- db.exec(`ALTER TABLE task_snapshots ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default'`);
364
- }
365
- if (!tableHasColumn(db, 'memory_conflicts', 'tenant_id')) {
366
- db.exec(`ALTER TABLE memory_conflicts ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default'`);
367
- }
368
- // Composite indexes for recall hot paths. Leading column is tenant_id so
369
- // single-tenant lookups are O(log n).
370
- db.exec(`CREATE INDEX IF NOT EXISTS idx_memories_tenant_created ON memories(tenant_id, created)`);
371
- db.exec(`CREATE INDEX IF NOT EXISTS idx_working_memory_tenant ON working_memory(tenant_id, importance DESC, created_at DESC)`);
372
- db.exec(`CREATE INDEX IF NOT EXISTS idx_consolidation_runs_tenant_ts ON consolidation_runs(tenant_id, timestamp DESC)`);
373
- db.exec(`CREATE INDEX IF NOT EXISTS idx_task_snapshots_tenant_status ON task_snapshots(tenant_id, status, updated_at DESC)`);
374
- // A5 stub auth: api_keys (scrypt-hashed; plaintext returned to caller exactly once)
375
- // and audit_log (append-only mutation trail). Both carry tenant_id from day 1 so
376
- // future multi-tenant enforcement is a config flip, not a re-migration.
377
- db.exec(`
378
- CREATE TABLE IF NOT EXISTS api_keys (
379
- id INTEGER PRIMARY KEY AUTOINCREMENT,
380
- key_id TEXT UNIQUE NOT NULL,
381
- key_hash TEXT NOT NULL,
382
- tenant_id TEXT NOT NULL DEFAULT 'default',
383
- label TEXT,
384
- created_at TEXT NOT NULL,
385
- revoked_at TEXT
386
- )
387
- `);
388
- db.exec(`
389
- CREATE INDEX IF NOT EXISTS idx_api_keys_tenant_active
390
- ON api_keys(tenant_id) WHERE revoked_at IS NULL
391
- `);
392
- db.exec(`
393
- CREATE TABLE IF NOT EXISTS audit_log (
394
- id INTEGER PRIMARY KEY AUTOINCREMENT,
395
- ts TEXT NOT NULL,
396
- tenant_id TEXT NOT NULL DEFAULT 'default',
397
- actor TEXT NOT NULL,
398
- op TEXT NOT NULL,
399
- target_id TEXT,
400
- metadata_json TEXT NOT NULL DEFAULT '{}'
401
- )
402
- `);
403
- db.exec(`CREATE INDEX IF NOT EXISTS idx_audit_log_tenant_ts ON audit_log(tenant_id, ts DESC)`);
404
- },
405
- },
406
- {
407
- version: 17,
408
- up: (db) => {
409
- // E1.3 Slack ingestion: idempotency log, per-channel backfill cursors, DLQ.
410
- // See docs/plans/2026-04-29-e1.3-slack-ingestion.md.
411
- db.exec(`
412
- CREATE TABLE IF NOT EXISTS slack_event_log (
413
- event_id TEXT PRIMARY KEY,
414
- ingested_at TEXT NOT NULL,
415
- memory_id TEXT
416
- )
417
- `);
418
- db.exec(`CREATE INDEX IF NOT EXISTS idx_slack_event_log_memory ON slack_event_log(memory_id) WHERE memory_id IS NOT NULL`);
419
- db.exec(`
420
- CREATE TABLE IF NOT EXISTS slack_cursors (
421
- tenant_id TEXT NOT NULL,
422
- channel_id TEXT NOT NULL,
423
- latest_ts TEXT NOT NULL,
424
- updated_at TEXT NOT NULL,
425
- PRIMARY KEY (tenant_id, channel_id)
426
- )
427
- `);
428
- db.exec(`
429
- CREATE TABLE IF NOT EXISTS slack_dlq (
430
- id INTEGER PRIMARY KEY AUTOINCREMENT,
431
- tenant_id TEXT NOT NULL,
432
- raw_payload TEXT NOT NULL,
433
- error TEXT NOT NULL,
434
- received_at TEXT NOT NULL,
435
- retried_at TEXT
436
- )
437
- `);
438
- db.exec(`CREATE INDEX IF NOT EXISTS idx_slack_dlq_tenant_received ON slack_dlq(tenant_id, received_at)`);
439
- // Multi-tenant routing seam (review patch #6). Empty by default — single-
440
- // tenant deployments resolve via HIPPO_TENANT fallback. Multi-workspace
441
- // deployments populate this table to map team_id → tenant_id.
442
- db.exec(`
443
- CREATE TABLE IF NOT EXISTS slack_workspaces (
444
- team_id TEXT PRIMARY KEY,
445
- tenant_id TEXT NOT NULL,
446
- added_at TEXT NOT NULL
447
- )
448
- `);
449
- },
450
- },
451
- {
452
- version: 18,
453
- up: (db) => {
454
- db.exec(`
455
- CREATE TABLE IF NOT EXISTS goal_stack (
456
- id TEXT PRIMARY KEY,
457
- session_id TEXT NOT NULL,
458
- tenant_id TEXT NOT NULL DEFAULT 'default',
459
- goal_name TEXT NOT NULL,
460
- level INTEGER NOT NULL DEFAULT 0
461
- CHECK (level BETWEEN 0 AND 2),
462
- parent_goal_id TEXT REFERENCES goal_stack(id) ON DELETE SET NULL,
463
- status TEXT NOT NULL CHECK (status IN ('active','suspended','completed')),
464
- success_condition TEXT,
465
- retrieval_policy_id TEXT,
466
- created_at TEXT NOT NULL,
467
- completed_at TEXT,
468
- outcome_score REAL
469
- CHECK (outcome_score IS NULL OR (outcome_score >= 0 AND outcome_score <= 1))
470
- );
471
-
472
- CREATE INDEX IF NOT EXISTS idx_goal_stack_tenant_session_status
473
- ON goal_stack(tenant_id, session_id, status, created_at);
474
-
475
- CREATE TABLE IF NOT EXISTS retrieval_policy (
476
- id TEXT PRIMARY KEY,
477
- goal_id TEXT NOT NULL REFERENCES goal_stack(id) ON DELETE CASCADE,
478
- policy_type TEXT NOT NULL CHECK (policy_type IN
479
- ('schema-fit-biased','error-prioritized','recency-first','hybrid')),
480
- weight_schema_fit REAL NOT NULL DEFAULT 1.0
481
- CHECK (weight_schema_fit >= 0 AND weight_schema_fit <= 100),
482
- weight_recency REAL NOT NULL DEFAULT 1.0
483
- CHECK (weight_recency >= 0 AND weight_recency <= 100),
484
- weight_outcome REAL NOT NULL DEFAULT 1.0
485
- CHECK (weight_outcome >= 0 AND weight_outcome <= 100),
486
- error_priority REAL NOT NULL DEFAULT 1.0
487
- CHECK (error_priority >= 0 AND error_priority <= 100)
488
- );
489
-
490
- CREATE INDEX IF NOT EXISTS idx_retrieval_policy_goal
491
- ON retrieval_policy(goal_id);
492
-
493
- CREATE TABLE IF NOT EXISTS goal_recall_log (
494
- id INTEGER PRIMARY KEY AUTOINCREMENT,
495
- goal_id TEXT NOT NULL REFERENCES goal_stack(id) ON DELETE CASCADE,
496
- memory_id TEXT NOT NULL REFERENCES memories(id) ON DELETE CASCADE,
497
- tenant_id TEXT NOT NULL DEFAULT 'default',
498
- session_id TEXT NOT NULL,
499
- recalled_at TEXT NOT NULL,
500
- score REAL NOT NULL
501
- );
502
-
503
- CREATE INDEX IF NOT EXISTS idx_goal_recall_log_goal
504
- ON goal_recall_log(goal_id);
505
- CREATE UNIQUE INDEX IF NOT EXISTS uniq_goal_recall_log_memory_goal
506
- ON goal_recall_log(memory_id, goal_id);
507
- `);
508
- },
509
- },
510
- {
511
- version: 19,
512
- up: (db) => {
513
- // v0.39 commit 3 (Slack hardening): widen slack_dlq with bucketing,
514
- // retry tracking, and the signature/timestamp pair that lets `hippo
515
- // slack dlq replay` re-verify before re-running ingest. ALTER ADD
516
- // COLUMN with DEFAULT is non-destructive — legacy rows take the
517
- // default values. Idempotent via tableHasColumn().
518
- if (!tableHasColumn(db, 'slack_dlq', 'team_id')) {
519
- db.exec(`ALTER TABLE slack_dlq ADD COLUMN team_id TEXT`);
520
- }
521
- if (!tableHasColumn(db, 'slack_dlq', 'bucket')) {
522
- db.exec(`ALTER TABLE slack_dlq ADD COLUMN bucket TEXT NOT NULL DEFAULT 'parse_error'`);
523
- // SQLite ALTER TABLE cannot add CHECK; bucket value enforcement is app-level.
524
- }
525
- if (!tableHasColumn(db, 'slack_dlq', 'retry_count')) {
526
- db.exec(`ALTER TABLE slack_dlq ADD COLUMN retry_count INTEGER NOT NULL DEFAULT 0`);
527
- }
528
- if (!tableHasColumn(db, 'slack_dlq', 'signature')) {
529
- db.exec(`ALTER TABLE slack_dlq ADD COLUMN signature TEXT`);
530
- }
531
- if (!tableHasColumn(db, 'slack_dlq', 'slack_timestamp')) {
532
- db.exec(`ALTER TABLE slack_dlq ADD COLUMN slack_timestamp TEXT`);
533
- }
534
- },
535
- },
536
- {
537
- version: 20,
538
- up: (db) => {
539
- // v0.39 commit 4 (GDPR Path A backfill): redact every existing
540
- // raw_archive.payload_json so historical archives match the new
541
- // metadata-only contract from src/raw-archive.ts. Read each row, parse
542
- // the existing JSON to extract tenant_id and kind (best effort), then
543
- // UPDATE with the redacted shape. Rows with unparseable legacy JSON get
544
- // redacted with tenant_id='unknown', kind='unknown'. The audit_log
545
- // remains the compliance record.
546
- // SAFETY: rows' shape matches the four columns named in the SELECT above.
547
- const rows = db
548
- .prepare(`SELECT id, archived_at, reason, payload_json FROM raw_archive`)
549
- .all();
550
- const update = db.prepare(`UPDATE raw_archive SET payload_json = ? WHERE id = ?`);
551
- for (const row of rows) {
552
- let tenant = 'unknown';
553
- let kind = 'unknown';
554
- try {
555
- // SAFETY: parsed is a best-effort JSON.parse of legacy payload_json;
556
- // an unexpected shape only yields undefined fields (falling back to
557
- // 'unknown' below), and a parse failure is caught, so this never crashes.
558
- const parsed = JSON.parse(row.payload_json);
559
- tenant = parsed.tenant_id ?? 'unknown';
560
- kind = parsed.kind ?? 'unknown';
561
- }
562
- catch {
563
- // Unparseable legacy payload — redact with unknowns.
564
- }
565
- const redacted = JSON.stringify({
566
- redacted: true,
567
- archived_at: row.archived_at,
568
- tenant_id: tenant,
569
- kind,
570
- reason: row.reason,
571
- migration: 'v20_redact',
572
- });
573
- update.run(redacted, row.id);
574
- }
575
- },
576
- },
577
- {
578
- version: 21,
579
- up: (db) => {
580
- // v0.39 codex round 3: per-row mirror cleanup tracking. Replaces the
581
- // global gdpr_v20_mirror_cleanup meta gate (which made the reaper
582
- // one-shot and silently swallowed failed unlinks). With this column the
583
- // reaper processes only rows WHERE mirror_cleaned_at IS NULL, sets the
584
- // timestamp on success, and leaves it NULL on any unlink failure so the
585
- // next openHippoDb retries automatically.
586
- if (!tableHasColumn(db, 'raw_archive', 'mirror_cleaned_at')) {
587
- db.exec(`ALTER TABLE raw_archive ADD COLUMN mirror_cleaned_at TEXT`);
588
- }
589
- },
590
- },
591
- {
592
- version: 22,
593
- up: (db) => {
594
- // Tenant-isolation gap on continuity tables (codex review 2026-05-02).
595
- // session_events and session_handoffs predate the v16 tenant migration
596
- // and were never added to it, so the v0.40.0 provenance gate work
597
- // exposed a real cross-tenant leak when continuity primitives are used.
598
- // Adds tenant_id (NOT NULL DEFAULT 'default') with smart backfill from
599
- // task_snapshots.session_id when unambiguous, plus an optional scope
600
- // column so a private-channel-derived handoff can default-deny via the
601
- // same rule recall already enforces.
602
- //
603
- // Self-heal partial-init stores: re-run the v4/v5 CREATE TABLE IF NOT
604
- // EXISTS bodies before ALTERing. A silent skip would otherwise stamp
605
- // schema_version=22 on a DB missing the underlying tables, leaving
606
- // them permanently absent.
607
- db.exec(`
608
- CREATE TABLE IF NOT EXISTS session_events (
609
- id INTEGER PRIMARY KEY AUTOINCREMENT,
610
- session_id TEXT NOT NULL,
611
- task TEXT,
612
- event_type TEXT NOT NULL,
613
- content TEXT NOT NULL,
614
- source TEXT NOT NULL,
615
- metadata_json TEXT NOT NULL,
616
- created_at TEXT NOT NULL
617
- );
618
- CREATE TABLE IF NOT EXISTS session_handoffs (
619
- id INTEGER PRIMARY KEY AUTOINCREMENT,
620
- session_id TEXT NOT NULL,
621
- repo_root TEXT,
622
- task_id TEXT,
623
- summary TEXT NOT NULL,
624
- next_action TEXT,
625
- artifacts_json TEXT NOT NULL DEFAULT '[]',
626
- created_at TEXT NOT NULL
627
- );
628
- `);
629
- if (!tableHasColumn(db, 'session_events', 'tenant_id')) {
630
- db.exec(`ALTER TABLE session_events ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default'`);
631
- }
632
- if (!tableHasColumn(db, 'session_events', 'scope')) {
633
- db.exec(`ALTER TABLE session_events ADD COLUMN scope TEXT`);
634
- }
635
- if (!tableHasColumn(db, 'session_handoffs', 'tenant_id')) {
636
- db.exec(`ALTER TABLE session_handoffs ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default'`);
637
- }
638
- if (!tableHasColumn(db, 'session_handoffs', 'scope')) {
639
- db.exec(`ALTER TABLE session_handoffs ADD COLUMN scope TEXT`);
640
- }
641
- // Smart backfill: rows whose session_id maps to exactly one tenant in
642
- // task_snapshots inherit that tenant. Ambiguous or unmapped rows stay
643
- // at the column default ('default'). Conservative: never crosses
644
- // tenant boundaries on guesses. The COUNT(DISTINCT) gate is the load-
645
- // bearing check; without it, rows with multiple tenants under the same
646
- // session_id would silently pick whichever group came first.
647
- db.exec(`
648
- UPDATE session_events
649
- SET tenant_id = (
650
- SELECT MAX(t.tenant_id) FROM task_snapshots t
651
- WHERE t.session_id = session_events.session_id
652
- )
653
- WHERE tenant_id = 'default'
654
- AND (SELECT COUNT(DISTINCT t.tenant_id) FROM task_snapshots t
655
- WHERE t.session_id = session_events.session_id) = 1
656
- `);
657
- db.exec(`
658
- UPDATE session_handoffs
659
- SET tenant_id = (
660
- SELECT MAX(t.tenant_id) FROM task_snapshots t
661
- WHERE t.session_id = session_handoffs.session_id
662
- )
663
- WHERE tenant_id = 'default'
664
- AND (SELECT COUNT(DISTINCT t.tenant_id) FROM task_snapshots t
665
- WHERE t.session_id = session_handoffs.session_id) = 1
666
- `);
667
- db.exec(`CREATE INDEX IF NOT EXISTS idx_session_events_tenant_session ON session_events(tenant_id, session_id, created_at DESC, id DESC)`);
668
- db.exec(`CREATE INDEX IF NOT EXISTS idx_session_handoffs_tenant_session ON session_handoffs(tenant_id, session_id, created_at DESC)`);
669
- },
670
- },
671
- {
672
- version: 23,
673
- up: (db) => {
674
- // task_snapshots: add scope so all three continuity tables carry it.
675
- // Self-heal partial-init stores via CREATE TABLE IF NOT EXISTS (the v22
676
- // session_events / session_handoffs healing is upstream).
677
- db.exec(`
678
- CREATE TABLE IF NOT EXISTS task_snapshots (
679
- id INTEGER PRIMARY KEY AUTOINCREMENT,
680
- task TEXT NOT NULL,
681
- summary TEXT NOT NULL,
682
- next_step TEXT NOT NULL,
683
- status TEXT NOT NULL,
684
- source TEXT NOT NULL,
685
- session_id TEXT,
686
- tenant_id TEXT NOT NULL DEFAULT 'default',
687
- created_at TEXT NOT NULL,
688
- updated_at TEXT NOT NULL
689
- )
690
- `);
691
- if (!tableHasColumn(db, 'task_snapshots', 'scope')) {
692
- db.exec(`ALTER TABLE task_snapshots ADD COLUMN scope TEXT`);
693
- }
694
- // Quarantine policy (codex round 1 P1): pre-existing continuity rows
695
- // with NULL scope cannot be safely classified as public after the fact.
696
- // Mark them 'unknown:legacy' so the api.recall + cmdRecall default-deny
697
- // filter excludes them for no-scope callers. Fresh rows from new
698
- // writers carry NULL when scope is unspecified (legitimate non-Slack
699
- // writes); the 'unknown:legacy' marker is a v23-only one-shot for
700
- // pre-upgrade rows.
701
- //
702
- // Run UPDATEs only on tables that exist (some test paths and edge stores
703
- // skip v22's table healing). The UPDATEs are themselves idempotent via
704
- // the WHERE scope IS NULL clause, so re-running is a no-op.
705
- if (tableExists(db, 'task_snapshots')) {
706
- db.exec(`UPDATE task_snapshots SET scope = 'unknown:legacy' WHERE scope IS NULL`);
707
- }
708
- if (tableExists(db, 'session_events')) {
709
- db.exec(`UPDATE session_events SET scope = 'unknown:legacy' WHERE scope IS NULL`);
710
- }
711
- if (tableExists(db, 'session_handoffs')) {
712
- db.exec(`UPDATE session_handoffs SET scope = 'unknown:legacy' WHERE scope IS NULL`);
713
- }
714
- db.exec(`CREATE INDEX IF NOT EXISTS idx_task_snapshots_tenant_scope ON task_snapshots(tenant_id, scope, status)`);
715
- },
716
- },
717
- {
718
- version: 24,
719
- up: (db) => {
720
- // v1.3.0 GitHub connector schema (codex round 1, 2026-05-04).
721
- // Six tables + a min_compatible_binary meta row for rollback safety.
722
- db.exec(`
723
- CREATE TABLE IF NOT EXISTS github_event_log (
724
- idempotency_key TEXT PRIMARY KEY,
725
- delivery_id TEXT NOT NULL,
726
- event_name TEXT NOT NULL,
727
- ingested_at TEXT NOT NULL,
728
- memory_id TEXT
729
- )
730
- `);
731
- db.exec(`CREATE INDEX IF NOT EXISTS idx_github_event_log_memory ON github_event_log(memory_id) WHERE memory_id IS NOT NULL`);
732
- db.exec(`CREATE INDEX IF NOT EXISTS idx_github_event_log_delivery ON github_event_log(delivery_id)`);
733
- db.exec(`
734
- CREATE TABLE IF NOT EXISTS github_cursors (
735
- tenant_id TEXT NOT NULL,
736
- repo_full_name TEXT NOT NULL,
737
- issues_hwm TEXT,
738
- issue_comments_hwm TEXT,
739
- pr_review_comments_hwm TEXT,
740
- updated_at TEXT NOT NULL,
741
- PRIMARY KEY (tenant_id, repo_full_name)
742
- )
743
- `);
744
- db.exec(`
745
- CREATE TABLE IF NOT EXISTS github_dlq (
746
- id INTEGER PRIMARY KEY AUTOINCREMENT,
747
- tenant_id TEXT NOT NULL,
748
- raw_payload TEXT NOT NULL,
749
- error TEXT NOT NULL,
750
- event_name TEXT,
751
- delivery_id TEXT,
752
- signature TEXT,
753
- installation_id TEXT,
754
- repo_full_name TEXT,
755
- retry_count INTEGER NOT NULL DEFAULT 0,
756
- received_at TEXT NOT NULL,
757
- retried_at TEXT,
758
- bucket TEXT NOT NULL DEFAULT 'parse_error'
759
- )
760
- `);
761
- db.exec(`CREATE INDEX IF NOT EXISTS idx_github_dlq_tenant_received ON github_dlq(tenant_id, received_at)`);
762
- db.exec(`
763
- CREATE TABLE IF NOT EXISTS github_installations (
764
- installation_id TEXT PRIMARY KEY,
765
- tenant_id TEXT NOT NULL,
766
- added_at TEXT NOT NULL
767
- )
768
- `);
769
- // PAT-mode multi-tenant routing (codex P0 #4). Maps repo_full_name to
770
- // tenant when the webhook envelope has no `installation` field. Composite
771
- // PK so the same repo can intentionally be visible to multiple tenants
772
- // (e.g., shared tooling accounts) — collision is on (repo, tenant) pair.
773
- db.exec(`
774
- CREATE TABLE IF NOT EXISTS github_repositories (
775
- repo_full_name TEXT NOT NULL,
776
- tenant_id TEXT NOT NULL,
777
- added_at TEXT NOT NULL,
778
- PRIMARY KEY (repo_full_name, tenant_id)
779
- )
780
- `);
781
- // Rollback-safety guard (codex P0 #2). Any binary < 1.2.1 lacks the
782
- // generic *:private:* default-deny and would leak github:private:* rows
783
- // if it opened this DB. The startup guard in v1.2.1+ refuses to open a
784
- // DB whose min_compatible_binary is newer than its own version.
785
- db.prepare(`INSERT INTO meta(key, value) VALUES('min_compatible_binary', ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run('1.2.1');
786
- },
787
- },
788
- {
789
- version: 25,
790
- up: (db) => {
791
- // v1.5.0 DAG-aware recall — cache summary metadata so the assembler can
792
- // reason about scope without re-walking the DAG. Three additive,
793
- // optional columns. No min_compatible_binary bump: these columns are
794
- // pure metadata; older binaries opening this DB will see them as NULL
795
- // / 0 and behave as before. See docs/plans/2026-05-05-dag-recall.md
796
- // Task 1.
797
- if (!tableHasColumn(db, 'memories', 'descendant_count')) {
798
- db.exec(`ALTER TABLE memories ADD COLUMN descendant_count INTEGER NOT NULL DEFAULT 0`);
799
- }
800
- if (!tableHasColumn(db, 'memories', 'earliest_at')) {
801
- db.exec(`ALTER TABLE memories ADD COLUMN earliest_at TEXT`);
802
- }
803
- if (!tableHasColumn(db, 'memories', 'latest_at')) {
804
- db.exec(`ALTER TABLE memories ADD COLUMN latest_at TEXT`);
805
- }
806
- // Backfill descendant_count for existing level-2 summary rows. Use
807
- // dag_parent_id pointing at the summary id. Level-3 (entity profiles)
808
- // not built today; their descendant_count stays at default 0.
809
- db.exec(`
810
- UPDATE memories
811
- SET descendant_count = (
812
- SELECT COUNT(*) FROM memories AS c WHERE c.dag_parent_id = memories.id
813
- )
814
- WHERE dag_level >= 2
815
- AND descendant_count = 0
816
- `);
817
- // Backfill earliest_at / latest_at from child created timestamps for
818
- // existing summaries. Children of a level-2 summary are level-1 facts.
819
- db.exec(`
820
- UPDATE memories
821
- SET earliest_at = (
822
- SELECT MIN(c.created) FROM memories AS c WHERE c.dag_parent_id = memories.id
823
- ),
824
- latest_at = (
825
- SELECT MAX(c.created) FROM memories AS c WHERE c.dag_parent_id = memories.id
826
- )
827
- WHERE dag_level >= 2
828
- AND earliest_at IS NULL
829
- `);
830
- },
831
- },
832
- {
833
- version: 26,
834
- up: (db) => {
835
- // v1.12.0 A5 v2 sub-1: add role column to api_keys for the admin/member
836
- // distinction that gates /v1/sleep. Additive — existing keys backfill to
837
- // 'admin' via DEFAULT (single-tenant operator = admin by definition).
838
- // No min_compatible_binary bump: old binaries (v1.11.x) ignore the
839
- // column on SELECTs that don't name it; new binaries on old data run
840
- // this migration at openHippoDb time before any createApiKey call.
841
- //
842
- // v1.12.7 defensive: also guard on tableExists. If a DB landed in the
843
- // partial-v16-state (api_keys table missing despite schema_version >= 16),
844
- // running ALTER TABLE here would crash and block all later migrations.
845
- // The v27 heal below recreates api_keys with the role column already
846
- // present, so this ALTER becomes a no-op anyway for that path. Defense
847
- // in depth: guard so v26 never crashes on the partial-apply state.
848
- if (tableExists(db, 'api_keys') && !tableHasColumn(db, 'api_keys', 'role')) {
849
- db.exec(`ALTER TABLE api_keys ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'`);
850
- }
851
- },
852
- },
853
- {
854
- version: 27,
855
- up: (db) => {
856
- // v1.12.7 self-heal — re-assert the v16 schema (api_keys + audit_log).
857
- //
858
- // Surfaced 2026-05-24 on Keith's ~/.hippo/hippo.db: schema_version
859
- // recorded as 25 but api_keys and audit_log tables were missing from
860
- // migration v16. Root cause unknown — the migration runner has wrapped
861
- // each migration in BEGIN/COMMIT since the first SQLite commit, so
862
- // atomicity isn't the bug. Possible causes: DROP TABLE post-migration,
863
- // SQL import / restore from a pre-v16 backup over a v16+ schema_version,
864
- // or some edge case the wrapping doesn't catch. Cause may be operator
865
- // action; either way the practical fix is the same.
866
- //
867
- // All CREATE IF NOT EXISTS — zero-cost no-op for users without the
868
- // bug, fixes anyone who has it. Includes the role column from the
869
- // start so it matches v26's intent without needing v26 to ALTER on
870
- // this heal path.
871
- db.exec(`
872
- CREATE TABLE IF NOT EXISTS api_keys (
873
- id INTEGER PRIMARY KEY AUTOINCREMENT,
874
- key_id TEXT UNIQUE NOT NULL,
875
- key_hash TEXT NOT NULL,
876
- tenant_id TEXT NOT NULL DEFAULT 'default',
877
- label TEXT,
878
- created_at TEXT NOT NULL,
879
- revoked_at TEXT,
880
- role TEXT NOT NULL DEFAULT 'admin'
881
- )
882
- `);
883
- db.exec(`
884
- CREATE INDEX IF NOT EXISTS idx_api_keys_tenant_active
885
- ON api_keys(tenant_id) WHERE revoked_at IS NULL
886
- `);
887
- db.exec(`
888
- CREATE TABLE IF NOT EXISTS audit_log (
889
- id INTEGER PRIMARY KEY AUTOINCREMENT,
890
- ts TEXT NOT NULL,
891
- tenant_id TEXT NOT NULL DEFAULT 'default',
892
- actor TEXT NOT NULL,
893
- op TEXT NOT NULL,
894
- target_id TEXT,
895
- metadata_json TEXT NOT NULL DEFAULT '{}'
896
- )
897
- `);
898
- db.exec(`CREATE INDEX IF NOT EXISTS idx_audit_log_tenant_ts ON audit_log(tenant_id, ts DESC)`);
899
- // Belt-and-braces: if api_keys existed before this migration WITHOUT
900
- // the role column (i.e. v16-shape table that v26's ALTER skipped due
901
- // to tableExists=false on an earlier broken run, then someone manually
902
- // CREATEd it without role), backfill role.
903
- if (tableExists(db, 'api_keys') && !tableHasColumn(db, 'api_keys', 'role')) {
904
- db.exec(`ALTER TABLE api_keys ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'`);
905
- }
906
- },
907
- },
908
- {
909
- version: 28,
910
- up: (db) => {
911
- // E1 of 5-episode DAG live-coupling arc (docs/plans/2026-05-25-dag-e1-schema-v28.md).
912
- // Adds dirty-flag persistence for the existing DAG layer's level-2
913
- // summaries so E2 (child-write propagation) and E3 (sleep-cycle
914
- // rebuild) have somewhere to write + read the staleness signal. Adds
915
- // dag_level_3_built_at as a column (Keith Q5 pick: lets E5 entity-
916
- // profile build path land without a second migration).
917
- //
918
- // All columns are additive + DEFAULTed/nullable, so backfill is
919
- // automatic for existing rows. No min_compatible_binary bump: old
920
- // binaries (v1.12.x) ignore the columns on SELECTs that don't name
921
- // them; new binaries on old data hit this migration at openHippoDb
922
- // time before any DAG path touches summary_dirty.
923
- //
924
- // Precedent for column-only guards on memories: v25 (db.ts:827),
925
- // which added the DAG cache columns the same way. memories table
926
- // itself comes from v1 and is always present, so the tableExists
927
- // half of the v26/v27 guard pattern isn't load-bearing here.
928
- if (!tableHasColumn(db, 'memories', 'summary_dirty')) {
929
- db.exec(`ALTER TABLE memories ADD COLUMN summary_dirty INTEGER NOT NULL DEFAULT 0`);
930
- }
931
- if (!tableHasColumn(db, 'memories', 'last_rebuilt_at')) {
932
- db.exec(`ALTER TABLE memories ADD COLUMN last_rebuilt_at TEXT`);
933
- }
934
- if (!tableHasColumn(db, 'memories', 'rebuild_count')) {
935
- db.exec(`ALTER TABLE memories ADD COLUMN rebuild_count INTEGER NOT NULL DEFAULT 0`);
936
- }
937
- if (!tableHasColumn(db, 'memories', 'dag_level_3_built_at')) {
938
- // Reserved for E5: buildEntityProfiles will set this on level-3
939
- // rows when they're created. Always NULL for level 0/1/2 rows.
940
- db.exec(`ALTER TABLE memories ADD COLUMN dag_level_3_built_at TEXT`);
941
- }
942
- },
943
- },
944
- {
945
- version: 29,
946
- up: (db) => {
947
- // E2 prediction first-class object (docs/plans/2026-05-26-e2-prediction-object.md).
948
- // Adds a canonical predictions table for J3 reference-class /
949
- // planning-fallacy detector (a follow-up episode). Predictions
950
- // duplicate claim_text in the table itself so memory deletion
951
- // (forget/consolidate/archive) does not lose prediction data; FK
952
- // memory_id is NULLABLE with ON DELETE SET NULL.
953
- //
954
- // Cross-tenant safety: BEFORE INSERT + BEFORE UPDATE triggers
955
- // enforce tenant_id match against the referenced memory. SQLite's
956
- // ON DELETE SET NULL is incompatible with composite FK where one
957
- // side is NOT NULL, so the trigger pattern replaces a composite
958
- // FK target. Precedent: v14 memories.kind trigger pair at db.ts:298-322.
959
- //
960
- // CHECK constraint pins closure_state to (open|closed|closed-unknown).
961
- // J3 computes accuracy (clean vs regressed) from (estimate_value,
962
- // actual_value) at query time.
963
- if (!tableExists(db, 'predictions')) {
964
- db.exec(`
965
- CREATE TABLE predictions (
966
- id INTEGER PRIMARY KEY AUTOINCREMENT,
967
- memory_id TEXT,
968
- tenant_id TEXT NOT NULL,
969
- class_tag TEXT NOT NULL,
970
- claim_text TEXT NOT NULL,
971
- estimate_value REAL,
972
- estimate_unit TEXT,
973
- target_date TEXT,
974
- actual_value REAL,
975
- closure_state TEXT NOT NULL DEFAULT 'open'
976
- CHECK (closure_state IN ('open', 'closed', 'closed-unknown')),
977
- closed_at TEXT,
978
- closure_note TEXT,
979
- created_at TEXT NOT NULL,
980
- FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE SET NULL
981
- )
982
- `);
983
- db.exec(`
984
- CREATE INDEX IF NOT EXISTS idx_predictions_tenant_class
985
- ON predictions(tenant_id, class_tag, closure_state)
986
- `);
987
- db.exec(`
988
- CREATE INDEX IF NOT EXISTS idx_predictions_memory
989
- ON predictions(memory_id) WHERE memory_id IS NOT NULL
990
- `);
991
- // Cross-tenant safety: tenant_id must match the referenced memory's
992
- // tenant_id when memory_id IS NOT NULL. INSERT + UPDATE pair, mirroring
993
- // v14 memories.kind enforcement (db.ts:298-322).
994
- db.exec(`
995
- CREATE TRIGGER IF NOT EXISTS trg_predictions_tenant_match_insert
996
- BEFORE INSERT ON predictions
997
- WHEN NEW.memory_id IS NOT NULL
998
- BEGIN
999
- SELECT CASE
1000
- WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1001
- THEN RAISE(ABORT, 'predictions.tenant_id must match memories.tenant_id for the referenced memory')
1002
- END;
1003
- END
1004
- `);
1005
- db.exec(`
1006
- CREATE TRIGGER IF NOT EXISTS trg_predictions_tenant_match_update
1007
- BEFORE UPDATE ON predictions
1008
- WHEN NEW.memory_id IS NOT NULL
1009
- AND (NEW.memory_id IS NOT OLD.memory_id OR NEW.tenant_id IS NOT OLD.tenant_id)
1010
- BEGIN
1011
- SELECT CASE
1012
- WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1013
- THEN RAISE(ABORT, 'predictions.tenant_id must match memories.tenant_id for the referenced memory')
1014
- END;
1015
- END
1016
- `);
1017
- }
1018
- },
1019
- },
1020
- {
1021
- version: 30,
1022
- up: (db) => {
1023
- // E2 decision first-class object (docs/plans/2026-05-28-e2-decision-object.md).
1024
- // Promotes `hippo decide` from a tagged memory (which decayed on a 90-day
1025
- // half-life even while the decision was still in force) to a canonical
1026
- // decisions table that is the source of truth. The memory mirror is kept
1027
- // for recall but is no longer authoritative; memory_id is NULLABLE with
1028
- // ON DELETE SET NULL so forget/consolidate/archive does not lose a
1029
- // decision. Mirrors the v29 predictions tenant-match trigger pattern.
1030
- //
1031
- // status (active|superseded|closed): superseded carries a self-FK
1032
- // superseded_by to the successor decision; closed is a terminal
1033
- // retire-without-successor. A superseded_by same-tenant trigger makes
1034
- // cross-tenant supersession unrepresentable at the schema level.
1035
- if (!tableExists(db, 'decisions')) {
1036
- db.exec(`
1037
- CREATE TABLE decisions (
1038
- id INTEGER PRIMARY KEY AUTOINCREMENT,
1039
- memory_id TEXT,
1040
- tenant_id TEXT NOT NULL,
1041
- decision_text TEXT NOT NULL,
1042
- context TEXT,
1043
- status TEXT NOT NULL DEFAULT 'active'
1044
- CHECK (status IN ('active', 'superseded', 'closed')),
1045
- superseded_by INTEGER,
1046
- superseded_at TEXT,
1047
- closed_at TEXT,
1048
- created_at TEXT NOT NULL,
1049
- FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE SET NULL,
1050
- FOREIGN KEY (superseded_by) REFERENCES decisions(id) ON DELETE SET NULL
1051
- )
1052
- `);
1053
- db.exec(`
1054
- CREATE INDEX IF NOT EXISTS idx_decisions_tenant_status
1055
- ON decisions(tenant_id, status)
1056
- `);
1057
- db.exec(`
1058
- CREATE INDEX IF NOT EXISTS idx_decisions_memory
1059
- ON decisions(memory_id) WHERE memory_id IS NOT NULL
1060
- `);
1061
- // Cross-tenant safety vs the referenced memory (mirrors predictions v29).
1062
- db.exec(`
1063
- CREATE TRIGGER IF NOT EXISTS trg_decisions_tenant_match_insert
1064
- BEFORE INSERT ON decisions
1065
- WHEN NEW.memory_id IS NOT NULL
1066
- BEGIN
1067
- SELECT CASE
1068
- WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1069
- THEN RAISE(ABORT, 'decisions.tenant_id must match memories.tenant_id for the referenced memory')
1070
- END;
1071
- END
1072
- `);
1073
- db.exec(`
1074
- CREATE TRIGGER IF NOT EXISTS trg_decisions_tenant_match_update
1075
- BEFORE UPDATE ON decisions
1076
- WHEN NEW.memory_id IS NOT NULL
1077
- AND (NEW.memory_id IS NOT OLD.memory_id OR NEW.tenant_id IS NOT OLD.tenant_id)
1078
- BEGIN
1079
- SELECT CASE
1080
- WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1081
- THEN RAISE(ABORT, 'decisions.tenant_id must match memories.tenant_id for the referenced memory')
1082
- END;
1083
- END
1084
- `);
1085
- // Cross-tenant safety vs the successor decision (self-FK). superseded_by
1086
- // is set only via UPDATE (the supersede path); the successor must share
1087
- // the tenant. The successor row already exists in the same transaction
1088
- // when this fires, so the subquery resolves.
1089
- db.exec(`
1090
- CREATE TRIGGER IF NOT EXISTS trg_decisions_supersede_tenant_match_update
1091
- BEFORE UPDATE ON decisions
1092
- WHEN NEW.superseded_by IS NOT NULL
1093
- AND NEW.superseded_by IS NOT OLD.superseded_by
1094
- BEGIN
1095
- SELECT CASE
1096
- WHEN NEW.tenant_id != (SELECT tenant_id FROM decisions WHERE id = NEW.superseded_by)
1097
- THEN RAISE(ABORT, 'decisions.superseded_by must reference a decision in the same tenant')
1098
- END;
1099
- END
1100
- `);
1101
- }
1102
- },
1103
- },
1104
- {
1105
- version: 31,
1106
- up: (db) => {
1107
- // E2 incident first-class object (docs/plans/2026-05-29-e2-incident-object.md).
1108
- // Mirrors the v30 decisions block but for an open -> resolved -> closed
1109
- // lifecycle (NOT supersede): there is no superseded_by self-FK and no
1110
- // supersede trigger. An incident is a postmortem capsule: a recorded
1111
- // operational event with a lifecycle and optional linked receipts (the
1112
- // memories that are its evidence, stored as a JSON array of ids in
1113
- // linked_memory_ids). The memory mirror is kept for recall but is not
1114
- // authoritative; memory_id is NULLABLE with ON DELETE SET NULL so
1115
- // forget/consolidate/archive does not lose an incident.
1116
- //
1117
- // status (open|resolved|closed): resolved records a resolution_text +
1118
- // resolved_at and stays on record; closed is a terminal retire reachable
1119
- // from open or resolved. Cross-tenant safety: BEFORE INSERT + BEFORE
1120
- // UPDATE triggers enforce incidents.tenant_id == the referenced memory's
1121
- // tenant_id (verbatim mirror of the v30 decisions tenant-match triggers).
1122
- if (!tableExists(db, 'incidents')) {
1123
- db.exec(`
1124
- CREATE TABLE incidents (
1125
- id INTEGER PRIMARY KEY AUTOINCREMENT,
1126
- memory_id TEXT,
1127
- tenant_id TEXT NOT NULL,
1128
- incident_text TEXT NOT NULL,
1129
- context TEXT,
1130
- status TEXT NOT NULL DEFAULT 'open'
1131
- CHECK (status IN ('open', 'resolved', 'closed')),
1132
- resolution_text TEXT,
1133
- resolved_at TEXT,
1134
- closed_at TEXT,
1135
- linked_memory_ids TEXT NOT NULL DEFAULT '[]',
1136
- created_at TEXT NOT NULL,
1137
- FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE SET NULL
1138
- )
1139
- `);
1140
- db.exec(`
1141
- CREATE INDEX IF NOT EXISTS idx_incidents_tenant_status
1142
- ON incidents(tenant_id, status)
1143
- `);
1144
- db.exec(`
1145
- CREATE INDEX IF NOT EXISTS idx_incidents_memory
1146
- ON incidents(memory_id) WHERE memory_id IS NOT NULL
1147
- `);
1148
- // Cross-tenant safety vs the referenced memory (verbatim mirror of the
1149
- // v30 decisions tenant-match triggers; no supersede trigger).
1150
- db.exec(`
1151
- CREATE TRIGGER IF NOT EXISTS trg_incidents_tenant_match_insert
1152
- BEFORE INSERT ON incidents
1153
- WHEN NEW.memory_id IS NOT NULL
1154
- BEGIN
1155
- SELECT CASE
1156
- WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1157
- THEN RAISE(ABORT, 'incidents.tenant_id must match memories.tenant_id for the referenced memory')
1158
- END;
1159
- END
1160
- `);
1161
- db.exec(`
1162
- CREATE TRIGGER IF NOT EXISTS trg_incidents_tenant_match_update
1163
- BEFORE UPDATE ON incidents
1164
- WHEN NEW.memory_id IS NOT NULL
1165
- AND (NEW.memory_id IS NOT OLD.memory_id OR NEW.tenant_id IS NOT OLD.tenant_id)
1166
- BEGIN
1167
- SELECT CASE
1168
- WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1169
- THEN RAISE(ABORT, 'incidents.tenant_id must match memories.tenant_id for the referenced memory')
1170
- END;
1171
- END
1172
- `);
1173
- }
1174
- },
1175
- },
1176
- {
1177
- version: 32,
1178
- up: (db) => {
1179
- // E2 process first-class object (docs/plans/2026-05-29-e2-process-object.md).
1180
- // A process is a "living process map": a named, ordered list of steps that
1181
- // evolves. Unlike incident (open->resolved->closed, no supersede), process
1182
- // REUSES the v30 decisions supersede path as its delta mechanism: a process
1183
- // evolves by being superseded by a NEW VERSION that records what changed
1184
- // (change_summary) and the full new state (steps), carrying a derived
1185
- // version counter. So this table combines the v31 incidents tenant-match
1186
- // trigger pair (vs the referenced memory) WITH the v30 decisions
1187
- // superseded_by self-FK + supersede tenant-match trigger.
1188
- //
1189
- // status (active|superseded|closed): superseded carries a self-FK
1190
- // superseded_by to the successor version; closed is a terminal
1191
- // retire-without-successor (only an active head closes). The memory mirror
1192
- // is kept for recall but is not authoritative; memory_id is NULLABLE with
1193
- // ON DELETE SET NULL so forget/consolidate/archive does not lose a process.
1194
- // steps is a JSON-encoded array of step strings (scoped v1; a normalized
1195
- // process_steps table is deferred). version is server-derived
1196
- // (predecessor.version + 1); change_summary is set on a successor row only.
1197
- if (!tableExists(db, 'processes')) {
1198
- db.exec(`
1199
- CREATE TABLE processes (
1200
- id INTEGER PRIMARY KEY AUTOINCREMENT,
1201
- memory_id TEXT,
1202
- tenant_id TEXT NOT NULL,
1203
- process_name TEXT NOT NULL,
1204
- description TEXT,
1205
- steps TEXT NOT NULL DEFAULT '[]',
1206
- version INTEGER NOT NULL DEFAULT 1,
1207
- status TEXT NOT NULL DEFAULT 'active'
1208
- CHECK (status IN ('active', 'superseded', 'closed')),
1209
- superseded_by INTEGER,
1210
- superseded_at TEXT,
1211
- change_summary TEXT,
1212
- closed_at TEXT,
1213
- created_at TEXT NOT NULL,
1214
- FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE SET NULL,
1215
- FOREIGN KEY (superseded_by) REFERENCES processes(id) ON DELETE SET NULL
1216
- )
1217
- `);
1218
- db.exec(`
1219
- CREATE INDEX IF NOT EXISTS idx_processes_tenant_status
1220
- ON processes(tenant_id, status)
1221
- `);
1222
- db.exec(`
1223
- CREATE INDEX IF NOT EXISTS idx_processes_memory
1224
- ON processes(memory_id) WHERE memory_id IS NOT NULL
1225
- `);
1226
- // Cross-tenant safety vs the referenced memory (verbatim mirror of the
1227
- // v31 incidents / v30 decisions tenant-match triggers).
1228
- db.exec(`
1229
- CREATE TRIGGER IF NOT EXISTS trg_processes_tenant_match_insert
1230
- BEFORE INSERT ON processes
1231
- WHEN NEW.memory_id IS NOT NULL
1232
- BEGIN
1233
- SELECT CASE
1234
- WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1235
- THEN RAISE(ABORT, 'processes.tenant_id must match memories.tenant_id for the referenced memory')
1236
- END;
1237
- END
1238
- `);
1239
- db.exec(`
1240
- CREATE TRIGGER IF NOT EXISTS trg_processes_tenant_match_update
1241
- BEFORE UPDATE ON processes
1242
- WHEN NEW.memory_id IS NOT NULL
1243
- AND (NEW.memory_id IS NOT OLD.memory_id OR NEW.tenant_id IS NOT OLD.tenant_id)
1244
- BEGIN
1245
- SELECT CASE
1246
- WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1247
- THEN RAISE(ABORT, 'processes.tenant_id must match memories.tenant_id for the referenced memory')
1248
- END;
1249
- END
1250
- `);
1251
- // Cross-tenant safety vs the successor process (self-FK; verbatim mirror
1252
- // of the v30 decisions supersede trigger). superseded_by is set only via
1253
- // the supersede UPDATE; the successor must share the tenant. The successor
1254
- // row already exists in the same transaction when this fires.
1255
- db.exec(`
1256
- CREATE TRIGGER IF NOT EXISTS trg_processes_supersede_tenant_match_update
1257
- BEFORE UPDATE ON processes
1258
- WHEN NEW.superseded_by IS NOT NULL
1259
- AND NEW.superseded_by IS NOT OLD.superseded_by
1260
- BEGIN
1261
- SELECT CASE
1262
- WHEN NEW.tenant_id != (SELECT tenant_id FROM processes WHERE id = NEW.superseded_by)
1263
- THEN RAISE(ABORT, 'processes.superseded_by must reference a process in the same tenant')
1264
- END;
1265
- END
1266
- `);
1267
- }
1268
- },
1269
- },
1270
- {
1271
- version: 33,
1272
- up: (db) => {
1273
- // E2 policy first-class object (docs/plans/2026-05-30-e2-policy-object.md).
1274
- // The "bi-temporal-first" object type: a named rule/statement that is in
1275
- // force over an EFFECTIVE-TIME range (valid_from required, valid_to nullable
1276
- // = open-ended) and evolves via the v32 processes supersede machinery
1277
- // (superseded_by self-FK + supersede tenant-match trigger + version +
1278
- // change_summary). This table = the v32 processes table MINUS `steps`
1279
- // (a policy has policy_text, not an ordered step list) PLUS the first-class
1280
- // effective-time columns valid_from/valid_to. Valid-time is the queryable
1281
- // axis (the as-of query loadPoliciesAsOf); transaction-time is present via
1282
- // created_at + the supersede chain's superseded_at (time-travel deferred).
1283
- //
1284
- // All date inputs are normalized to canonical ISO-8601 datetime
1285
- // (toISOString) at the store boundary before persist/compare, so the
1286
- // fixed-width values sort lexically and the half-open [valid_from, valid_to)
1287
- // as-of comparison is correct (plan-eng-critic round-1 CRIT fix).
1288
- if (!tableExists(db, 'policies')) {
1289
- db.exec(`
1290
- CREATE TABLE policies (
1291
- id INTEGER PRIMARY KEY AUTOINCREMENT,
1292
- memory_id TEXT,
1293
- tenant_id TEXT NOT NULL,
1294
- policy_name TEXT NOT NULL,
1295
- policy_text TEXT NOT NULL,
1296
- valid_from TEXT NOT NULL,
1297
- valid_to TEXT,
1298
- version INTEGER NOT NULL DEFAULT 1,
1299
- status TEXT NOT NULL DEFAULT 'active'
1300
- CHECK (status IN ('active', 'superseded', 'closed')),
1301
- superseded_by INTEGER,
1302
- superseded_at TEXT,
1303
- change_summary TEXT,
1304
- closed_at TEXT,
1305
- created_at TEXT NOT NULL,
1306
- FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE SET NULL,
1307
- FOREIGN KEY (superseded_by) REFERENCES policies(id) ON DELETE SET NULL
1308
- )
1309
- `);
1310
- db.exec(`
1311
- CREATE INDEX IF NOT EXISTS idx_policies_tenant_status
1312
- ON policies(tenant_id, status)
1313
- `);
1314
- db.exec(`
1315
- CREATE INDEX IF NOT EXISTS idx_policies_memory
1316
- ON policies(memory_id) WHERE memory_id IS NOT NULL
1317
- `);
1318
- // Supports the as-of query (active policies in force at a valid-time).
1319
- db.exec(`
1320
- CREATE INDEX IF NOT EXISTS idx_policies_asof
1321
- ON policies(tenant_id, valid_from)
1322
- `);
1323
- // Cross-tenant safety vs the referenced memory (verbatim mirror of the
1324
- // v32 processes tenant-match triggers).
1325
- db.exec(`
1326
- CREATE TRIGGER IF NOT EXISTS trg_policies_tenant_match_insert
1327
- BEFORE INSERT ON policies
1328
- WHEN NEW.memory_id IS NOT NULL
1329
- BEGIN
1330
- SELECT CASE
1331
- WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1332
- THEN RAISE(ABORT, 'policies.tenant_id must match memories.tenant_id for the referenced memory')
1333
- END;
1334
- END
1335
- `);
1336
- db.exec(`
1337
- CREATE TRIGGER IF NOT EXISTS trg_policies_tenant_match_update
1338
- BEFORE UPDATE ON policies
1339
- WHEN NEW.memory_id IS NOT NULL
1340
- AND (NEW.memory_id IS NOT OLD.memory_id OR NEW.tenant_id IS NOT OLD.tenant_id)
1341
- BEGIN
1342
- SELECT CASE
1343
- WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1344
- THEN RAISE(ABORT, 'policies.tenant_id must match memories.tenant_id for the referenced memory')
1345
- END;
1346
- END
1347
- `);
1348
- // Cross-tenant safety vs the successor policy (self-FK; verbatim mirror of
1349
- // the v32 processes supersede trigger).
1350
- db.exec(`
1351
- CREATE TRIGGER IF NOT EXISTS trg_policies_supersede_tenant_match_update
1352
- BEFORE UPDATE ON policies
1353
- WHEN NEW.superseded_by IS NOT NULL
1354
- AND NEW.superseded_by IS NOT OLD.superseded_by
1355
- BEGIN
1356
- SELECT CASE
1357
- WHEN NEW.tenant_id != (SELECT tenant_id FROM policies WHERE id = NEW.superseded_by)
1358
- THEN RAISE(ABORT, 'policies.superseded_by must reference a policy in the same tenant')
1359
- END;
1360
- END
1361
- `);
1362
- }
1363
- },
1364
- },
1365
- {
1366
- version: 34,
1367
- up: (db) => {
1368
- // E2 skill first-class object (docs/plans/2026-05-30-e2-skill-object.md).
1369
- // A skill is a reusable, agent-followable capability: an `instructions` body
1370
- // + an optional `trigger_text` (when to apply), evolving via the v32
1371
- // processes supersede machinery (superseded_by self-FK + supersede
1372
- // tenant-match trigger + version + change_summary). This table = the v32
1373
- // processes table MINUS `steps` (a skill's content is a single instructions
1374
- // body) PLUS `instructions` (NOT NULL) and `trigger_text`. "Executable" is
1375
- // scoped to an agent-followable instruction that EXPORTS into the agent's
1376
- // in-force rules (AGENTS.md / CLAUDE.md) via exportSkills; literal code
1377
- // execution is deferred. NOTE: the column is `trigger_text`, NOT `trigger`,
1378
- // because TRIGGER is a SQLite reserved keyword.
1379
- if (!tableExists(db, 'skills')) {
1380
- db.exec(`
1381
- CREATE TABLE skills (
1382
- id INTEGER PRIMARY KEY AUTOINCREMENT,
1383
- memory_id TEXT,
1384
- tenant_id TEXT NOT NULL,
1385
- skill_name TEXT NOT NULL,
1386
- instructions TEXT NOT NULL,
1387
- trigger_text TEXT,
1388
- version INTEGER NOT NULL DEFAULT 1,
1389
- status TEXT NOT NULL DEFAULT 'active'
1390
- CHECK (status IN ('active', 'superseded', 'closed')),
1391
- superseded_by INTEGER,
1392
- superseded_at TEXT,
1393
- change_summary TEXT,
1394
- closed_at TEXT,
1395
- created_at TEXT NOT NULL,
1396
- FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE SET NULL,
1397
- FOREIGN KEY (superseded_by) REFERENCES skills(id) ON DELETE SET NULL
1398
- )
1399
- `);
1400
- db.exec(`
1401
- CREATE INDEX IF NOT EXISTS idx_skills_tenant_status
1402
- ON skills(tenant_id, status)
1403
- `);
1404
- db.exec(`
1405
- CREATE INDEX IF NOT EXISTS idx_skills_memory
1406
- ON skills(memory_id) WHERE memory_id IS NOT NULL
1407
- `);
1408
- // Cross-tenant safety vs the referenced memory (verbatim mirror of the
1409
- // v32 processes tenant-match triggers).
1410
- db.exec(`
1411
- CREATE TRIGGER IF NOT EXISTS trg_skills_tenant_match_insert
1412
- BEFORE INSERT ON skills
1413
- WHEN NEW.memory_id IS NOT NULL
1414
- BEGIN
1415
- SELECT CASE
1416
- WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1417
- THEN RAISE(ABORT, 'skills.tenant_id must match memories.tenant_id for the referenced memory')
1418
- END;
1419
- END
1420
- `);
1421
- db.exec(`
1422
- CREATE TRIGGER IF NOT EXISTS trg_skills_tenant_match_update
1423
- BEFORE UPDATE ON skills
1424
- WHEN NEW.memory_id IS NOT NULL
1425
- AND (NEW.memory_id IS NOT OLD.memory_id OR NEW.tenant_id IS NOT OLD.tenant_id)
1426
- BEGIN
1427
- SELECT CASE
1428
- WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1429
- THEN RAISE(ABORT, 'skills.tenant_id must match memories.tenant_id for the referenced memory')
1430
- END;
1431
- END
1432
- `);
1433
- // Cross-tenant safety vs the successor skill (self-FK; verbatim mirror of
1434
- // the v32 processes supersede trigger).
1435
- db.exec(`
1436
- CREATE TRIGGER IF NOT EXISTS trg_skills_supersede_tenant_match_update
1437
- BEFORE UPDATE ON skills
1438
- WHEN NEW.superseded_by IS NOT NULL
1439
- AND NEW.superseded_by IS NOT OLD.superseded_by
1440
- BEGIN
1441
- SELECT CASE
1442
- WHEN NEW.tenant_id != (SELECT tenant_id FROM skills WHERE id = NEW.superseded_by)
1443
- THEN RAISE(ABORT, 'skills.superseded_by must reference a skill in the same tenant')
1444
- END;
1445
- END
1446
- `);
1447
- }
1448
- },
1449
- },
1450
- {
1451
- version: 35,
1452
- up: (db) => {
1453
- // E2 project_brief first-class object
1454
- // (docs/plans/2026-05-30-e2-project-brief-object.md). A project_brief is the
1455
- // living, repo-scoped summary of a repository's state: a `summary` body
1456
- // scoped to a `repo`, evolving via the v34 skills supersede machinery
1457
- // (superseded_by self-FK + supersede tenant-match trigger + version +
1458
- // change_summary). This table = the v34 skills table with
1459
- // skill_name/trigger_text replaced by `repo` (the repo-scoping dimension)
1460
- // PLUS `summary` (the brief body). The distinguishing op (refreshBrief, in
1461
- // src/project-briefs.ts) auto-assembles the summary from the repo's receipts
1462
- // (memory rows tagged path:<repo>); it needs no schema support beyond `repo`.
1463
- // All column names were checked against SQLite reserved words (skill-episode
1464
- // lesson re: `trigger`): repo/summary/version/status/etc. are non-reserved.
1465
- if (!tableExists(db, 'project_briefs')) {
1466
- db.exec(`
1467
- CREATE TABLE project_briefs (
1468
- id INTEGER PRIMARY KEY AUTOINCREMENT,
1469
- memory_id TEXT,
1470
- tenant_id TEXT NOT NULL,
1471
- repo TEXT NOT NULL,
1472
- summary TEXT NOT NULL,
1473
- version INTEGER NOT NULL DEFAULT 1,
1474
- status TEXT NOT NULL DEFAULT 'active'
1475
- CHECK (status IN ('active', 'superseded', 'closed')),
1476
- superseded_by INTEGER,
1477
- superseded_at TEXT,
1478
- change_summary TEXT,
1479
- closed_at TEXT,
1480
- created_at TEXT NOT NULL,
1481
- FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE SET NULL,
1482
- FOREIGN KEY (superseded_by) REFERENCES project_briefs(id) ON DELETE SET NULL
1483
- )
1484
- `);
1485
- db.exec(`
1486
- CREATE INDEX IF NOT EXISTS idx_project_briefs_tenant_status
1487
- ON project_briefs(tenant_id, status)
1488
- `);
1489
- db.exec(`
1490
- CREATE INDEX IF NOT EXISTS idx_project_briefs_memory
1491
- ON project_briefs(memory_id) WHERE memory_id IS NOT NULL
1492
- `);
1493
- db.exec(`
1494
- CREATE INDEX IF NOT EXISTS idx_project_briefs_repo
1495
- ON project_briefs(tenant_id, repo, status)
1496
- `);
1497
- // Cross-tenant safety vs the referenced memory (verbatim mirror of the
1498
- // v34 skills tenant-match triggers).
1499
- db.exec(`
1500
- CREATE TRIGGER IF NOT EXISTS trg_project_briefs_tenant_match_insert
1501
- BEFORE INSERT ON project_briefs
1502
- WHEN NEW.memory_id IS NOT NULL
1503
- BEGIN
1504
- SELECT CASE
1505
- WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1506
- THEN RAISE(ABORT, 'project_briefs.tenant_id must match memories.tenant_id for the referenced memory')
1507
- END;
1508
- END
1509
- `);
1510
- db.exec(`
1511
- CREATE TRIGGER IF NOT EXISTS trg_project_briefs_tenant_match_update
1512
- BEFORE UPDATE ON project_briefs
1513
- WHEN NEW.memory_id IS NOT NULL
1514
- AND (NEW.memory_id IS NOT OLD.memory_id OR NEW.tenant_id IS NOT OLD.tenant_id)
1515
- BEGIN
1516
- SELECT CASE
1517
- WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1518
- THEN RAISE(ABORT, 'project_briefs.tenant_id must match memories.tenant_id for the referenced memory')
1519
- END;
1520
- END
1521
- `);
1522
- // Cross-tenant safety vs the successor brief (self-FK; verbatim mirror of
1523
- // the v34 skills supersede trigger).
1524
- db.exec(`
1525
- CREATE TRIGGER IF NOT EXISTS trg_project_briefs_supersede_tenant_match_update
1526
- BEFORE UPDATE ON project_briefs
1527
- WHEN NEW.superseded_by IS NOT NULL
1528
- AND NEW.superseded_by IS NOT OLD.superseded_by
1529
- BEGIN
1530
- SELECT CASE
1531
- WHEN NEW.tenant_id != (SELECT tenant_id FROM project_briefs WHERE id = NEW.superseded_by)
1532
- THEN RAISE(ABORT, 'project_briefs.superseded_by must reference a project_brief in the same tenant')
1533
- END;
1534
- END
1535
- `);
1536
- }
1537
- },
1538
- },
1539
- {
1540
- version: 36,
1541
- up: (db) => {
1542
- // E2 customer_note first-class object (the LAST E2 object)
1543
- // (docs/plans/2026-06-01-e2-customer-note-object.md). A customer_note is a
1544
- // discrete note recorded against an account/customer entity, evolving via the
1545
- // v35 project_briefs supersede machinery (superseded_by self-FK + supersede
1546
- // tenant-match trigger + version + change_summary). This table = the v35
1547
- // project_briefs table with repo/summary replaced by `customer` (the
1548
- // entity-scoping dimension; a free-form account/customer id - the entities
1549
- // table is unbuilt E3.1, so a FK is deferred) PLUS `note` (the note body).
1550
- // MANY notes per customer (each its own supersede chain), unlike the
1551
- // one-summary-per-repo project_brief. All column names checked against SQLite
1552
- // reserved words (skill-episode lesson, codebase-audit rule 10): customer/note/
1553
- // version/status/etc. are non-reserved.
1554
- if (!tableExists(db, 'customer_notes')) {
1555
- db.exec(`
1556
- CREATE TABLE customer_notes (
1557
- id INTEGER PRIMARY KEY AUTOINCREMENT,
1558
- memory_id TEXT,
1559
- tenant_id TEXT NOT NULL,
1560
- customer TEXT NOT NULL,
1561
- note TEXT NOT NULL,
1562
- version INTEGER NOT NULL DEFAULT 1,
1563
- status TEXT NOT NULL DEFAULT 'active'
1564
- CHECK (status IN ('active', 'superseded', 'closed')),
1565
- superseded_by INTEGER,
1566
- superseded_at TEXT,
1567
- change_summary TEXT,
1568
- closed_at TEXT,
1569
- created_at TEXT NOT NULL,
1570
- FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE SET NULL,
1571
- FOREIGN KEY (superseded_by) REFERENCES customer_notes(id) ON DELETE SET NULL
1572
- )
1573
- `);
1574
- db.exec(`
1575
- CREATE INDEX IF NOT EXISTS idx_customer_notes_tenant_status
1576
- ON customer_notes(tenant_id, status)
1577
- `);
1578
- db.exec(`
1579
- CREATE INDEX IF NOT EXISTS idx_customer_notes_memory
1580
- ON customer_notes(memory_id) WHERE memory_id IS NOT NULL
1581
- `);
1582
- db.exec(`
1583
- CREATE INDEX IF NOT EXISTS idx_customer_notes_customer
1584
- ON customer_notes(tenant_id, customer, status)
1585
- `);
1586
- // Cross-tenant safety vs the referenced memory (verbatim mirror of the
1587
- // v35 project_briefs tenant-match triggers).
1588
- db.exec(`
1589
- CREATE TRIGGER IF NOT EXISTS trg_customer_notes_tenant_match_insert
1590
- BEFORE INSERT ON customer_notes
1591
- WHEN NEW.memory_id IS NOT NULL
1592
- BEGIN
1593
- SELECT CASE
1594
- WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1595
- THEN RAISE(ABORT, 'customer_notes.tenant_id must match memories.tenant_id for the referenced memory')
1596
- END;
1597
- END
1598
- `);
1599
- db.exec(`
1600
- CREATE TRIGGER IF NOT EXISTS trg_customer_notes_tenant_match_update
1601
- BEFORE UPDATE ON customer_notes
1602
- WHEN NEW.memory_id IS NOT NULL
1603
- AND (NEW.memory_id IS NOT OLD.memory_id OR NEW.tenant_id IS NOT OLD.tenant_id)
1604
- BEGIN
1605
- SELECT CASE
1606
- WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1607
- THEN RAISE(ABORT, 'customer_notes.tenant_id must match memories.tenant_id for the referenced memory')
1608
- END;
1609
- END
1610
- `);
1611
- // Cross-tenant safety vs the successor note (self-FK; verbatim mirror of the
1612
- // v35 project_briefs supersede trigger).
1613
- db.exec(`
1614
- CREATE TRIGGER IF NOT EXISTS trg_customer_notes_supersede_tenant_match_update
1615
- BEFORE UPDATE ON customer_notes
1616
- WHEN NEW.superseded_by IS NOT NULL
1617
- AND NEW.superseded_by IS NOT OLD.superseded_by
1618
- BEGIN
1619
- SELECT CASE
1620
- WHEN NEW.tenant_id != (SELECT tenant_id FROM customer_notes WHERE id = NEW.superseded_by)
1621
- THEN RAISE(ABORT, 'customer_notes.superseded_by must reference a customer_note in the same tenant')
1622
- END;
1623
- END
1624
- `);
1625
- }
1626
- },
1627
- },
1628
- {
1629
- version: 37,
1630
- up: (db) => {
1631
- // E3.3 graph-on-consolidated guard (docs/plans/2026-06-01-e3-graph-guard.md).
1632
- // The graph layer (entities + relations) sits ON TOP OF consolidated state and
1633
- // must NEVER index the raw layer. The substrate: entities + relations +
1634
- // graph_extraction_queue, each FK-ing to memories and guarded so they can only
1635
- // reference CONSOLIDATED memories (kind IN ('distilled','superseded')), never
1636
- // kind='raw'. New tables -> real CHECK constraints (unlike the ALTER'd memories,
1637
- // whose kind CHECK lives in triggers). The kind/source MATCH (source_kind ==
1638
- // the FK'd memory's actual kind) cannot be a CHECK (CHECK can't subquery), so it
1639
- // is a BEFORE INSERT *and* BEFORE UPDATE trigger (the subquery-capable pattern
1640
- // from the v30 decisions / predictions tenant-match triggers). Both INSERT and
1641
- // UPDATE are guarded: an INSERT-only guard is bypassable via a raw SQL UPDATE
1642
- // that moves a row onto a raw memory (plan-eng-critic 2026-06-01). All column
1643
- // names checked vs SQL reserved words (rule 10): rel_type avoids REFERENCES.
1644
- if (!tableExists(db, 'entities')) {
1645
- db.exec(`
1646
- CREATE TABLE entities (
1647
- id INTEGER PRIMARY KEY AUTOINCREMENT,
1648
- tenant_id TEXT NOT NULL,
1649
- entity_type TEXT NOT NULL
1650
- CHECK (entity_type IN ('person', 'project', 'customer', 'system', 'policy', 'decision')),
1651
- name TEXT NOT NULL,
1652
- memory_id TEXT NOT NULL,
1653
- source_kind TEXT NOT NULL CHECK (source_kind IN ('distilled', 'superseded')),
1654
- created_at TEXT NOT NULL,
1655
- FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE CASCADE
1656
- )
1657
- `);
1658
- db.exec(`CREATE INDEX IF NOT EXISTS idx_entities_tenant ON entities(tenant_id)`);
1659
- db.exec(`CREATE INDEX IF NOT EXISTS idx_entities_memory ON entities(memory_id)`);
1660
- db.exec(`
1661
- CREATE TABLE relations (
1662
- id INTEGER PRIMARY KEY AUTOINCREMENT,
1663
- tenant_id TEXT NOT NULL,
1664
- from_entity_id INTEGER NOT NULL,
1665
- to_entity_id INTEGER NOT NULL,
1666
- rel_type TEXT NOT NULL
1667
- CHECK (rel_type IN ('owns', 'supersedes', 'depends-on', 'blocked-by', 'references')),
1668
- memory_id TEXT NOT NULL,
1669
- source_kind TEXT NOT NULL CHECK (source_kind IN ('distilled', 'superseded')),
1670
- created_at TEXT NOT NULL,
1671
- FOREIGN KEY (from_entity_id) REFERENCES entities(id) ON DELETE CASCADE,
1672
- FOREIGN KEY (to_entity_id) REFERENCES entities(id) ON DELETE CASCADE,
1673
- FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE CASCADE
1674
- )
1675
- `);
1676
- db.exec(`CREATE INDEX IF NOT EXISTS idx_relations_tenant ON relations(tenant_id)`);
1677
- db.exec(`CREATE INDEX IF NOT EXISTS idx_relations_from ON relations(from_entity_id)`);
1678
- db.exec(`CREATE INDEX IF NOT EXISTS idx_relations_to ON relations(to_entity_id)`);
1679
- db.exec(`CREATE INDEX IF NOT EXISTS idx_relations_memory ON relations(memory_id)`);
1680
- db.exec(`
1681
- CREATE TABLE graph_extraction_queue (
1682
- id INTEGER PRIMARY KEY AUTOINCREMENT,
1683
- tenant_id TEXT NOT NULL,
1684
- memory_id TEXT NOT NULL,
1685
- kind TEXT NOT NULL CHECK (kind IN ('distilled', 'superseded')),
1686
- status TEXT NOT NULL DEFAULT 'pending'
1687
- CHECK (status IN ('pending', 'processed', 'skipped')),
1688
- enqueued_at TEXT NOT NULL,
1689
- processed_at TEXT,
1690
- FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE CASCADE
1691
- )
1692
- `);
1693
- db.exec(`CREATE INDEX IF NOT EXISTS idx_graph_queue_status ON graph_extraction_queue(tenant_id, status)`);
1694
- // entities guard: source_kind must equal the FK'd memory's actual kind (so a
1695
- // raw memory or a lying source_kind both ABORT), and tenant must match. Both
1696
- // INSERT and UPDATE (UPDATE fires when memory_id/source_kind/tenant_id change).
1697
- db.exec(`
1698
- CREATE TRIGGER IF NOT EXISTS trg_entities_consolidated_only_insert
1699
- BEFORE INSERT ON entities
1700
- BEGIN
1701
- SELECT CASE
1702
- WHEN NEW.source_kind != (SELECT kind FROM memories WHERE id = NEW.memory_id)
1703
- THEN RAISE(ABORT, 'entities.source_kind must equal the referenced memory kind; the graph indexes consolidated state only (no raw)')
1704
- WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1705
- THEN RAISE(ABORT, 'entities.tenant_id must match memories.tenant_id for the referenced memory')
1706
- END;
1707
- END
1708
- `);
1709
- db.exec(`
1710
- CREATE TRIGGER IF NOT EXISTS trg_entities_consolidated_only_update
1711
- BEFORE UPDATE ON entities
1712
- WHEN NEW.memory_id IS NOT OLD.memory_id
1713
- OR NEW.source_kind IS NOT OLD.source_kind
1714
- OR NEW.tenant_id IS NOT OLD.tenant_id
1715
- BEGIN
1716
- SELECT CASE
1717
- WHEN NEW.source_kind != (SELECT kind FROM memories WHERE id = NEW.memory_id)
1718
- THEN RAISE(ABORT, 'entities.source_kind must equal the referenced memory kind; the graph indexes consolidated state only (no raw)')
1719
- WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1720
- THEN RAISE(ABORT, 'entities.tenant_id must match memories.tenant_id for the referenced memory')
1721
- END;
1722
- END
1723
- `);
1724
- // relations guard: source_kind must equal the FK'd memory's kind; tenant must
1725
- // match the memory AND both endpoint entities (no cross-tenant edges).
1726
- db.exec(`
1727
- CREATE TRIGGER IF NOT EXISTS trg_relations_consolidated_only_insert
1728
- BEFORE INSERT ON relations
1729
- BEGIN
1730
- SELECT CASE
1731
- WHEN NEW.source_kind != (SELECT kind FROM memories WHERE id = NEW.memory_id)
1732
- THEN RAISE(ABORT, 'relations.source_kind must equal the referenced memory kind; the graph indexes consolidated state only (no raw)')
1733
- WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1734
- THEN RAISE(ABORT, 'relations.tenant_id must match memories.tenant_id for the referenced memory')
1735
- WHEN NEW.tenant_id != (SELECT tenant_id FROM entities WHERE id = NEW.from_entity_id)
1736
- THEN RAISE(ABORT, 'relations.tenant_id must match the from_entity tenant (no cross-tenant edges)')
1737
- WHEN NEW.tenant_id != (SELECT tenant_id FROM entities WHERE id = NEW.to_entity_id)
1738
- THEN RAISE(ABORT, 'relations.tenant_id must match the to_entity tenant (no cross-tenant edges)')
1739
- END;
1740
- END
1741
- `);
1742
- db.exec(`
1743
- CREATE TRIGGER IF NOT EXISTS trg_relations_consolidated_only_update
1744
- BEFORE UPDATE ON relations
1745
- WHEN NEW.memory_id IS NOT OLD.memory_id
1746
- OR NEW.source_kind IS NOT OLD.source_kind
1747
- OR NEW.tenant_id IS NOT OLD.tenant_id
1748
- OR NEW.from_entity_id IS NOT OLD.from_entity_id
1749
- OR NEW.to_entity_id IS NOT OLD.to_entity_id
1750
- BEGIN
1751
- SELECT CASE
1752
- WHEN NEW.source_kind != (SELECT kind FROM memories WHERE id = NEW.memory_id)
1753
- THEN RAISE(ABORT, 'relations.source_kind must equal the referenced memory kind; the graph indexes consolidated state only (no raw)')
1754
- WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1755
- THEN RAISE(ABORT, 'relations.tenant_id must match memories.tenant_id for the referenced memory')
1756
- WHEN NEW.tenant_id != (SELECT tenant_id FROM entities WHERE id = NEW.from_entity_id)
1757
- THEN RAISE(ABORT, 'relations.tenant_id must match the from_entity tenant (no cross-tenant edges)')
1758
- WHEN NEW.tenant_id != (SELECT tenant_id FROM entities WHERE id = NEW.to_entity_id)
1759
- THEN RAISE(ABORT, 'relations.tenant_id must match the to_entity tenant (no cross-tenant edges)')
1760
- END;
1761
- END
1762
- `);
1763
- // graph_extraction_queue guard: kind must equal the FK'd memory's actual kind
1764
- // (so a raw memory ABORTs), and tenant must match. INSERT and UPDATE.
1765
- db.exec(`
1766
- CREATE TRIGGER IF NOT EXISTS trg_graph_queue_consolidated_only_insert
1767
- BEFORE INSERT ON graph_extraction_queue
1768
- BEGIN
1769
- SELECT CASE
1770
- WHEN NEW.kind != (SELECT kind FROM memories WHERE id = NEW.memory_id)
1771
- THEN RAISE(ABORT, 'graph_extraction_queue.kind must equal the referenced memory kind; only consolidated memories are queued (no raw)')
1772
- WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1773
- THEN RAISE(ABORT, 'graph_extraction_queue.tenant_id must match memories.tenant_id for the referenced memory')
1774
- END;
1775
- END
1776
- `);
1777
- db.exec(`
1778
- CREATE TRIGGER IF NOT EXISTS trg_graph_queue_consolidated_only_update
1779
- BEFORE UPDATE ON graph_extraction_queue
1780
- WHEN NEW.memory_id IS NOT OLD.memory_id
1781
- OR NEW.kind IS NOT OLD.kind
1782
- OR NEW.tenant_id IS NOT OLD.tenant_id
1783
- BEGIN
1784
- SELECT CASE
1785
- WHEN NEW.kind != (SELECT kind FROM memories WHERE id = NEW.memory_id)
1786
- THEN RAISE(ABORT, 'graph_extraction_queue.kind must equal the referenced memory kind; only consolidated memories are queued (no raw)')
1787
- WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1788
- THEN RAISE(ABORT, 'graph_extraction_queue.tenant_id must match memories.tenant_id for the referenced memory')
1789
- END;
1790
- END
1791
- `);
1792
- // Reverse guard (codex-review-critic 2026-06-01, P1): the graph-table triggers
1793
- // only fire on writes to the GRAPH tables. They do NOT fire when an
1794
- // already-indexed memory is later mutated. So 'UPDATE memories SET kind=raw'
1795
- // (or a tenant change) on a memory the graph references would silently leave
1796
- // entity/relation/queue rows pointing at a raw / cross-tenant memory while
1797
- // their source_kind stays 'distilled' - bypassing the central 'graph never
1798
- // indexes raw' invariant after insertion. This trigger closes that direction:
1799
- // a memory cannot be reclassified to raw, nor moved cross-tenant, WHILE the
1800
- // graph references it (rebuild/remove the graph rows first). Cheap: the EXISTS
1801
- // checks are only evaluated when kind actually becomes raw or tenant changes.
1802
- db.exec(`
1803
- CREATE TRIGGER IF NOT EXISTS trg_memories_graph_referenced_guard
1804
- BEFORE UPDATE ON memories
1805
- WHEN (NEW.kind IS NOT OLD.kind OR NEW.tenant_id IS NOT OLD.tenant_id)
1806
- AND (
1807
- EXISTS (SELECT 1 FROM entities WHERE memory_id = OLD.id)
1808
- OR EXISTS (SELECT 1 FROM relations WHERE memory_id = OLD.id)
1809
- OR EXISTS (SELECT 1 FROM graph_extraction_queue WHERE memory_id = OLD.id)
1810
- )
1811
- BEGIN
1812
- SELECT RAISE(ABORT, 'cannot change the kind or tenant of a memory while the graph references it (E3.3 graph-on-consolidated guard); a graph-referenced memory is immutable in kind/tenant - rebuild/remove the graph rows first, or rebuild them after supersession');
1813
- END
1814
- `);
1815
- // Reverse guard #2 (codex-review-critic 2026-06-01 retry, P2): an entity that is
1816
- // a relation endpoint cannot be moved cross-tenant. The entity UPDATE trigger
1817
- // validates the entity against its source memory, but an existing relation
1818
- // pointing at the entity is NOT re-validated, so a raw 'UPDATE entities SET
1819
- // tenant_id=?, memory_id=?' to another tenant would leave a tenant-A relation
1820
- // pointing at a tenant-B entity. Block the tenant move while the entity is
1821
- // referenced by any relation (rebuild the relations first).
1822
- db.exec(`
1823
- CREATE TRIGGER IF NOT EXISTS trg_entities_no_tenant_move_when_referenced
1824
- BEFORE UPDATE ON entities
1825
- WHEN NEW.tenant_id IS NOT OLD.tenant_id
1826
- AND EXISTS (SELECT 1 FROM relations WHERE from_entity_id = OLD.id OR to_entity_id = OLD.id)
1827
- BEGIN
1828
- SELECT RAISE(ABORT, 'cannot move an entity cross-tenant while a relation references it as an endpoint (E3.3 graph-on-consolidated guard); rebuild/remove the relations first');
1829
- END
1830
- `);
1831
- }
1832
- },
1833
- },
1834
- {
1835
- version: 38,
1836
- up: (db) => {
1837
- // E2-provenance: anchor graph entity/relation provenance to the authoritative E2
1838
- // object (decision/policy/customer-note/project-brief) instead of the decaying
1839
- // memory mirror (docs/plans/2026-06-03-graph-e2-provenance.md). An in-force E2
1840
- // object must STAY in the graph after its mirror memory is forgotten or
1841
- // consolidation-pruned. The graph is a PURE DERIVED CACHE (clearGraph + rebuild on
1842
- // every `graph extract` / `sleep`), so v38 DROPs+recreates entities/relations (no
1843
- // data copy) and the next extract repopulates. graph_extraction_queue is untouched.
1844
- //
1845
- // Two provenance paths, "at least one, no raw":
1846
- // - memory path (memory_id NOT NULL): source_kind must equal the FK'd memory's live
1847
- // kind and that kind is distilled|superseded (raw still ABORTs) + tenant-match.
1848
- // - object path (memory_id NULL): source_object_type/id must reference an EXISTING
1849
- // same-tenant E2 row whose status is active|superseded (not closed). E2 objects
1850
- // are consolidated BY CONSTRUCTION, so the no-raw invariant still holds.
1851
- // - all-null is rejected.
1852
- //
1853
- // memory_id is now NULLABLE with ON DELETE SET NULL (was NOT NULL / CASCADE), so a
1854
- // mirror forget/consolidate nulls the recall pointer without dropping the row. NOTE
1855
- // (empirically verified, contradicts the SQLite docs): node:sqlite DOES fire the
1856
- // BEFORE UPDATE guard from the FK SET NULL action even with recursive_triggers OFF.
1857
- // So the *_consolidated_only_UPDATE triggers deliberately OMIT the all-null ABORT
1858
- // (kept on INSERT) - otherwise a mirror delete of a memory-only row would be blocked.
1859
- // See the per-trigger comments below. A SET NULL leaves source_kind at its old value
1860
- // by design (the object path is distilled-by-construction; source_kind is only
1861
- // re-checked when memory_id NOT NULL).
1862
- // source_object_id is a SOFT (type,id) pointer (no hard FK) the rebuild re-validates,
1863
- // so a legitimate E2 hard-delete is never blocked; a `closed` E2 row drops at next
1864
- // extract. SQLite cannot parametrize a table name in a trigger, so the object-path
1865
- // validation is an explicit 4-way CASE (one arm per E2 table).
1866
- // Drop child (relations FK entities) first, then parent.
1867
- db.exec('DROP TABLE IF EXISTS relations');
1868
- db.exec('DROP TABLE IF EXISTS entities');
1869
- db.exec(`
1870
- CREATE TABLE entities (
1871
- id INTEGER PRIMARY KEY AUTOINCREMENT,
1872
- tenant_id TEXT NOT NULL,
1873
- entity_type TEXT NOT NULL
1874
- CHECK (entity_type IN ('person', 'project', 'customer', 'system', 'policy', 'decision')),
1875
- name TEXT NOT NULL,
1876
- memory_id TEXT,
1877
- source_kind TEXT NOT NULL CHECK (source_kind IN ('distilled', 'superseded')),
1878
- source_object_type TEXT
1879
- CHECK (source_object_type IS NULL OR source_object_type IN ('decision', 'policy', 'customer', 'project')),
1880
- source_object_id INTEGER,
1881
- created_at TEXT NOT NULL,
1882
- FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE SET NULL
1883
- )
1884
- `);
1885
- db.exec(`CREATE INDEX IF NOT EXISTS idx_entities_tenant ON entities(tenant_id)`);
1886
- db.exec(`CREATE INDEX IF NOT EXISTS idx_entities_memory ON entities(memory_id)`);
1887
- db.exec(`CREATE INDEX IF NOT EXISTS idx_entities_source_object ON entities(source_object_type, source_object_id) WHERE source_object_id IS NOT NULL`);
1888
- db.exec(`
1889
- CREATE TABLE relations (
1890
- id INTEGER PRIMARY KEY AUTOINCREMENT,
1891
- tenant_id TEXT NOT NULL,
1892
- from_entity_id INTEGER NOT NULL,
1893
- to_entity_id INTEGER NOT NULL,
1894
- rel_type TEXT NOT NULL
1895
- CHECK (rel_type IN ('owns', 'supersedes', 'depends-on', 'blocked-by', 'references')),
1896
- memory_id TEXT,
1897
- source_kind TEXT NOT NULL CHECK (source_kind IN ('distilled', 'superseded')),
1898
- source_object_type TEXT
1899
- CHECK (source_object_type IS NULL OR source_object_type IN ('decision', 'policy', 'customer', 'project')),
1900
- source_object_id INTEGER,
1901
- created_at TEXT NOT NULL,
1902
- FOREIGN KEY (from_entity_id) REFERENCES entities(id) ON DELETE CASCADE,
1903
- FOREIGN KEY (to_entity_id) REFERENCES entities(id) ON DELETE CASCADE,
1904
- FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE SET NULL
1905
- )
1906
- `);
1907
- db.exec(`CREATE INDEX IF NOT EXISTS idx_relations_tenant ON relations(tenant_id)`);
1908
- db.exec(`CREATE INDEX IF NOT EXISTS idx_relations_from ON relations(from_entity_id)`);
1909
- db.exec(`CREATE INDEX IF NOT EXISTS idx_relations_to ON relations(to_entity_id)`);
1910
- db.exec(`CREATE INDEX IF NOT EXISTS idx_relations_memory ON relations(memory_id)`);
1911
- // Mirrors idx_entities_source_object: removeGraphEntitiesForObject (close-time cleanup)
1912
- // deletes relations by (source_object_type, source_object_id), so index that pair.
1913
- db.exec(`CREATE INDEX IF NOT EXISTS idx_relations_source_object ON relations(source_object_type, source_object_id) WHERE source_object_id IS NOT NULL`);
1914
- // entities guard (dual-provenance): at least one valid provenance, no raw.
1915
- // - all-null -> ABORT.
1916
- // - memory path (memory_id NOT NULL): source_kind == the FK'd memory's kind
1917
- // (raw / lying source_kind ABORT) AND tenant-match.
1918
- // - object path (memory_id NULL): the (type,id) points at an EXISTING same-tenant
1919
- // E2 row whose status is active|superseded (explicit 4-way CASE per table).
1920
- db.exec(`
1921
- CREATE TRIGGER IF NOT EXISTS trg_entities_consolidated_only_insert
1922
- BEFORE INSERT ON entities
1923
- BEGIN
1924
- SELECT CASE
1925
- WHEN (NEW.source_object_type IS NULL AND NEW.source_object_id IS NOT NULL)
1926
- OR (NEW.source_object_type IS NOT NULL AND NEW.source_object_id IS NULL)
1927
- THEN RAISE(ABORT, 'source_object_type and source_object_id must be set together (all-or-none)')
1928
- WHEN NEW.memory_id IS NULL AND NEW.source_object_id IS NULL
1929
- THEN RAISE(ABORT, 'graph row needs a memory or a source object')
1930
- WHEN NEW.memory_id IS NOT NULL AND NEW.source_kind != (SELECT kind FROM memories WHERE id = NEW.memory_id)
1931
- THEN RAISE(ABORT, 'entities.source_kind must equal the referenced memory kind; the graph indexes consolidated state only (no raw)')
1932
- WHEN NEW.memory_id IS NOT NULL AND NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1933
- THEN RAISE(ABORT, 'entities.tenant_id must match memories.tenant_id for the referenced memory')
1934
- WHEN NEW.source_object_type ='decision'
1935
- AND NOT EXISTS (SELECT 1 FROM decisions WHERE id = NEW.source_object_id AND tenant_id = NEW.tenant_id AND status IN ('active', 'superseded'))
1936
- THEN RAISE(ABORT, 'entities.source_object must reference an active/superseded decision in the same tenant')
1937
- WHEN NEW.source_object_type ='policy'
1938
- AND NOT EXISTS (SELECT 1 FROM policies WHERE id = NEW.source_object_id AND tenant_id = NEW.tenant_id AND status IN ('active', 'superseded'))
1939
- THEN RAISE(ABORT, 'entities.source_object must reference an active/superseded policy in the same tenant')
1940
- WHEN NEW.source_object_type ='customer'
1941
- AND NOT EXISTS (SELECT 1 FROM customer_notes WHERE id = NEW.source_object_id AND tenant_id = NEW.tenant_id AND status IN ('active', 'superseded'))
1942
- THEN RAISE(ABORT, 'entities.source_object must reference an active/superseded customer_note in the same tenant')
1943
- WHEN NEW.source_object_type ='project'
1944
- AND NOT EXISTS (SELECT 1 FROM project_briefs WHERE id = NEW.source_object_id AND tenant_id = NEW.tenant_id AND status IN ('active', 'superseded'))
1945
- THEN RAISE(ABORT, 'entities.source_object must reference an active/superseded project_brief in the same tenant')
1946
- END;
1947
- END
1948
- `);
1949
- db.exec(`
1950
- CREATE TRIGGER IF NOT EXISTS trg_entities_consolidated_only_update
1951
- BEFORE UPDATE ON entities
1952
- WHEN NEW.memory_id IS NOT OLD.memory_id
1953
- OR NEW.source_kind IS NOT OLD.source_kind
1954
- OR NEW.tenant_id IS NOT OLD.tenant_id
1955
- OR NEW.source_object_type IS NOT OLD.source_object_type
1956
- OR NEW.source_object_id IS NOT OLD.source_object_id
1957
- BEGIN
1958
- -- NO all-null ABORT on UPDATE: node:sqlite fires this BEFORE UPDATE trigger from
1959
- -- the FK ON DELETE SET NULL action even with recursive_triggers OFF (empirically
1960
- -- verified - the plan's "SET NULL does not fire the guard" premise is false for
1961
- -- this build). A mirror forget on a memory-only entity legitimately produces an
1962
- -- all-null row; aborting here would block the memory delete. The INSERT guard
1963
- -- still forbids CREATING a provenance-less row, and the next graph rebuild clears
1964
- -- any all-null orphan. A raw UPDATE to all-null is harmless (not recall-surfaced).
1965
- -- Object-path STATUS is validated only when the object columns THEMSELVES change
1966
- -- (an explicit re-point), NOT on the FK ON DELETE SET NULL transition (which changes
1967
- -- only memory_id). This threads the needle (codex P1 + P2 round 4): the SET NULL of a
1968
- -- since-closed object's mirror is NOT blocked (object cols unchanged -> object checks
1969
- -- skip), while an explicit raw UPDATE that re-points the row to a bad/closed/cross-
1970
- -- tenant object IS rejected. all-or-none + memory-path (raw/tenant) always apply.
1971
- SELECT CASE
1972
- WHEN (NEW.source_object_type IS NULL AND NEW.source_object_id IS NOT NULL)
1973
- OR (NEW.source_object_type IS NOT NULL AND NEW.source_object_id IS NULL)
1974
- THEN RAISE(ABORT, 'entities.source_object_type and source_object_id must be set together (all-or-none)')
1975
- WHEN NEW.memory_id IS NOT NULL AND NEW.source_kind != (SELECT kind FROM memories WHERE id = NEW.memory_id)
1976
- THEN RAISE(ABORT, 'entities.source_kind must equal the referenced memory kind; the graph indexes consolidated state only (no raw)')
1977
- WHEN NEW.memory_id IS NOT NULL AND NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1978
- THEN RAISE(ABORT, 'entities.tenant_id must match memories.tenant_id for the referenced memory')
1979
- WHEN (NEW.source_object_type IS NOT OLD.source_object_type OR NEW.source_object_id IS NOT OLD.source_object_id OR NEW.tenant_id IS NOT OLD.tenant_id)
1980
- AND NEW.source_object_type ='decision'
1981
- AND NOT EXISTS (SELECT 1 FROM decisions WHERE id = NEW.source_object_id AND tenant_id = NEW.tenant_id AND status IN ('active', 'superseded'))
1982
- THEN RAISE(ABORT, 'entities.source_object must reference an active/superseded decision in the same tenant')
1983
- WHEN (NEW.source_object_type IS NOT OLD.source_object_type OR NEW.source_object_id IS NOT OLD.source_object_id OR NEW.tenant_id IS NOT OLD.tenant_id)
1984
- AND NEW.source_object_type ='policy'
1985
- AND NOT EXISTS (SELECT 1 FROM policies WHERE id = NEW.source_object_id AND tenant_id = NEW.tenant_id AND status IN ('active', 'superseded'))
1986
- THEN RAISE(ABORT, 'entities.source_object must reference an active/superseded policy in the same tenant')
1987
- WHEN (NEW.source_object_type IS NOT OLD.source_object_type OR NEW.source_object_id IS NOT OLD.source_object_id OR NEW.tenant_id IS NOT OLD.tenant_id)
1988
- AND NEW.source_object_type ='customer'
1989
- AND NOT EXISTS (SELECT 1 FROM customer_notes WHERE id = NEW.source_object_id AND tenant_id = NEW.tenant_id AND status IN ('active', 'superseded'))
1990
- THEN RAISE(ABORT, 'entities.source_object must reference an active/superseded customer_note in the same tenant')
1991
- WHEN (NEW.source_object_type IS NOT OLD.source_object_type OR NEW.source_object_id IS NOT OLD.source_object_id OR NEW.tenant_id IS NOT OLD.tenant_id)
1992
- AND NEW.source_object_type ='project'
1993
- AND NOT EXISTS (SELECT 1 FROM project_briefs WHERE id = NEW.source_object_id AND tenant_id = NEW.tenant_id AND status IN ('active', 'superseded'))
1994
- THEN RAISE(ABORT, 'entities.source_object must reference an active/superseded project_brief in the same tenant')
1995
- END;
1996
- END
1997
- `);
1998
- // relations guard (dual-provenance) + the existing from/to endpoint same-tenant checks.
1999
- db.exec(`
2000
- CREATE TRIGGER IF NOT EXISTS trg_relations_consolidated_only_insert
2001
- BEFORE INSERT ON relations
2002
- BEGIN
2003
- SELECT CASE
2004
- WHEN (NEW.source_object_type IS NULL AND NEW.source_object_id IS NOT NULL)
2005
- OR (NEW.source_object_type IS NOT NULL AND NEW.source_object_id IS NULL)
2006
- THEN RAISE(ABORT, 'source_object_type and source_object_id must be set together (all-or-none)')
2007
- WHEN NEW.memory_id IS NULL AND NEW.source_object_id IS NULL
2008
- THEN RAISE(ABORT, 'graph row needs a memory or a source object')
2009
- WHEN NEW.memory_id IS NOT NULL AND NEW.source_kind != (SELECT kind FROM memories WHERE id = NEW.memory_id)
2010
- THEN RAISE(ABORT, 'relations.source_kind must equal the referenced memory kind; the graph indexes consolidated state only (no raw)')
2011
- WHEN NEW.memory_id IS NOT NULL AND NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
2012
- THEN RAISE(ABORT, 'relations.tenant_id must match memories.tenant_id for the referenced memory')
2013
- WHEN NEW.source_object_type ='decision'
2014
- AND NOT EXISTS (SELECT 1 FROM decisions WHERE id = NEW.source_object_id AND tenant_id = NEW.tenant_id AND status IN ('active', 'superseded'))
2015
- THEN RAISE(ABORT, 'relations.source_object must reference an active/superseded decision in the same tenant')
2016
- WHEN NEW.source_object_type ='policy'
2017
- AND NOT EXISTS (SELECT 1 FROM policies WHERE id = NEW.source_object_id AND tenant_id = NEW.tenant_id AND status IN ('active', 'superseded'))
2018
- THEN RAISE(ABORT, 'relations.source_object must reference an active/superseded policy in the same tenant')
2019
- WHEN NEW.source_object_type ='customer'
2020
- AND NOT EXISTS (SELECT 1 FROM customer_notes WHERE id = NEW.source_object_id AND tenant_id = NEW.tenant_id AND status IN ('active', 'superseded'))
2021
- THEN RAISE(ABORT, 'relations.source_object must reference an active/superseded customer_note in the same tenant')
2022
- WHEN NEW.source_object_type ='project'
2023
- AND NOT EXISTS (SELECT 1 FROM project_briefs WHERE id = NEW.source_object_id AND tenant_id = NEW.tenant_id AND status IN ('active', 'superseded'))
2024
- THEN RAISE(ABORT, 'relations.source_object must reference an active/superseded project_brief in the same tenant')
2025
- WHEN NEW.tenant_id != (SELECT tenant_id FROM entities WHERE id = NEW.from_entity_id)
2026
- THEN RAISE(ABORT, 'relations.tenant_id must match the from_entity tenant (no cross-tenant edges)')
2027
- WHEN NEW.tenant_id != (SELECT tenant_id FROM entities WHERE id = NEW.to_entity_id)
2028
- THEN RAISE(ABORT, 'relations.tenant_id must match the to_entity tenant (no cross-tenant edges)')
2029
- END;
2030
- END
2031
- `);
2032
- db.exec(`
2033
- CREATE TRIGGER IF NOT EXISTS trg_relations_consolidated_only_update
2034
- BEFORE UPDATE ON relations
2035
- WHEN NEW.memory_id IS NOT OLD.memory_id
2036
- OR NEW.source_kind IS NOT OLD.source_kind
2037
- OR NEW.tenant_id IS NOT OLD.tenant_id
2038
- OR NEW.source_object_type IS NOT OLD.source_object_type
2039
- OR NEW.source_object_id IS NOT OLD.source_object_id
2040
- OR NEW.from_entity_id IS NOT OLD.from_entity_id
2041
- OR NEW.to_entity_id IS NOT OLD.to_entity_id
2042
- BEGIN
2043
- -- NO all-null ABORT on UPDATE (same reason as the entities UPDATE trigger): the FK
2044
- -- ON DELETE SET NULL fires this trigger in node:sqlite, and a mirror forget on a
2045
- -- memory-only relation legitimately nulls memory_id. The INSERT guard still forbids
2046
- -- creating a provenance-less relation; the rebuild clears any orphan.
2047
- -- Object-path STATUS validated only on an explicit object-column change, NOT on the
2048
- -- FK SET NULL transition (see the entities UPDATE trigger): SET NULL of a since-closed
2049
- -- object's mirror is not blocked (object cols unchanged), while an explicit re-point to
2050
- -- a bad object is rejected. all-or-none + memory-path + endpoint checks always apply.
2051
- SELECT CASE
2052
- WHEN (NEW.source_object_type IS NULL AND NEW.source_object_id IS NOT NULL)
2053
- OR (NEW.source_object_type IS NOT NULL AND NEW.source_object_id IS NULL)
2054
- THEN RAISE(ABORT, 'relations.source_object_type and source_object_id must be set together (all-or-none)')
2055
- WHEN NEW.memory_id IS NOT NULL AND NEW.source_kind != (SELECT kind FROM memories WHERE id = NEW.memory_id)
2056
- THEN RAISE(ABORT, 'relations.source_kind must equal the referenced memory kind; the graph indexes consolidated state only (no raw)')
2057
- WHEN NEW.memory_id IS NOT NULL AND NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
2058
- THEN RAISE(ABORT, 'relations.tenant_id must match memories.tenant_id for the referenced memory')
2059
- WHEN (NEW.source_object_type IS NOT OLD.source_object_type OR NEW.source_object_id IS NOT OLD.source_object_id OR NEW.tenant_id IS NOT OLD.tenant_id)
2060
- AND NEW.source_object_type ='decision'
2061
- AND NOT EXISTS (SELECT 1 FROM decisions WHERE id = NEW.source_object_id AND tenant_id = NEW.tenant_id AND status IN ('active', 'superseded'))
2062
- THEN RAISE(ABORT, 'relations.source_object must reference an active/superseded decision in the same tenant')
2063
- WHEN (NEW.source_object_type IS NOT OLD.source_object_type OR NEW.source_object_id IS NOT OLD.source_object_id OR NEW.tenant_id IS NOT OLD.tenant_id)
2064
- AND NEW.source_object_type ='policy'
2065
- AND NOT EXISTS (SELECT 1 FROM policies WHERE id = NEW.source_object_id AND tenant_id = NEW.tenant_id AND status IN ('active', 'superseded'))
2066
- THEN RAISE(ABORT, 'relations.source_object must reference an active/superseded policy in the same tenant')
2067
- WHEN (NEW.source_object_type IS NOT OLD.source_object_type OR NEW.source_object_id IS NOT OLD.source_object_id OR NEW.tenant_id IS NOT OLD.tenant_id)
2068
- AND NEW.source_object_type ='customer'
2069
- AND NOT EXISTS (SELECT 1 FROM customer_notes WHERE id = NEW.source_object_id AND tenant_id = NEW.tenant_id AND status IN ('active', 'superseded'))
2070
- THEN RAISE(ABORT, 'relations.source_object must reference an active/superseded customer_note in the same tenant')
2071
- WHEN (NEW.source_object_type IS NOT OLD.source_object_type OR NEW.source_object_id IS NOT OLD.source_object_id OR NEW.tenant_id IS NOT OLD.tenant_id)
2072
- AND NEW.source_object_type ='project'
2073
- AND NOT EXISTS (SELECT 1 FROM project_briefs WHERE id = NEW.source_object_id AND tenant_id = NEW.tenant_id AND status IN ('active', 'superseded'))
2074
- THEN RAISE(ABORT, 'relations.source_object must reference an active/superseded project_brief in the same tenant')
2075
- WHEN NEW.tenant_id != (SELECT tenant_id FROM entities WHERE id = NEW.from_entity_id)
2076
- THEN RAISE(ABORT, 'relations.tenant_id must match the from_entity tenant (no cross-tenant edges)')
2077
- WHEN NEW.tenant_id != (SELECT tenant_id FROM entities WHERE id = NEW.to_entity_id)
2078
- THEN RAISE(ABORT, 'relations.tenant_id must match the to_entity tenant (no cross-tenant edges)')
2079
- END;
2080
- END
2081
- `);
2082
- // Recreated VERBATIM from v37 (logic unchanged): an entity that is a relation
2083
- // endpoint cannot be moved cross-tenant while referenced. trg_memories_graph_referenced_guard
2084
- // (on memories) and trg_graph_queue_* (on graph_extraction_queue) are NOT recreated
2085
- // here: those tables are not dropped by v38, so the triggers survive.
2086
- db.exec(`
2087
- CREATE TRIGGER IF NOT EXISTS trg_entities_no_tenant_move_when_referenced
2088
- BEFORE UPDATE ON entities
2089
- WHEN NEW.tenant_id IS NOT OLD.tenant_id
2090
- AND EXISTS (SELECT 1 FROM relations WHERE from_entity_id = OLD.id OR to_entity_id = OLD.id)
2091
- BEGIN
2092
- SELECT RAISE(ABORT, 'cannot move an entity cross-tenant while a relation references it as an endpoint (E3.3 graph-on-consolidated guard); rebuild/remove the relations first');
2093
- END
2094
- `);
2095
- },
2096
- },
2097
- {
2098
- version: 39,
2099
- up: (db, ctx) => {
2100
- // Memory scope isolation (docs/plans/2026-07-01-memory-scope-isolation.md).
2101
- // origin_project: '<name>' = owned by that project, '' = user-global,
2102
- // NULL = legacy/unknown (ambient context treats NULL as deny).
2103
- if (!tableHasColumn(db, 'memories', 'origin_project')) {
2104
- db.exec(`ALTER TABLE memories ADD COLUMN origin_project TEXT`);
2105
- }
2106
- // Backfill on store-location evidence only (no path-tag guessing):
2107
- // 1. Rows shared from a project carry source 'shared:<project>:<ts>' -
2108
- // take <project>; a share whose <project> is the home dir basename
2109
- // maps to '' (user-global).
2110
- // 2. Every other row was written into THIS store, so it takes the
2111
- // store's own origin: `<project>/.hippo` -> '<project>', the
2112
- // home/global store -> '' (user-global).
2113
- // Rows stay NULL only when no hippoRoot was provided.
2114
- const hippoRoot = ctx?.hippoRoot;
2115
- if (hippoRoot) {
2116
- // Provenance-source evidence first (shared:<project>: / promoted:<localRoot>,
2117
- // parsed by the same helper the markdown-import stamp uses), then the
2118
- // store's own location for everything else.
2119
- const homeName = path.basename(os.homedir()).toLowerCase();
2120
- // SAFETY: sourcedRows' shape matches the two columns (id, source)
2121
- // named in the SELECT above.
2122
- const sourcedRows = db.prepare(`SELECT id, source FROM memories WHERE origin_project IS NULL AND (source LIKE 'shared:%' OR source LIKE 'promoted:%')`).all();
2123
- const setOrigin = db.prepare(`UPDATE memories SET origin_project = ? WHERE id = ?`);
2124
- for (const row of sourcedRows) {
2125
- const origin = originFromSource(row.source, homeName);
2126
- if (origin === null)
2127
- continue;
2128
- setOrigin.run(origin, row.id);
2129
- }
2130
- // The global root itself is ALWAYS user-global (''), regardless of
2131
- // what surrounds it on disk - a HIPPO_HOME inside a dotfiles git
2132
- // repo must not stamp the whole corpus with that repo's name.
2133
- const storeOrigin = isGlobalStoreRoot(hippoRoot)
2134
- ? ''
2135
- : deriveOriginProject(path.dirname(hippoRoot));
2136
- db.prepare(`UPDATE memories SET origin_project = ? WHERE origin_project IS NULL`).run(storeOrigin);
2137
- }
2138
- // Rollback-safety guard (v24 precedent): a pre-isolation binary opening
2139
- // this DB would ignore origin_project and the secret veto and resume
2140
- // injecting cross-project rows. 1.24.0 is the first version with the
2141
- // isolation behavior. Forward-only - never lower an existing minimum.
2142
- // SAFETY: this get() result's shape matches the single `value` column
2143
- // named in the SELECT above.
2144
- const existingMin = db.prepare(`SELECT value FROM meta WHERE key = 'min_compatible_binary'`).get()?.value;
2145
- if (!existingMin || compareSemver('1.24.0', existingMin) > 0) {
2146
- db.prepare(`INSERT INTO meta(key, value) VALUES('min_compatible_binary', ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run('1.24.0');
2147
- }
2148
- },
2149
- },
2150
- {
2151
- version: 40,
2152
- up: (db) => {
2153
- // LC1 retrieval-trace persistence (docs/plans/2026-08-02-lc1-recall-trace-persistence.md).
2154
- // Follows the v18 goal_recall_log style: one parent trace row per
2155
- // recall, a WITHOUT ROWID child table for the ranked results, and a
2156
- // separate append-only outcomes table so audit_log pruning can never
2157
- // erase training data.
2158
- //
2159
- // F6 (deliberate, not an oversight): unlike v39, this migration does
2160
- // NOT bump `min_compatible_binary`. A pre-v40 binary opening this DB
2161
- // ignores the three new tables and keeps writing `last_retrieval_ids`
2162
- // exactly as before — it never touches `last_trace_id` (that key
2163
- // simply stays whatever it was). recordTraceOutcome's F4 consumer-
2164
- // side validation (recall-trace.ts) makes any resulting staleness
2165
- // harmless to linkage: it re-validates the named trace's tenant AND
2166
- // intersects credited ids against the trace's OWN result set before
2167
- // inserting, so a stale/mismatched trace id from an old-binary write
2168
- // gets silently skipped rather than mislinked. That preserves the
2169
- // plan's "drop the three tables + last_trace_id restores v39 behavior
2170
- // exactly" rollback promise — a min_compatible_binary bump would
2171
- // additionally lock old binaries out of the WHOLE store for what is
2172
- // purely an observability/training feature, which the rollback
2173
- // promise does not require.
2174
- db.exec(`
2175
- CREATE TABLE IF NOT EXISTS recall_traces (
2176
- id INTEGER PRIMARY KEY AUTOINCREMENT,
2177
- ts TEXT NOT NULL,
2178
- tenant_id TEXT NOT NULL DEFAULT 'default',
2179
- session_id TEXT,
2180
- pipeline TEXT NOT NULL CHECK (pipeline IN ('api','cli','context','mcp')),
2181
- -- 'mcp' reserved for the deferred MCP wire-up. Deliberate: SQLite
2182
- -- cannot ALTER a CHECK constraint, so extending it later means a
2183
- -- full table-rebuild migration. One unused enum value now is
2184
- -- cheaper than that rebuild.
2185
- query_hash TEXT NOT NULL, -- sha256/16, NEVER raw query (audit convention, cli.ts:1532)
2186
- query_length INTEGER NOT NULL,
2187
- result_count INTEGER NOT NULL,
2188
- explain_mode INTEGER NOT NULL DEFAULT 0
2189
- );
2190
- CREATE INDEX IF NOT EXISTS idx_recall_traces_tenant_ts ON recall_traces(tenant_id, ts DESC);
2191
-
2192
- CREATE TABLE IF NOT EXISTS recall_trace_results (
2193
- trace_id INTEGER NOT NULL REFERENCES recall_traces(id) ON DELETE CASCADE,
2194
- tenant_id TEXT NOT NULL DEFAULT 'default', -- denormalized like goal_recall_log (v18
2195
- -- precedent): tenant-scoped training queries
2196
- -- over the memory index must not need a join
2197
- -- back through recall_traces
2198
- memory_id TEXT NOT NULL, -- NO FK to memories: traces must OUTLIVE forgotten
2199
- -- memories (they are LC2's negative class). PRAGMA
2200
- -- foreign_keys=ON is real, so an FK would either
2201
- -- block inserts or cascade-delete exactly the rows
2202
- -- training needs. Deliberate.
2203
- result_rank INTEGER NOT NULL, -- NOT "rank" (SQL keyword; audit rule 10)
2204
- score REAL NOT NULL,
2205
- rerank_json TEXT, -- compact RerankStep[] when explain/trace present; else NULL
2206
- PRIMARY KEY (trace_id, result_rank)
2207
- ) WITHOUT ROWID;
2208
- CREATE INDEX IF NOT EXISTS idx_recall_trace_results_tenant_memory
2209
- ON recall_trace_results(tenant_id, memory_id);
2210
-
2211
- CREATE TABLE IF NOT EXISTS recall_trace_outcomes (
2212
- id INTEGER PRIMARY KEY AUTOINCREMENT,
2213
- trace_id INTEGER NOT NULL REFERENCES recall_traces(id) ON DELETE CASCADE,
2214
- ts TEXT NOT NULL,
2215
- tenant_id TEXT NOT NULL DEFAULT 'default',
2216
- outcome TEXT NOT NULL CHECK (outcome IN ('positive','negative')),
2217
- memory_ids_json TEXT NOT NULL -- ids actually credited by this outcome event
2218
- );
2219
- CREATE INDEX IF NOT EXISTS idx_recall_trace_outcomes_trace ON recall_trace_outcomes(trace_id);
2220
- `);
2221
- },
2222
- },
2223
- {
2224
- version: 41,
2225
- up: (db) => {
2226
- // AT1 rejected-value tombstone (docs/plans/2026-08-15-at1-rejected-value-tombstone.md).
2227
- // Additive table, template = v40 above. A human who rejects a fact gets
2228
- // a durable say: the write-path guard in upsertEntryRow refuses any
2229
- // write that would re-introduce a value whose normalized digest
2230
- // matches a row here.
2231
- //
2232
- // No raw content and no preview stored. Follows the raw_archive
2233
- // redaction precedent (raw-archive.ts payload is {redacted:true, ...}):
2234
- // a rejected value may itself be a secret or PII ("never store my key
2235
- // again") — persisting it in the tombstone would defeat the point. The
2236
- // human sees the content at reject time (CLI echoes it); afterwards
2237
- // `reason` is the human-readable identity.
2238
- //
2239
- // No FK on source_memory_id (v40 precedent above: tombstone outlives
2240
- // the row it was sourced from) — provenance only.
2241
- //
2242
- // Reserved-word check on column names (skill-episode lesson, rule 10):
2243
- // tenant/digest/reason/rejected/source/normalized/chars are non-reserved.
2244
- //
2245
- // No min_compatible_binary bump — a deliberate tradeoff (plan §2,
2246
- // flagged to the ship gate). An old binary sharing a synced store
2247
- // writes WITHOUT the guard until upgraded; documented in
2248
- // MEMORY_ENVELOPE.md rather than hard-locking every old binary out of
2249
- // the store, which is disproportionate for the dominant single-user
2250
- // single-binary deployment.
2251
- db.exec(`
2252
- CREATE TABLE IF NOT EXISTS rejected_values (
2253
- tenant_id TEXT NOT NULL,
2254
- digest TEXT NOT NULL, -- sha256/64 of normalized content
2255
- reason TEXT, -- human-supplied; the only description stored
2256
- rejected_by TEXT, -- actor ('user:<id>' / 'cli' / ...)
2257
- rejected_at TEXT NOT NULL,
2258
- source_memory_id TEXT, -- provenance only; NO FK (tombstone outlives the row)
2259
- normalized_chars INTEGER, -- weak sanity aid for humans listing tombstones
2260
- PRIMARY KEY (tenant_id, digest)
2261
- ) WITHOUT ROWID;
2262
- `);
2263
- },
2264
- },
2265
- {
2266
- version: 42,
2267
- up: (db) => {
2268
- // W1 handoff envelope (trajectories/01M2BQTM4AGFVMYY7G2XV5G7WY/plan.md).
2269
- // Five nullable columns so the envelope carries evidence and outcome
2270
- // and W2/W5 can filter on them without parsing JSON.
2271
- if (!tableHasColumn(db, 'session_handoffs', 'constraints_json')) {
2272
- db.exec(`ALTER TABLE session_handoffs ADD COLUMN constraints_json TEXT`);
2273
- }
2274
- if (!tableHasColumn(db, 'session_handoffs', 'evidence_json')) {
2275
- db.exec(`ALTER TABLE session_handoffs ADD COLUMN evidence_json TEXT`);
2276
- }
2277
- if (!tableHasColumn(db, 'session_handoffs', 'outcome')) {
2278
- db.exec(`ALTER TABLE session_handoffs ADD COLUMN outcome TEXT`);
2279
- // codex P2: backfill from session_complete so pre-existing handoffs don't
2280
- // all read as unfinished and get injected by the new 72h ambient fallback.
2281
- db.exec(`
2282
- UPDATE session_handoffs SET outcome = (
2283
- SELECT e.content FROM session_events e
2284
- WHERE e.tenant_id = session_handoffs.tenant_id AND e.session_id = session_handoffs.session_id
2285
- AND e.event_type = 'session_complete' AND e.content IN ('success','partial','failure')
2286
- ORDER BY e.created_at DESC, e.id DESC LIMIT 1
2287
- )
2288
- WHERE outcome IS NULL AND EXISTS (
2289
- SELECT 1 FROM session_events e
2290
- WHERE e.tenant_id = session_handoffs.tenant_id AND e.session_id = session_handoffs.session_id
2291
- AND e.event_type = 'session_complete' AND e.content IN ('success','partial','failure')
2292
- )
2293
- `);
2294
- }
2295
- if (!tableHasColumn(db, 'session_handoffs', 'target_runtime')) {
2296
- db.exec(`ALTER TABLE session_handoffs ADD COLUMN target_runtime TEXT`);
2297
- }
2298
- if (!tableHasColumn(db, 'session_handoffs', 'card_id')) {
2299
- db.exec(`ALTER TABLE session_handoffs ADD COLUMN card_id TEXT`);
2300
- }
2301
- db.exec(`CREATE INDEX IF NOT EXISTS idx_session_handoffs_tenant_outcome ON session_handoffs(tenant_id, outcome, created_at DESC)`);
2302
- },
2303
- },
2304
- {
2305
- version: 43,
2306
- up: (db) => {
2307
- // W2a work-queue cards (trajectories/01M2D5VSYJFK4YXQ0RG2NGCPYJ/plan.md). Additive only, v41 precedent.
2308
- db.exec(`
2309
- CREATE TABLE IF NOT EXISTS cards (
2310
- id TEXT PRIMARY KEY,
2311
- title TEXT NOT NULL,
2312
- status TEXT NOT NULL DEFAULT 'backlog',
2313
- assignee_runtime TEXT,
2314
- repo TEXT,
2315
- contract TEXT,
2316
- budget INTEGER,
2317
- lease_until TEXT,
2318
- heartbeat_at TEXT,
2319
- created_at TEXT NOT NULL,
2320
- updated_at TEXT NOT NULL,
2321
- tenant_id TEXT NOT NULL DEFAULT 'default',
2322
- scope TEXT
2323
- ) WITHOUT ROWID;
2324
- CREATE INDEX IF NOT EXISTS idx_cards_tenant_status
2325
- ON cards(tenant_id, status, updated_at DESC);
2326
-
2327
- CREATE TABLE IF NOT EXISTS card_deps (
2328
- parent TEXT NOT NULL REFERENCES cards(id),
2329
- child TEXT NOT NULL REFERENCES cards(id),
2330
- tenant_id TEXT NOT NULL DEFAULT 'default',
2331
- created_at TEXT NOT NULL,
2332
- PRIMARY KEY (parent, child)
2333
- ) WITHOUT ROWID;
2334
- CREATE INDEX IF NOT EXISTS idx_card_deps_tenant_child
2335
- ON card_deps(tenant_id, child);
2336
- CREATE INDEX IF NOT EXISTS idx_card_deps_tenant_parent
2337
- ON card_deps(tenant_id, parent);
2338
-
2339
- CREATE TABLE IF NOT EXISTS card_runs (
2340
- id INTEGER PRIMARY KEY AUTOINCREMENT,
2341
- card TEXT NOT NULL REFERENCES cards(id),
2342
- runtime TEXT NOT NULL,
2343
- session_id TEXT,
2344
- started TEXT NOT NULL,
2345
- ended TEXT,
2346
- outcome TEXT,
2347
- created_at TEXT NOT NULL,
2348
- updated_at TEXT NOT NULL,
2349
- tenant_id TEXT NOT NULL DEFAULT 'default'
2350
- );
2351
- CREATE INDEX IF NOT EXISTS idx_card_runs_tenant_card
2352
- ON card_runs(tenant_id, card, started DESC);
2353
-
2354
- CREATE TABLE IF NOT EXISTS card_comments (
2355
- id INTEGER PRIMARY KEY AUTOINCREMENT,
2356
- card_id TEXT NOT NULL REFERENCES cards(id),
2357
- author TEXT NOT NULL,
2358
- body TEXT NOT NULL,
2359
- created_at TEXT NOT NULL,
2360
- tenant_id TEXT NOT NULL DEFAULT 'default'
2361
- );
2362
- CREATE INDEX IF NOT EXISTS idx_card_comments_tenant_card
2363
- ON card_comments(tenant_id, card_id, created_at DESC);
2364
-
2365
- CREATE INDEX IF NOT EXISTS idx_session_handoffs_tenant_card
2366
- ON session_handoffs(tenant_id, card_id, created_at DESC);
2367
- `);
2368
- },
2369
- },
2370
- ];
2371
- function tableHasColumn(db, tableName, columnName) {
2372
- if (!/^[a-z_]+$/i.test(tableName))
2373
- throw new Error(`Invalid table name: ${tableName}`);
2374
- // SAFETY: rows' shape matches PRAGMA table_info's documented `name` column.
2375
- const rows = db.prepare(`PRAGMA table_info(${tableName})`).all();
2376
- return rows.some((row) => row.name === columnName);
2377
- }
2378
- function tableExists(db, tableName) {
2379
- if (!/^[a-z_]+$/i.test(tableName))
2380
- throw new Error(`Invalid table name: ${tableName}`);
2381
- // SAFETY: row's shape matches the single `name` column named in the
2382
- // SELECT above.
2383
- const row = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name = ?`).get(tableName);
2384
- return !!row?.name;
2385
- }
2386
- export function getHippoDbPath(hippoRoot) {
2387
- return path.join(hippoRoot, 'hippo.db');
2388
- }
2389
- export function getCurrentSchemaVersion() {
2390
- return CURRENT_SCHEMA_VERSION;
2391
- }
2392
- function readMinCompatibleBinary(db) {
2393
- // Tolerate the meta table not yet existing on a fresh DB.
2394
- try {
2395
- // SAFETY: row's shape matches the single `value` column named in the
2396
- // SELECT above.
2397
- const row = db.prepare(`SELECT value FROM meta WHERE key = 'min_compatible_binary'`).get();
2398
- return row?.value ?? null;
2399
- }
2400
- catch {
2401
- return null;
2402
- }
2403
- }
2404
- function isSqliteBusy(error) {
2405
- const code = error?.errcode;
2406
- return code === 5 || code === 6 || code === 517;
2407
- }
2408
- // busy_timeout covers neither of this file's two contended statements: SQLite
2409
- // skips the busy handler for `PRAGMA journal_mode` and for a write that upgrades
2410
- // a deferred read snapshot. Both need an explicit wait instead.
2411
- function execWithBusyRetry(db, sql, timeoutMs = 30000) {
2412
- const deadline = Date.now() + timeoutMs;
2413
- const idle = new Int32Array(new SharedArrayBuffer(4));
2414
- for (;;) {
2415
- try {
2416
- db.exec(sql);
2417
- return;
2418
- }
2419
- catch (error) {
2420
- if (!isSqliteBusy(error) || Date.now() >= deadline)
2421
- throw error;
2422
- Atomics.wait(idle, 0, 0, 10 + Math.floor(Math.random() * 20));
2423
- }
2424
- }
2425
- }
2426
- export function openHippoDb(hippoRoot) {
2427
- fs.mkdirSync(hippoRoot, { recursive: true });
2428
- const db = new DatabaseSync(getHippoDbPath(hippoRoot));
2429
- try {
2430
- db.exec('PRAGMA busy_timeout = 5000');
2431
- execWithBusyRetry(db, 'PRAGMA journal_mode = WAL');
2432
- db.exec('PRAGMA synchronous = NORMAL');
2433
- db.exec('PRAGMA wal_autocheckpoint = 100');
2434
- db.exec('PRAGMA foreign_keys = ON');
2435
- runMigrations(db, hippoRoot);
2436
- // Path A backfill: delete any orphan markdown mirrors for already-archived
2437
- // raw_archive rows. Idempotent via per-row raw_archive.mirror_cleaned_at
2438
- // (v21). Wrapped in try/catch — a filesystem failure must not prevent DB open.
2439
- try {
2440
- cleanupArchivedMirrors(hippoRoot, db);
2441
- }
2442
- catch (cleanupErr) {
2443
- console.error('openHippoDb: cleanupArchivedMirrors failed (non-fatal):', cleanupErr);
2444
- }
2445
- return db;
2446
- }
2447
- catch (error) {
2448
- try {
2449
- db.close();
2450
- }
2451
- catch {
2452
- // Best effort only.
2453
- }
2454
- throw error;
2455
- }
2456
- }
2457
- function runMigrations(db, hippoRoot) {
2458
- ensureMetaTable(db);
2459
- // v1.3.1 rollback-safety guard. Schema v24 stamped meta.min_compatible_binary;
2460
- // older binaries that lack the generic *:private:* default-deny filter would
2461
- // leak github:private:* rows on no-scope recall. Refuse to open a DB stamped
2462
- // with a min newer than this binary's version. Read BEFORE migrations so a
2463
- // stale v1.2.0 binary cannot apply unknown future migrations either.
2464
- const minRequired = readMinCompatibleBinary(db);
2465
- if (minRequired && compareSemver(minRequired, PACKAGE_VERSION) > 0) {
2466
- throw new Error(`hippo-memory: this database requires hippo-memory >= ${minRequired}, but the running binary is ${PACKAGE_VERSION}. ` +
2467
- `Upgrade hippo-memory to open this database. Running an older binary against this DB would leak private rows that the ` +
2468
- `older filter does not recognize.`);
2469
- }
2470
- let currentVersion = getSchemaVersion(db);
2471
- if (currentVersion > 0)
2472
- ensureContinuityTables(db);
2473
- for (const migration of MIGRATIONS) {
2474
- if (migration.version <= currentVersion)
2475
- continue;
2476
- execWithBusyRetry(db, 'BEGIN IMMEDIATE');
2477
- try {
2478
- // Re-read under the write lock: another process may have applied this
2479
- // migration while we waited, and re-running one is not idempotent.
2480
- const applied = getSchemaVersion(db);
2481
- if (applied >= migration.version) {
2482
- db.exec('COMMIT');
2483
- currentVersion = applied;
2484
- continue;
2485
- }
2486
- migration.up(db, { hippoRoot });
2487
- setSchemaVersion(db, migration.version);
2488
- db.exec('COMMIT');
2489
- currentVersion = migration.version;
2490
- }
2491
- catch (error) {
2492
- try {
2493
- db.exec('ROLLBACK');
2494
- }
2495
- catch { /* already rolled back; keep the original error */ }
2496
- throw error;
2497
- }
2498
- }
2499
- ensureContinuityIndexes(db);
2500
- ensureMetaDefaults(db);
2501
- ensureOptionalFts(db);
2502
- }
2503
- function ensureMetaTable(db) {
2504
- db.exec(`
2505
- CREATE TABLE IF NOT EXISTS meta (
2506
- key TEXT PRIMARY KEY,
2507
- value TEXT NOT NULL
2508
- );
2509
- `);
2510
- }
2511
- export function getSchemaVersion(db) {
2512
- // SAFETY: row's shape matches the single `value` column named in the
2513
- // SELECT above.
2514
- const row = db.prepare(`SELECT value FROM meta WHERE key = 'schema_version'`).get();
2515
- const version = Number(row?.value ?? 0);
2516
- return Number.isFinite(version) ? version : 0;
2517
- }
2518
- function setSchemaVersion(db, version) {
2519
- db.prepare(`INSERT INTO meta(key, value) VALUES('schema_version', ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run(String(version));
2520
- db.exec(`PRAGMA user_version = ${Math.max(0, Math.trunc(version))}`);
2521
- }
2522
- // Before the loop on stamped stores: a table lost after its migration stamped (2026-08-15
2523
- // incident) is never re-migrated, and v4/v16/v22 ALTER or read it. Fresh stores use the chain.
2524
- function ensureContinuityTables(db) {
2525
- db.exec(`
2526
- CREATE TABLE IF NOT EXISTS task_snapshots (
2527
- id INTEGER PRIMARY KEY AUTOINCREMENT,
2528
- task TEXT NOT NULL,
2529
- summary TEXT NOT NULL,
2530
- next_step TEXT NOT NULL,
2531
- status TEXT NOT NULL,
2532
- source TEXT NOT NULL,
2533
- created_at TEXT NOT NULL,
2534
- updated_at TEXT NOT NULL,
2535
- session_id TEXT,
2536
- tenant_id TEXT NOT NULL DEFAULT 'default',
2537
- scope TEXT
2538
- )
2539
- `);
2540
- db.exec(`
2541
- CREATE TABLE IF NOT EXISTS session_events (
2542
- id INTEGER PRIMARY KEY AUTOINCREMENT,
2543
- session_id TEXT NOT NULL,
2544
- task TEXT,
2545
- event_type TEXT NOT NULL,
2546
- content TEXT NOT NULL,
2547
- source TEXT NOT NULL,
2548
- metadata_json TEXT NOT NULL,
2549
- created_at TEXT NOT NULL,
2550
- tenant_id TEXT NOT NULL DEFAULT 'default',
2551
- scope TEXT
2552
- )
2553
- `);
2554
- db.exec(`
2555
- CREATE TABLE IF NOT EXISTS session_handoffs (
2556
- id INTEGER PRIMARY KEY AUTOINCREMENT,
2557
- session_id TEXT NOT NULL,
2558
- repo_root TEXT,
2559
- task_id TEXT,
2560
- summary TEXT NOT NULL,
2561
- next_action TEXT,
2562
- artifacts_json TEXT NOT NULL DEFAULT '[]',
2563
- created_at TEXT NOT NULL,
2564
- tenant_id TEXT NOT NULL DEFAULT 'default',
2565
- scope TEXT,
2566
- constraints_json TEXT,
2567
- evidence_json TEXT,
2568
- outcome TEXT,
2569
- target_runtime TEXT,
2570
- card_id TEXT
2571
- )
2572
- `);
2573
- db.exec(`
2574
- CREATE TABLE IF NOT EXISTS cards (
2575
- id TEXT PRIMARY KEY,
2576
- title TEXT NOT NULL,
2577
- status TEXT NOT NULL DEFAULT 'backlog',
2578
- assignee_runtime TEXT,
2579
- repo TEXT,
2580
- contract TEXT,
2581
- budget INTEGER,
2582
- lease_until TEXT,
2583
- heartbeat_at TEXT,
2584
- created_at TEXT NOT NULL,
2585
- updated_at TEXT NOT NULL,
2586
- tenant_id TEXT NOT NULL DEFAULT 'default',
2587
- scope TEXT
2588
- ) WITHOUT ROWID
2589
- `);
2590
- db.exec(`
2591
- CREATE TABLE IF NOT EXISTS card_deps (
2592
- parent TEXT NOT NULL REFERENCES cards(id),
2593
- child TEXT NOT NULL REFERENCES cards(id),
2594
- tenant_id TEXT NOT NULL DEFAULT 'default',
2595
- created_at TEXT NOT NULL,
2596
- PRIMARY KEY (parent, child)
2597
- ) WITHOUT ROWID
2598
- `);
2599
- db.exec(`
2600
- CREATE TABLE IF NOT EXISTS card_runs (
2601
- id INTEGER PRIMARY KEY AUTOINCREMENT,
2602
- card TEXT NOT NULL REFERENCES cards(id),
2603
- runtime TEXT NOT NULL,
2604
- session_id TEXT,
2605
- started TEXT NOT NULL,
2606
- ended TEXT,
2607
- outcome TEXT,
2608
- created_at TEXT NOT NULL,
2609
- updated_at TEXT NOT NULL,
2610
- tenant_id TEXT NOT NULL DEFAULT 'default'
2611
- )
2612
- `);
2613
- db.exec(`
2614
- CREATE TABLE IF NOT EXISTS card_comments (
2615
- id INTEGER PRIMARY KEY AUTOINCREMENT,
2616
- card_id TEXT NOT NULL REFERENCES cards(id),
2617
- author TEXT NOT NULL,
2618
- body TEXT NOT NULL,
2619
- created_at TEXT NOT NULL,
2620
- tenant_id TEXT NOT NULL DEFAULT 'default'
2621
- )
2622
- `);
2623
- }
2624
- // After the loop: tenant_id (v16) and scope (v23) do not exist yet on a genuine old store.
2625
- function ensureContinuityIndexes(db) {
2626
- db.exec(`CREATE INDEX IF NOT EXISTS idx_task_snapshots_status_updated ON task_snapshots(status, updated_at DESC, id DESC)`);
2627
- db.exec(`CREATE INDEX IF NOT EXISTS idx_task_snapshots_tenant_status ON task_snapshots(tenant_id, status, updated_at DESC)`);
2628
- db.exec(`CREATE INDEX IF NOT EXISTS idx_task_snapshots_tenant_scope ON task_snapshots(tenant_id, scope, status)`);
2629
- db.exec(`CREATE INDEX IF NOT EXISTS idx_session_events_session_created ON session_events(session_id, created_at DESC, id DESC)`);
2630
- db.exec(`CREATE INDEX IF NOT EXISTS idx_session_events_task_created ON session_events(task, created_at DESC, id DESC)`);
2631
- db.exec(`CREATE INDEX IF NOT EXISTS idx_session_events_tenant_session ON session_events(tenant_id, session_id, created_at DESC, id DESC)`);
2632
- db.exec(`CREATE INDEX IF NOT EXISTS idx_session_handoffs_session ON session_handoffs(session_id, created_at DESC)`);
2633
- db.exec(`CREATE INDEX IF NOT EXISTS idx_session_handoffs_tenant_session ON session_handoffs(tenant_id, session_id, created_at DESC)`);
2634
- db.exec(`CREATE INDEX IF NOT EXISTS idx_session_handoffs_tenant_outcome ON session_handoffs(tenant_id, outcome, created_at DESC)`);
2635
- db.exec(`CREATE INDEX IF NOT EXISTS idx_cards_tenant_status ON cards(tenant_id, status, updated_at DESC)`);
2636
- db.exec(`CREATE INDEX IF NOT EXISTS idx_card_deps_tenant_child ON card_deps(tenant_id, child)`);
2637
- db.exec(`CREATE INDEX IF NOT EXISTS idx_card_deps_tenant_parent ON card_deps(tenant_id, parent)`);
2638
- db.exec(`CREATE INDEX IF NOT EXISTS idx_card_runs_tenant_card ON card_runs(tenant_id, card, started DESC)`);
2639
- db.exec(`CREATE INDEX IF NOT EXISTS idx_card_comments_tenant_card ON card_comments(tenant_id, card_id, created_at DESC)`);
2640
- db.exec(`CREATE INDEX IF NOT EXISTS idx_session_handoffs_tenant_card ON session_handoffs(tenant_id, card_id, created_at DESC)`);
2641
- }
2642
- function ensureMetaDefaults(db) {
2643
- const defaults = [
2644
- ['schema_version', String(CURRENT_SCHEMA_VERSION)],
2645
- ['last_retrieval_ids', '[]'],
2646
- ['last_trace_id', ''],
2647
- ['total_remembered', '0'],
2648
- ['total_recalled', '0'],
2649
- ['total_forgotten', '0'],
2650
- ['fts5_available', '0'],
2651
- ];
2652
- // Read-first: an already-current store has all 7 keys, so this is one
2653
- // SELECT and zero writes instead of 7 no-op INSERT OR IGNOREs, each of
2654
- // which takes a RESERVED lock even when nothing changes.
2655
- const keys = defaults.map(([key]) => key);
2656
- const present = new Set(db
2657
- .prepare(`SELECT key FROM meta WHERE key IN (${keys.map(() => '?').join(',')})`)
2658
- .all(...keys)
2659
- .map((r) => r.key));
2660
- if (present.size === defaults.length)
2661
- return;
2662
- const stmt = db.prepare(`INSERT OR IGNORE INTO meta(key, value) VALUES (?, ?)`);
2663
- for (const [key, value] of defaults) {
2664
- if (!present.has(key))
2665
- stmt.run(key, value);
2666
- }
2667
- }
2668
- function ensureOptionalFts(db) {
2669
- let available = false;
2670
- try {
2671
- db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(id UNINDEXED, content, tags)`);
2672
- backfillFtsIndex(db);
2673
- available = true;
2674
- }
2675
- catch {
2676
- available = false;
2677
- }
2678
- // Read-first: only write when the flag actually changed, so a healthy
2679
- // store's open doesn't take a write lock for a same-value upsert.
2680
- const flag = available ? '1' : '0';
2681
- if (getMeta(db, 'fts5_available') !== flag)
2682
- setMeta(db, 'fts5_available', flag);
2683
- }
2684
- function backfillFtsIndex(db) {
2685
- // SAFETY: this get() result's shape matches the single aliased `c` column
2686
- // named in the SELECT above.
2687
- const memCount = db.prepare(`SELECT COUNT(*) AS c FROM memories`).get()?.c ?? 0;
2688
- // SAFETY: this get() result's shape matches the single aliased `c` column
2689
- // named in the SELECT above.
2690
- const ftsCount = db.prepare(`SELECT COUNT(*) AS c FROM memories_fts`).get()?.c ?? 0;
2691
- if (memCount === ftsCount)
2692
- return;
2693
- db.exec(`
2694
- INSERT INTO memories_fts(id, content, tags)
2695
- SELECT m.id, m.content, m.tags_json
2696
- FROM memories m
2697
- WHERE NOT EXISTS (
2698
- SELECT 1 FROM memories_fts f WHERE f.id = m.id
2699
- )
2700
- `);
2701
- db.exec(`
2702
- DELETE FROM memories_fts
2703
- WHERE id NOT IN (SELECT id FROM memories)
2704
- `);
2705
- }
2706
- export function closeHippoDb(db) {
2707
- db.close();
2708
- }
2709
- export function getMeta(db, key, fallback = '') {
2710
- // SAFETY: row's shape matches the single `value` column named in the
2711
- // SELECT above.
2712
- const row = db.prepare(`SELECT value FROM meta WHERE key = ?`).get(key);
2713
- return row?.value ?? fallback;
2714
- }
2715
- export function setMeta(db, key, value) {
2716
- db.prepare(`INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run(key, value);
2717
- }
2718
- export function isFtsAvailable(db) {
2719
- return getMeta(db, 'fts5_available', '0') === '1';
2720
- }
2721
- export function pruneConsolidationRuns(db, keep = 50) {
2722
- db.prepare(`
2723
- DELETE FROM consolidation_runs
2724
- WHERE id NOT IN (
2725
- SELECT id FROM consolidation_runs
2726
- ORDER BY timestamp DESC, id DESC
2727
- LIMIT ?
2728
- )
2729
- `).run(keep);
2730
- }
2731
- //# sourceMappingURL=db.js.map