hippo-memory 1.59.0 → 1.61.0

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