hippo-memory 1.60.0 → 1.61.0

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