hippo-memory 1.43.3 → 1.45.0

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