hippo-memory 1.43.3 → 1.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (534) hide show
  1. package/README.md +14 -12
  2. package/bin/hippo.js +3 -1
  3. package/dist/api.d.ts +21 -21
  4. package/dist/api.js +69 -42
  5. package/dist/audit-prune.js +4 -1
  6. package/dist/audit.d.ts +1 -1
  7. package/dist/audit.js +9 -0
  8. package/dist/autolearn.d.ts +1 -1
  9. package/dist/autolearn.js +2 -1
  10. package/dist/capture.d.ts +11 -10
  11. package/dist/capture.js +22 -30
  12. package/dist/cli.d.ts +4 -0
  13. package/dist/cli.js +309 -171
  14. package/dist/client.d.ts +4 -21
  15. package/dist/client.js +3 -112
  16. package/dist/config.js +2 -1
  17. package/dist/consolidate.d.ts +3 -0
  18. package/dist/consolidate.js +44 -42
  19. package/dist/dag.d.ts +1 -0
  20. package/dist/dag.js +10 -5
  21. package/dist/dashboard.js +7 -3
  22. package/dist/db.js +16 -24
  23. package/dist/dedupe.d.ts +1 -0
  24. package/dist/dedupe.js +4 -7
  25. package/dist/embedding-provider.d.ts +1 -1
  26. package/dist/embedding-provider.js +3 -2
  27. package/dist/embeddings.js +81 -8
  28. package/dist/extract.d.ts +2 -0
  29. package/dist/extract.js +9 -4
  30. package/dist/goals.js +12 -3
  31. package/dist/mcp/server.js +21 -36
  32. package/dist/memory.d.ts +3 -0
  33. package/dist/memory.js +7 -1
  34. package/dist/physics-state.js +4 -1
  35. package/dist/raw-archive.js +5 -2
  36. package/dist/recall-trace.js +4 -1
  37. package/dist/refine-llm.js +3 -2
  38. package/dist/rerankers/jev.js +3 -2
  39. package/dist/rerankers/llm.js +3 -2
  40. package/dist/search.js +3 -3
  41. package/dist/server.d.ts +5 -0
  42. package/dist/server.js +39 -29
  43. package/dist/shared.js +1 -1
  44. package/dist/stdin.d.ts +12 -0
  45. package/dist/stdin.js +41 -0
  46. package/dist/store.d.ts +18 -11
  47. package/dist/store.js +152 -65
  48. package/dist/version.d.ts +4 -6
  49. package/dist/version.js +4 -7
  50. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  51. package/extensions/openclaw-plugin/package.json +1 -1
  52. package/openclaw.plugin.json +1 -1
  53. package/package.json +8 -5
  54. package/dist/ablation.d.ts.map +0 -1
  55. package/dist/ablation.js.map +0 -1
  56. package/dist/ambient.d.ts.map +0 -1
  57. package/dist/ambient.js.map +0 -1
  58. package/dist/api.d.ts.map +0 -1
  59. package/dist/api.js.map +0 -1
  60. package/dist/audit-prune.d.ts.map +0 -1
  61. package/dist/audit-prune.js.map +0 -1
  62. package/dist/audit.d.ts.map +0 -1
  63. package/dist/audit.js.map +0 -1
  64. package/dist/auth.d.ts.map +0 -1
  65. package/dist/auth.js.map +0 -1
  66. package/dist/autolearn.d.ts.map +0 -1
  67. package/dist/autolearn.js.map +0 -1
  68. package/dist/availability.d.ts.map +0 -1
  69. package/dist/availability.js.map +0 -1
  70. package/dist/benchmarks/e1.3/incident-recall-eval.js +0 -78
  71. package/dist/benchmarks/e1.3/incident-recall-eval.js.map +0 -1
  72. package/dist/benchmarks/e1.3/slack-1000-event-smoke.js +0 -103
  73. package/dist/benchmarks/e1.3/slack-1000-event-smoke.js.map +0 -1
  74. package/dist/capture.d.ts.map +0 -1
  75. package/dist/capture.js.map +0 -1
  76. package/dist/card-detail.d.ts.map +0 -1
  77. package/dist/card-detail.js.map +0 -1
  78. package/dist/card.d.ts.map +0 -1
  79. package/dist/card.js.map +0 -1
  80. package/dist/cli.d.ts.map +0 -1
  81. package/dist/cli.js.map +0 -1
  82. package/dist/client.d.ts.map +0 -1
  83. package/dist/client.js.map +0 -1
  84. package/dist/compare.d.ts.map +0 -1
  85. package/dist/compare.js.map +0 -1
  86. package/dist/config.d.ts.map +0 -1
  87. package/dist/config.js.map +0 -1
  88. package/dist/connectors/github/backfill.d.ts.map +0 -1
  89. package/dist/connectors/github/backfill.js.map +0 -1
  90. package/dist/connectors/github/cli-impl.d.ts.map +0 -1
  91. package/dist/connectors/github/cli-impl.js.map +0 -1
  92. package/dist/connectors/github/deletion.d.ts.map +0 -1
  93. package/dist/connectors/github/deletion.js.map +0 -1
  94. package/dist/connectors/github/dlq.d.ts.map +0 -1
  95. package/dist/connectors/github/dlq.js.map +0 -1
  96. package/dist/connectors/github/idempotency.d.ts.map +0 -1
  97. package/dist/connectors/github/idempotency.js.map +0 -1
  98. package/dist/connectors/github/ingest.d.ts.map +0 -1
  99. package/dist/connectors/github/ingest.js.map +0 -1
  100. package/dist/connectors/github/octokit-client.d.ts.map +0 -1
  101. package/dist/connectors/github/octokit-client.js.map +0 -1
  102. package/dist/connectors/github/ratelimit.d.ts.map +0 -1
  103. package/dist/connectors/github/ratelimit.js.map +0 -1
  104. package/dist/connectors/github/scope.d.ts.map +0 -1
  105. package/dist/connectors/github/scope.js.map +0 -1
  106. package/dist/connectors/github/signature.d.ts.map +0 -1
  107. package/dist/connectors/github/signature.js.map +0 -1
  108. package/dist/connectors/github/tenant-routing.d.ts.map +0 -1
  109. package/dist/connectors/github/tenant-routing.js.map +0 -1
  110. package/dist/connectors/github/transform.d.ts.map +0 -1
  111. package/dist/connectors/github/transform.js.map +0 -1
  112. package/dist/connectors/github/types.d.ts.map +0 -1
  113. package/dist/connectors/github/types.js.map +0 -1
  114. package/dist/connectors/slack/backfill.d.ts.map +0 -1
  115. package/dist/connectors/slack/backfill.js.map +0 -1
  116. package/dist/connectors/slack/deletion.d.ts.map +0 -1
  117. package/dist/connectors/slack/deletion.js.map +0 -1
  118. package/dist/connectors/slack/dlq.d.ts.map +0 -1
  119. package/dist/connectors/slack/dlq.js.map +0 -1
  120. package/dist/connectors/slack/idempotency.d.ts.map +0 -1
  121. package/dist/connectors/slack/idempotency.js.map +0 -1
  122. package/dist/connectors/slack/ingest.d.ts.map +0 -1
  123. package/dist/connectors/slack/ingest.js.map +0 -1
  124. package/dist/connectors/slack/ratelimit.d.ts.map +0 -1
  125. package/dist/connectors/slack/ratelimit.js.map +0 -1
  126. package/dist/connectors/slack/scope.d.ts.map +0 -1
  127. package/dist/connectors/slack/scope.js.map +0 -1
  128. package/dist/connectors/slack/signature.d.ts.map +0 -1
  129. package/dist/connectors/slack/signature.js.map +0 -1
  130. package/dist/connectors/slack/tenant-routing.d.ts.map +0 -1
  131. package/dist/connectors/slack/tenant-routing.js.map +0 -1
  132. package/dist/connectors/slack/transform.d.ts.map +0 -1
  133. package/dist/connectors/slack/transform.js.map +0 -1
  134. package/dist/connectors/slack/types.d.ts.map +0 -1
  135. package/dist/connectors/slack/types.js.map +0 -1
  136. package/dist/connectors/slack/web-client.d.ts.map +0 -1
  137. package/dist/connectors/slack/web-client.js.map +0 -1
  138. package/dist/connectors/slack/workspaces.d.ts.map +0 -1
  139. package/dist/connectors/slack/workspaces.js.map +0 -1
  140. package/dist/consolidate.d.ts.map +0 -1
  141. package/dist/consolidate.js.map +0 -1
  142. package/dist/correction-latency.d.ts.map +0 -1
  143. package/dist/correction-latency.js.map +0 -1
  144. package/dist/customer-notes.d.ts.map +0 -1
  145. package/dist/customer-notes.js.map +0 -1
  146. package/dist/dag.d.ts.map +0 -1
  147. package/dist/dag.js.map +0 -1
  148. package/dist/dashboard.d.ts.map +0 -1
  149. package/dist/dashboard.js.map +0 -1
  150. package/dist/db.d.ts.map +0 -1
  151. package/dist/db.js.map +0 -1
  152. package/dist/decisions.d.ts.map +0 -1
  153. package/dist/decisions.js.map +0 -1
  154. package/dist/dedupe.d.ts.map +0 -1
  155. package/dist/dedupe.js.map +0 -1
  156. package/dist/embedding-provider.d.ts.map +0 -1
  157. package/dist/embedding-provider.js.map +0 -1
  158. package/dist/embeddings.d.ts.map +0 -1
  159. package/dist/embeddings.js.map +0 -1
  160. package/dist/eval-suite.d.ts.map +0 -1
  161. package/dist/eval-suite.js.map +0 -1
  162. package/dist/eval.d.ts.map +0 -1
  163. package/dist/eval.js.map +0 -1
  164. package/dist/extensions/openclaw-plugin/index.js.map +0 -1
  165. package/dist/extract.d.ts.map +0 -1
  166. package/dist/extract.js.map +0 -1
  167. package/dist/forward-claim-detector.d.ts.map +0 -1
  168. package/dist/forward-claim-detector.js.map +0 -1
  169. package/dist/goals.d.ts.map +0 -1
  170. package/dist/goals.js.map +0 -1
  171. package/dist/graph-extract.d.ts.map +0 -1
  172. package/dist/graph-extract.js.map +0 -1
  173. package/dist/graph-recall.d.ts.map +0 -1
  174. package/dist/graph-recall.js.map +0 -1
  175. package/dist/graph-stream.d.ts.map +0 -1
  176. package/dist/graph-stream.js.map +0 -1
  177. package/dist/graph-view.d.ts.map +0 -1
  178. package/dist/graph-view.js.map +0 -1
  179. package/dist/graph.d.ts.map +0 -1
  180. package/dist/graph.js.map +0 -1
  181. package/dist/handoff.d.ts.map +0 -1
  182. package/dist/handoff.js.map +0 -1
  183. package/dist/hooks.d.ts.map +0 -1
  184. package/dist/hooks.js.map +0 -1
  185. package/dist/importers.d.ts.map +0 -1
  186. package/dist/importers.js.map +0 -1
  187. package/dist/incidents.d.ts.map +0 -1
  188. package/dist/incidents.js.map +0 -1
  189. package/dist/index.d.ts.map +0 -1
  190. package/dist/index.js.map +0 -1
  191. package/dist/invalidation.d.ts.map +0 -1
  192. package/dist/invalidation.js.map +0 -1
  193. package/dist/mcp/framing.d.ts.map +0 -1
  194. package/dist/mcp/framing.js.map +0 -1
  195. package/dist/mcp/server.d.ts.map +0 -1
  196. package/dist/mcp/server.js.map +0 -1
  197. package/dist/memory-value-weights.d.ts.map +0 -1
  198. package/dist/memory-value-weights.js.map +0 -1
  199. package/dist/memory-value.d.ts.map +0 -1
  200. package/dist/memory-value.js.map +0 -1
  201. package/dist/memory.d.ts.map +0 -1
  202. package/dist/memory.js.map +0 -1
  203. package/dist/multihop.d.ts.map +0 -1
  204. package/dist/multihop.js.map +0 -1
  205. package/dist/owner-validation.d.ts.map +0 -1
  206. package/dist/owner-validation.js.map +0 -1
  207. package/dist/path-context.d.ts.map +0 -1
  208. package/dist/path-context.js.map +0 -1
  209. package/dist/physics-config.d.ts.map +0 -1
  210. package/dist/physics-config.js.map +0 -1
  211. package/dist/physics-state.d.ts.map +0 -1
  212. package/dist/physics-state.js.map +0 -1
  213. package/dist/physics.d.ts.map +0 -1
  214. package/dist/physics.js.map +0 -1
  215. package/dist/policies.d.ts.map +0 -1
  216. package/dist/policies.js.map +0 -1
  217. package/dist/postinstall.d.ts.map +0 -1
  218. package/dist/postinstall.js.map +0 -1
  219. package/dist/predictions.d.ts.map +0 -1
  220. package/dist/predictions.js.map +0 -1
  221. package/dist/processes.d.ts.map +0 -1
  222. package/dist/processes.js.map +0 -1
  223. package/dist/project-briefs.d.ts.map +0 -1
  224. package/dist/project-briefs.js.map +0 -1
  225. package/dist/project-identity.d.ts.map +0 -1
  226. package/dist/project-identity.js.map +0 -1
  227. package/dist/provenance-coverage.d.ts.map +0 -1
  228. package/dist/provenance-coverage.js.map +0 -1
  229. package/dist/rate-limit.d.ts.map +0 -1
  230. package/dist/rate-limit.js.map +0 -1
  231. package/dist/raw-archive-mirror-cleanup.d.ts.map +0 -1
  232. package/dist/raw-archive-mirror-cleanup.js.map +0 -1
  233. package/dist/raw-archive.d.ts.map +0 -1
  234. package/dist/raw-archive.js.map +0 -1
  235. package/dist/recall-history.d.ts.map +0 -1
  236. package/dist/recall-history.js.map +0 -1
  237. package/dist/recall-scope.d.ts.map +0 -1
  238. package/dist/recall-scope.js.map +0 -1
  239. package/dist/recall-trace.d.ts.map +0 -1
  240. package/dist/recall-trace.js.map +0 -1
  241. package/dist/refine-llm.d.ts.map +0 -1
  242. package/dist/refine-llm.js.map +0 -1
  243. package/dist/reject-flow.d.ts.map +0 -1
  244. package/dist/reject-flow.js.map +0 -1
  245. package/dist/rejection.d.ts.map +0 -1
  246. package/dist/rejection.js.map +0 -1
  247. package/dist/replay.d.ts.map +0 -1
  248. package/dist/replay.js.map +0 -1
  249. package/dist/rerankers/cross-encoder.d.ts.map +0 -1
  250. package/dist/rerankers/cross-encoder.js.map +0 -1
  251. package/dist/rerankers/index.d.ts.map +0 -1
  252. package/dist/rerankers/index.js.map +0 -1
  253. package/dist/rerankers/jev.d.ts.map +0 -1
  254. package/dist/rerankers/jev.js.map +0 -1
  255. package/dist/rerankers/llm.d.ts.map +0 -1
  256. package/dist/rerankers/llm.js.map +0 -1
  257. package/dist/rerankers/types.d.ts.map +0 -1
  258. package/dist/rerankers/types.js.map +0 -1
  259. package/dist/rrf.d.ts.map +0 -1
  260. package/dist/rrf.js.map +0 -1
  261. package/dist/salience.d.ts.map +0 -1
  262. package/dist/salience.js.map +0 -1
  263. package/dist/scheduler.d.ts.map +0 -1
  264. package/dist/scheduler.js.map +0 -1
  265. package/dist/scope.d.ts.map +0 -1
  266. package/dist/scope.js.map +0 -1
  267. package/dist/search.d.ts.map +0 -1
  268. package/dist/search.js.map +0 -1
  269. package/dist/secret-detect.d.ts.map +0 -1
  270. package/dist/secret-detect.js.map +0 -1
  271. package/dist/server-detect.d.ts.map +0 -1
  272. package/dist/server-detect.js.map +0 -1
  273. package/dist/server.d.ts.map +0 -1
  274. package/dist/server.js.map +0 -1
  275. package/dist/shared.d.ts.map +0 -1
  276. package/dist/shared.js.map +0 -1
  277. package/dist/skills.d.ts.map +0 -1
  278. package/dist/skills.js.map +0 -1
  279. package/dist/sleep-redact.d.ts +0 -58
  280. package/dist/sleep-redact.d.ts.map +0 -1
  281. package/dist/sleep-redact.js +0 -80
  282. package/dist/sleep-redact.js.map +0 -1
  283. package/dist/src/ablation.js +0 -138
  284. package/dist/src/ablation.js.map +0 -1
  285. package/dist/src/ambient.js +0 -148
  286. package/dist/src/ambient.js.map +0 -1
  287. package/dist/src/api.js +0 -2109
  288. package/dist/src/api.js.map +0 -1
  289. package/dist/src/audit-prune.js +0 -106
  290. package/dist/src/audit-prune.js.map +0 -1
  291. package/dist/src/audit.js +0 -213
  292. package/dist/src/audit.js.map +0 -1
  293. package/dist/src/auth.js +0 -97
  294. package/dist/src/auth.js.map +0 -1
  295. package/dist/src/autolearn.js +0 -174
  296. package/dist/src/autolearn.js.map +0 -1
  297. package/dist/src/availability.js +0 -94
  298. package/dist/src/availability.js.map +0 -1
  299. package/dist/src/capture.js +0 -1354
  300. package/dist/src/capture.js.map +0 -1
  301. package/dist/src/card-detail.js +0 -15
  302. package/dist/src/card-detail.js.map +0 -1
  303. package/dist/src/card.js +0 -19
  304. package/dist/src/card.js.map +0 -1
  305. package/dist/src/cli.js +0 -9328
  306. package/dist/src/cli.js.map +0 -1
  307. package/dist/src/client.js +0 -237
  308. package/dist/src/client.js.map +0 -1
  309. package/dist/src/compare.js +0 -122
  310. package/dist/src/compare.js.map +0 -1
  311. package/dist/src/config.js +0 -135
  312. package/dist/src/config.js.map +0 -1
  313. package/dist/src/connectors/github/backfill.js +0 -281
  314. package/dist/src/connectors/github/backfill.js.map +0 -1
  315. package/dist/src/connectors/github/cli-impl.js +0 -234
  316. package/dist/src/connectors/github/cli-impl.js.map +0 -1
  317. package/dist/src/connectors/github/deletion.js +0 -85
  318. package/dist/src/connectors/github/deletion.js.map +0 -1
  319. package/dist/src/connectors/github/dlq.js +0 -190
  320. package/dist/src/connectors/github/dlq.js.map +0 -1
  321. package/dist/src/connectors/github/idempotency.js +0 -27
  322. package/dist/src/connectors/github/idempotency.js.map +0 -1
  323. package/dist/src/connectors/github/ingest.js +0 -156
  324. package/dist/src/connectors/github/ingest.js.map +0 -1
  325. package/dist/src/connectors/github/octokit-client.js +0 -70
  326. package/dist/src/connectors/github/octokit-client.js.map +0 -1
  327. package/dist/src/connectors/github/ratelimit.js +0 -31
  328. package/dist/src/connectors/github/ratelimit.js.map +0 -1
  329. package/dist/src/connectors/github/scope.js +0 -13
  330. package/dist/src/connectors/github/scope.js.map +0 -1
  331. package/dist/src/connectors/github/signature.js +0 -81
  332. package/dist/src/connectors/github/signature.js.map +0 -1
  333. package/dist/src/connectors/github/tenant-routing.js +0 -69
  334. package/dist/src/connectors/github/tenant-routing.js.map +0 -1
  335. package/dist/src/connectors/github/transform.js +0 -103
  336. package/dist/src/connectors/github/transform.js.map +0 -1
  337. package/dist/src/connectors/github/types.js +0 -100
  338. package/dist/src/connectors/github/types.js.map +0 -1
  339. package/dist/src/connectors/slack/backfill.js +0 -78
  340. package/dist/src/connectors/slack/backfill.js.map +0 -1
  341. package/dist/src/connectors/slack/deletion.js +0 -59
  342. package/dist/src/connectors/slack/deletion.js.map +0 -1
  343. package/dist/src/connectors/slack/dlq.js +0 -224
  344. package/dist/src/connectors/slack/dlq.js.map +0 -1
  345. package/dist/src/connectors/slack/idempotency.js +0 -31
  346. package/dist/src/connectors/slack/idempotency.js.map +0 -1
  347. package/dist/src/connectors/slack/ingest.js +0 -109
  348. package/dist/src/connectors/slack/ingest.js.map +0 -1
  349. package/dist/src/connectors/slack/ratelimit.js +0 -18
  350. package/dist/src/connectors/slack/ratelimit.js.map +0 -1
  351. package/dist/src/connectors/slack/scope.js +0 -13
  352. package/dist/src/connectors/slack/scope.js.map +0 -1
  353. package/dist/src/connectors/slack/signature.js +0 -27
  354. package/dist/src/connectors/slack/signature.js.map +0 -1
  355. package/dist/src/connectors/slack/tenant-routing.js +0 -41
  356. package/dist/src/connectors/slack/tenant-routing.js.map +0 -1
  357. package/dist/src/connectors/slack/transform.js +0 -47
  358. package/dist/src/connectors/slack/transform.js.map +0 -1
  359. package/dist/src/connectors/slack/types.js +0 -35
  360. package/dist/src/connectors/slack/types.js.map +0 -1
  361. package/dist/src/connectors/slack/web-client.js +0 -43
  362. package/dist/src/connectors/slack/web-client.js.map +0 -1
  363. package/dist/src/connectors/slack/workspaces.js +0 -62
  364. package/dist/src/connectors/slack/workspaces.js.map +0 -1
  365. package/dist/src/consolidate.js +0 -978
  366. package/dist/src/consolidate.js.map +0 -1
  367. package/dist/src/correction-latency.js +0 -74
  368. package/dist/src/correction-latency.js.map +0 -1
  369. package/dist/src/customer-notes.js +0 -325
  370. package/dist/src/customer-notes.js.map +0 -1
  371. package/dist/src/dag.js +0 -352
  372. package/dist/src/dag.js.map +0 -1
  373. package/dist/src/dashboard.js +0 -258
  374. package/dist/src/dashboard.js.map +0 -1
  375. package/dist/src/db.js +0 -2728
  376. package/dist/src/db.js.map +0 -1
  377. package/dist/src/decisions.js +0 -304
  378. package/dist/src/decisions.js.map +0 -1
  379. package/dist/src/dedupe.js +0 -141
  380. package/dist/src/dedupe.js.map +0 -1
  381. package/dist/src/embedding-provider.js +0 -314
  382. package/dist/src/embedding-provider.js.map +0 -1
  383. package/dist/src/embeddings.js +0 -543
  384. package/dist/src/embeddings.js.map +0 -1
  385. package/dist/src/eval-suite.js +0 -294
  386. package/dist/src/eval-suite.js.map +0 -1
  387. package/dist/src/eval.js +0 -187
  388. package/dist/src/eval.js.map +0 -1
  389. package/dist/src/extract.js +0 -117
  390. package/dist/src/extract.js.map +0 -1
  391. package/dist/src/forward-claim-detector.js +0 -117
  392. package/dist/src/forward-claim-detector.js.map +0 -1
  393. package/dist/src/goals.js +0 -390
  394. package/dist/src/goals.js.map +0 -1
  395. package/dist/src/graph-extract.js +0 -314
  396. package/dist/src/graph-extract.js.map +0 -1
  397. package/dist/src/graph-recall.js +0 -277
  398. package/dist/src/graph-recall.js.map +0 -1
  399. package/dist/src/graph-stream.js +0 -176
  400. package/dist/src/graph-stream.js.map +0 -1
  401. package/dist/src/graph-view.js +0 -310
  402. package/dist/src/graph-view.js.map +0 -1
  403. package/dist/src/graph.js +0 -762
  404. package/dist/src/graph.js.map +0 -1
  405. package/dist/src/handoff.js +0 -65
  406. package/dist/src/handoff.js.map +0 -1
  407. package/dist/src/hooks.js +0 -926
  408. package/dist/src/hooks.js.map +0 -1
  409. package/dist/src/importers.js +0 -881
  410. package/dist/src/importers.js.map +0 -1
  411. package/dist/src/incidents.js +0 -336
  412. package/dist/src/incidents.js.map +0 -1
  413. package/dist/src/index.js +0 -32
  414. package/dist/src/index.js.map +0 -1
  415. package/dist/src/invalidation.js +0 -129
  416. package/dist/src/invalidation.js.map +0 -1
  417. package/dist/src/mcp/framing.js +0 -45
  418. package/dist/src/mcp/framing.js.map +0 -1
  419. package/dist/src/mcp/server.js +0 -1298
  420. package/dist/src/mcp/server.js.map +0 -1
  421. package/dist/src/memory-value-weights.js +0 -31
  422. package/dist/src/memory-value-weights.js.map +0 -1
  423. package/dist/src/memory-value.js +0 -261
  424. package/dist/src/memory-value.js.map +0 -1
  425. package/dist/src/memory.js +0 -425
  426. package/dist/src/memory.js.map +0 -1
  427. package/dist/src/multihop.js +0 -35
  428. package/dist/src/multihop.js.map +0 -1
  429. package/dist/src/owner-validation.js +0 -56
  430. package/dist/src/owner-validation.js.map +0 -1
  431. package/dist/src/path-context.js +0 -48
  432. package/dist/src/path-context.js.map +0 -1
  433. package/dist/src/physics-config.js +0 -26
  434. package/dist/src/physics-config.js.map +0 -1
  435. package/dist/src/physics-state.js +0 -169
  436. package/dist/src/physics-state.js.map +0 -1
  437. package/dist/src/physics.js +0 -378
  438. package/dist/src/physics.js.map +0 -1
  439. package/dist/src/policies.js +0 -414
  440. package/dist/src/policies.js.map +0 -1
  441. package/dist/src/postinstall.js +0 -101
  442. package/dist/src/postinstall.js.map +0 -1
  443. package/dist/src/predictions.js +0 -629
  444. package/dist/src/predictions.js.map +0 -1
  445. package/dist/src/processes.js +0 -349
  446. package/dist/src/processes.js.map +0 -1
  447. package/dist/src/project-briefs.js +0 -492
  448. package/dist/src/project-briefs.js.map +0 -1
  449. package/dist/src/project-identity.js +0 -200
  450. package/dist/src/project-identity.js.map +0 -1
  451. package/dist/src/provenance-coverage.js +0 -23
  452. package/dist/src/provenance-coverage.js.map +0 -1
  453. package/dist/src/rate-limit.js +0 -60
  454. package/dist/src/rate-limit.js.map +0 -1
  455. package/dist/src/raw-archive-mirror-cleanup.js +0 -55
  456. package/dist/src/raw-archive-mirror-cleanup.js.map +0 -1
  457. package/dist/src/raw-archive.js +0 -88
  458. package/dist/src/raw-archive.js.map +0 -1
  459. package/dist/src/recall-history.js +0 -235
  460. package/dist/src/recall-history.js.map +0 -1
  461. package/dist/src/recall-scope.js +0 -89
  462. package/dist/src/recall-scope.js.map +0 -1
  463. package/dist/src/recall-trace.js +0 -183
  464. package/dist/src/recall-trace.js.map +0 -1
  465. package/dist/src/refine-llm.js +0 -156
  466. package/dist/src/refine-llm.js.map +0 -1
  467. package/dist/src/reject-flow.js +0 -209
  468. package/dist/src/reject-flow.js.map +0 -1
  469. package/dist/src/rejection.js +0 -160
  470. package/dist/src/rejection.js.map +0 -1
  471. package/dist/src/replay.js +0 -122
  472. package/dist/src/replay.js.map +0 -1
  473. package/dist/src/rerankers/cross-encoder.js +0 -137
  474. package/dist/src/rerankers/cross-encoder.js.map +0 -1
  475. package/dist/src/rerankers/index.js +0 -20
  476. package/dist/src/rerankers/index.js.map +0 -1
  477. package/dist/src/rerankers/jev.js +0 -119
  478. package/dist/src/rerankers/jev.js.map +0 -1
  479. package/dist/src/rerankers/llm.js +0 -80
  480. package/dist/src/rerankers/llm.js.map +0 -1
  481. package/dist/src/rerankers/types.js +0 -2
  482. package/dist/src/rerankers/types.js.map +0 -1
  483. package/dist/src/rrf.js +0 -61
  484. package/dist/src/rrf.js.map +0 -1
  485. package/dist/src/salience.js +0 -74
  486. package/dist/src/salience.js.map +0 -1
  487. package/dist/src/scheduler.js +0 -77
  488. package/dist/src/scheduler.js.map +0 -1
  489. package/dist/src/scope.js +0 -35
  490. package/dist/src/scope.js.map +0 -1
  491. package/dist/src/search.js +0 -1003
  492. package/dist/src/search.js.map +0 -1
  493. package/dist/src/secret-detect.js +0 -95
  494. package/dist/src/secret-detect.js.map +0 -1
  495. package/dist/src/server-detect.js +0 -231
  496. package/dist/src/server-detect.js.map +0 -1
  497. package/dist/src/server.js +0 -3255
  498. package/dist/src/server.js.map +0 -1
  499. package/dist/src/shared.js +0 -502
  500. package/dist/src/shared.js.map +0 -1
  501. package/dist/src/skills.js +0 -346
  502. package/dist/src/skills.js.map +0 -1
  503. package/dist/src/sleep-redact.js +0 -80
  504. package/dist/src/sleep-redact.js.map +0 -1
  505. package/dist/src/sso.js +0 -22
  506. package/dist/src/sso.js.map +0 -1
  507. package/dist/src/store.js +0 -3743
  508. package/dist/src/store.js.map +0 -1
  509. package/dist/src/tenant.js +0 -17
  510. package/dist/src/tenant.js.map +0 -1
  511. package/dist/src/trace.js +0 -72
  512. package/dist/src/trace.js.map +0 -1
  513. package/dist/src/version.js +0 -40
  514. package/dist/src/version.js.map +0 -1
  515. package/dist/src/working-memory.js +0 -157
  516. package/dist/src/working-memory.js.map +0 -1
  517. package/dist/src/yaml.js +0 -107
  518. package/dist/src/yaml.js.map +0 -1
  519. package/dist/sso.d.ts +0 -13
  520. package/dist/sso.d.ts.map +0 -1
  521. package/dist/sso.js +0 -22
  522. package/dist/sso.js.map +0 -1
  523. package/dist/store.d.ts.map +0 -1
  524. package/dist/store.js.map +0 -1
  525. package/dist/tenant.d.ts.map +0 -1
  526. package/dist/tenant.js.map +0 -1
  527. package/dist/trace.d.ts.map +0 -1
  528. package/dist/trace.js.map +0 -1
  529. package/dist/version.d.ts.map +0 -1
  530. package/dist/version.js.map +0 -1
  531. package/dist/working-memory.d.ts.map +0 -1
  532. package/dist/working-memory.js.map +0 -1
  533. package/dist/yaml.d.ts.map +0 -1
  534. package/dist/yaml.js.map +0 -1
package/dist/src/api.js DELETED
@@ -1,2109 +0,0 @@
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 { createHash } from 'node:crypto';
10
- import { openHippoDb, closeHippoDb } from './db.js';
11
- import { writeEntry, writeEntryDbOnly, stampOriginProject, writeEntryMirrors, readEntry, deleteEntry, loadRecallSearchEntries, loadEntriesByIds, loadChildrenOf, loadFreshRawMemories, loadSessionRawMemories, countSessionRawMemories, DEFAULT_SEARCH_CANDIDATE_LIMIT, removeEntryMirrors, loadActiveTaskSnapshot, loadFreshActiveTaskSnapshot, loadLatestHandoff, listSessionEvents, SNAPSHOT_AMBIENT_MAX_AGE_MS, loadIndex, saveIndex, loadAllEntries, loadAmbientCandidates, updateStats, isInitialized, markSummaryDirtyInTx, auditRejectionRefusal, } from './store.js';
12
- import { RejectedValueError } from './rejection.js';
13
- import { rejectValue, unrejectValue, listRejectionsForTenant } from './reject-flow.js';
14
- import { formatHandoffEvidenceLine } from './handoff.js';
15
- import { createMemory, applyOutcome, calculateStrength, Layer, } from './memory.js';
16
- import { appendAuditEvent, queryAuditEvents, auditMemories, isContentWorthStoring, } from './audit.js';
17
- import { promoteToGlobal, getGlobalRoot, autoShare, searchBothHybrid } from './shared.js';
18
- import { writeRecallTrace, writeRecallTraceAtRoot, recordTraceOutcome } from './recall-trace.js';
19
- import { evalNow, isRecallBoostAblated } from './ablation.js';
20
- import { archiveRawMemory } from './raw-archive.js';
21
- import { createApiKey, listApiKeys, revokeApiKey, } from './auth.js';
22
- import { applyGoalStackBoost } from './goals.js';
23
- import { markRetrieved, estimateTokens, hybridSearch, physicsSearch } from './search.js';
24
- import { compareEntryIdentity, compareScoredResults } from './compare.js';
25
- import { scopeMatch } from './scope.js';
26
- import { consolidate } from './consolidate.js';
27
- import { loadConfig } from './config.js';
28
- import { resolveProjectIdentity, classifyOriginProject } from './project-identity.js';
29
- import { detectSecret } from './secret-detect.js';
30
- import { deduplicateStore } from './dedupe.js';
31
- import { computeAmbientState } from './ambient.js';
32
- import { loadPendingExtractionTenants, markPendingProcessedUpTo } from './graph.js';
33
- import { extractGraph } from './graph-extract.js';
34
- import { computePlanningFallacyOutput, } from './predictions.js';
35
- import { detectAnchoring, hashQueryText, } from './recall-history.js';
36
- import { detectAvailabilityBias } from './availability.js';
37
- /**
38
- * Helper for building process-local (admin-by-default) Actor values. v1.12.0
39
- * factory used by CLI / MCP / connector Context constructors so the role
40
- * boilerplate isn't repeated at every site. Bearer-authed callers (HTTP
41
- * /v1/*) construct Actor directly from the api_keys row's role column via
42
- * buildContextWithAuth in src/server.ts.
43
- */
44
- export function adminActor(subject) {
45
- return { subject, role: 'admin' };
46
- }
47
- /**
48
- * Thrown by `api.recall` when a caller's options violate a recall contract
49
- * that has been opted into via env. Carries a stable `code` field for HTTP /
50
- * MCP / CLI render paths to discriminate without parsing the message.
51
- *
52
- * Codes:
53
- * - 'fresh_tail_requires_session_id' — `freshTailCount > 0` AND no
54
- * `freshTailSessionId` AND `HIPPO_REQUIRE_SESSION_SCOPED_FRESH_TAIL=1`.
55
- * Default behaviour (env unset) returns tenant-wide rows; the env gate
56
- * is opt-in so multi-session tenants can fail loud instead of silently
57
- * surfacing cross-session rows tagged `isFreshTail=true`.
58
- * - 'invalid_scorer_window' — `opts.scorerWindow` is set to a non-positive,
59
- * non-integer, or non-finite value. Pre-v1.7.0 the value 0 routed
60
- * through FTS/LIKE `LIMIT 0` and then fell through to an uncapped
61
- * full-store fallback (codex v1.7.0 diff-pass P1). Validated upfront
62
- * so the contract holds.
63
- */
64
- export class RecallContractError extends Error {
65
- code;
66
- constructor(code, message) {
67
- super(message);
68
- this.name = 'RecallContractError';
69
- this.code = code;
70
- }
71
- }
72
- // v1.25.0: the recall-side scope predicates (PRIVATE_SCOPE_RE, isPrivateScope,
73
- // passesScopeFilterForRecall) live in recall-scope.ts (leaf) so shared.ts can
74
- // apply the same default-deny rule to searchBothHybrid's internal loads
75
- // without an api.ts import cycle — same pattern as classifyOriginProject
76
- // below. Imported here for this module's own call sites and re-exported for
77
- // back-compat (`api.isPrivateScope`, test imports). NOTE: the import statement
78
- // is required — a bare `export { x } from` re-export does not bind the local
79
- // names this module's ~9 call sites use.
80
- import { isPrivateScope, passesScopeFilterForRecall } from './recall-scope.js';
81
- export { isPrivateScope, passesScopeFilterForRecall };
82
- export { passesCliRecallScopeFilter } from './recall-scope.js';
83
- // v39: classifyOriginProject lives in project-identity.ts (leaf) so
84
- // shared.ts can use it without an api.ts import cycle. Re-exported here for
85
- // callers that already import the api surface.
86
- export { classifyOriginProject } from './project-identity.js';
87
- /**
88
- * v39: the single ambient-injection admission policy, shared by getContext
89
- * and the CLI-side ambient-state summary so the two cannot drift.
90
- *
91
- * - S4 secret veto is UNCONDITIONAL: neither crossProject nor
92
- * contextProjectIsolation:false re-includes secrets. A flagged row only
93
- * injects inside its owning project; flagged rows with no project origin
94
- * (''/null) never ambient-inject at all. Explicit recall is unaffected -
95
- * recalling a secret is a deliberate act.
96
- * - S2 envelope parity: private scopes + quarantine buckets never inject.
97
- * - S3 origin partition: other-project rows are excluded unless
98
- * `includeCrossProject`.
99
- */
100
- function ambientAdmitEntry(e, currentProjectName, includeCrossProject) {
101
- if (!ambientSecretAdmit(e, currentProjectName))
102
- return false;
103
- if (!passesScopeFilterForRecall(e.scope ?? null, undefined))
104
- return false;
105
- if (includeCrossProject)
106
- return true;
107
- return classifyOriginProject(e.origin_project, currentProjectName) !== 'cross-project';
108
- }
109
- /**
110
- * v39 S4: the secret half of the ambient policy on its own, for surfaces
111
- * with their own scope semantics (MCP hippo_context's explicit-scope
112
- * exact-match). A flagged row is only admitted inside its owning project;
113
- * flagged rows with no project origin never ambient-inject.
114
- */
115
- export function ambientSecretAdmit(e, currentProjectName) {
116
- if (!detectSecret(e).flagged)
117
- return true;
118
- const origin = e.origin_project;
119
- if (origin === undefined || origin === null || origin === '')
120
- return false;
121
- return origin === currentProjectName;
122
- }
123
- // The pinned-only branch needs pins and recent-N candidates, not the corpus.
124
- function loadAmbientEntries(hippoRoot, tenantId, pinnedOnly, includeRecent, admit) {
125
- if (!pinnedOnly)
126
- return loadAllEntries(hippoRoot, tenantId).filter(admit);
127
- // DF3's quality floor runs on the recent-N slice AFTER this load, so the load
128
- // counts by it too, or it stops short of a store whose newest rows are junk.
129
- const admitAmbient = (e) => admit(e) && (e.pinned || isContentWorthStoring(e.content));
130
- return loadAmbientCandidates(hippoRoot, tenantId, includeRecent, admitAmbient);
131
- }
132
- export function remember(ctx, opts) {
133
- const entry = createMemory(opts.content, {
134
- kind: opts.kind ?? 'distilled',
135
- scope: opts.scope ?? null,
136
- owner: opts.owner ?? null,
137
- artifact_ref: opts.artifactRef ?? null,
138
- tags: opts.tags,
139
- tenantId: ctx.tenantId,
140
- });
141
- // writeEntry threads ctx.actor.subject into its internal audit hook, so exactly
142
- // one 'remember' event lands in the log with the supplied actor.
143
- writeEntry(ctx.hippoRoot, entry, { actor: ctx.actor.subject, afterWrite: opts.afterWrite });
144
- return { id: entry.id, kind: entry.kind, tenantId: ctx.tenantId };
145
- }
146
- /**
147
- * Shared construction helper for `RecallSuppressionSummary`. Used by
148
- * `api.recall`, `cmdRecall`, and the MCP `hippo_recall` handler so all three
149
- * pipelines produce the same shape without duplicating field-construction
150
- * logic. Pass-through identity today; kept as a helper so future field
151
- * additions (B4 interference counter wiring, etc.) land at one site.
152
- */
153
- export function buildSuppressionSummary(counts) {
154
- return {
155
- totalCandidates: counts.totalCandidates,
156
- droppedPreRank: counts.droppedPreRank,
157
- droppedByBudget: counts.droppedByBudget,
158
- summarySubstitutionsAdded: counts.summarySubstitutionsAdded,
159
- freshTailAdded: counts.freshTailAdded,
160
- suppressedByInterference: counts.suppressedByInterference,
161
- };
162
- }
163
- /**
164
- * Domain-level recall. Loads BM25-ranked candidates from SQLite scoped to
165
- * `ctx.tenantId`. The `mode` flag is accepted for forward compatibility (the
166
- * CLI exposes hybrid/physics paths) but Task 2 wires only the BM25 candidate
167
- * loader; later tasks can extend this to call the physics/hybrid scorer.
168
- *
169
- * **api.recall does NOT mutate `index.last_retrieval_ids`** (v1.11.5 contract
170
- * lock). The CLI `cmdRecall` (cli.ts) writes `last_retrieval_ids` because the
171
- * CLI is interactive (user is about to run `hippo outcome --good`). SDK callers
172
- * are programmatic: they either pass explicit ids to `api.outcome` or call
173
- * `api.getContext` first for the context-then-outcome workflow (getContext
174
- * DOES write `last_retrieval_ids`). Adding the side-effect here would change
175
- * `api.recall` from a pure read into a read+write, breaking SDK callers who
176
- * batch recall calls in a row. Locked by
177
- * `tests/api-recall-no-side-effects.test.ts`.
178
- */
179
- export function recall(ctx, opts) {
180
- const limit = opts.limit ?? 10;
181
- // F5 (v1.6.5) preflight — codex P1: original guard fired AFTER
182
- // loadSearchEntries (which runs initStore, migrating legacy state on first
183
- // call). For a true contract preflight we want the throw before any
184
- // store-touching work. Single check here; the consumer site at
185
- // `if (freshTailCount > 0)` does NOT re-validate (would be a no-op).
186
- const freshTailCountPreflight = opts.freshTailCount ?? 0;
187
- if (freshTailCountPreflight > 0 &&
188
- !opts.freshTailSessionId &&
189
- process.env.HIPPO_REQUIRE_SESSION_SCOPED_FRESH_TAIL === '1') {
190
- throw new RecallContractError('fresh_tail_requires_session_id', 'fresh-tail requires a session id when HIPPO_REQUIRE_SESSION_SCOPED_FRESH_TAIL=1; ' +
191
- 'pass opts.freshTailSessionId or unset the env to allow tenant-wide fresh-tail.');
192
- }
193
- // F3 (v1.7.0): scorerWindow opt-in. When undefined (default),
194
- // loadSearchEntries uses its own store-internal default — this
195
- // preserves every pre-v1.7.0 caller's behaviour bit-for-bit (codex
196
- // mk2-pass P0-1: defaulting to `limit` would have shrunk the
197
- // candidate pool and killed overflow summaries).
198
- // DEFAULT_SEARCH_CANDIDATE_LIMIT is imported from store.ts so the two
199
- // values cannot drift (codex diff-pass P1 #3).
200
- // Validate the input — codex diff-pass P1 #1 caught that scorerWindow=0
201
- // would route through FTS/LIKE LIMIT 0 and then fall through to an
202
- // uncapped full-store fallback. Reject non-positive / non-finite values.
203
- if (opts.scorerWindow !== undefined) {
204
- if (!Number.isFinite(opts.scorerWindow) ||
205
- !Number.isInteger(opts.scorerWindow) ||
206
- opts.scorerWindow < 1) {
207
- throw new RecallContractError('invalid_scorer_window', `scorerWindow must be a positive integer; got ${opts.scorerWindow}`);
208
- }
209
- }
210
- const windowSize = opts.scorerWindow ?? DEFAULT_SEARCH_CANDIDATE_LIMIT;
211
- // v1.7.1 — root-cause fix for the `unknown:legacy` leak. Scope predicate
212
- // is now pushed into `loadSearchRows` SQL via `loadRecallSearchEntries`.
213
- // - opts.scope undefined / '': SQL excludes `unknown:legacy`.
214
- // - opts.scope non-empty: SQL exact-matches m.scope = opts.scope.
215
- // Tenant predicate still runs first, so a tenant-mismatched scope cannot
216
- // surface another tenant's row even when both share the same scope string.
217
- //
218
- // **CALLER CONTRACT:** any future recall-mode loader MUST go through
219
- // `loadRecallSearchEntries` (or invoke the SQL scope predicate equivalently).
220
- // Calling `loadSearchEntries` from this code path re-introduces the v1.6.5
221
- // codex-flagged leak. See `passesScopeFilterForRecall` in this file for
222
- // the canonical recall-side scope rule (kept in sync with the SQL clause
223
- // in loadSearchRows).
224
- //
225
- // Also fixes a latent code smell: pre-v1.7.1 passed `opts.scorerWindow`
226
- // (raw, possibly undefined) where `windowSize` was intended.
227
- // v1.12.13 / C5 — WYSIATI counters. Declared BEFORE the load step so the
228
- // assignments at the existing filter sites (load / scope-filter / limit-
229
- // slice / substitution / fresh-tail) are after declaration. The return at
230
- // end-of-function reads them via buildSuppressionSummary.
231
- let totalCandidatesCount = 0;
232
- let droppedPreRankCount = 0;
233
- let droppedByBudgetCount = 0;
234
- let summarySubstitutionsCount = 0;
235
- let freshTailAddedCount = 0;
236
- const all = loadRecallSearchEntries(ctx.hippoRoot, opts.query, windowSize, ctx.tenantId, opts.scope);
237
- // v1.12.13 / C5 — WYSIATI totalCandidates counter (post tenant + SQL scope
238
- // predicate, pre JS scope filter).
239
- totalCandidatesCount = all.length;
240
- let entries;
241
- if (opts.scope !== undefined && opts.scope !== '') {
242
- // SQL already exact-matched in loadRecallSearchEntries; keep the JS
243
- // filter as defense-in-depth so a future SQL-clause regression cannot
244
- // silently surface cross-scope rows.
245
- entries = all.filter((e) => e.scope === opts.scope);
246
- }
247
- else {
248
- // SQL already excluded `unknown:legacy` AND (v1.25.0) pre-filtered
249
- // ':private:' scopes with a conservative LIKE before the candidate
250
- // window, so private rows can no longer starve admitted rows out of the
251
- // LIMIT (codex review-stage P2). This JS filter stays as the exact
252
- // anchored `<source>:private:*` rule (v1.2.1 generalization) and
253
- // defense-in-depth: connector authors cannot silently surface private
254
- // rows to no-scope callers even if the SQL clause regresses.
255
- entries = all.filter((e) => !isPrivateScope(e.scope ?? null));
256
- }
257
- // v1.12.13 / C5 — WYSIATI dropped_pre_rank counter (JS scope filter drops
258
- // for api.recall; cmdRecall pipeline rolls --outcome/--layer/--as-of/etc.
259
- // into the same field per the plan's Task 3 mapping table).
260
- droppedPreRankCount = all.length - entries.length;
261
- // BM25 ordering already comes from loadRecallSearchEntries; cap to `limit`.
262
- // Score is a placeholder — the physics/hybrid scorers in src/search.ts
263
- // produce richer breakdowns and will replace this when wired up.
264
- let baseSlice = entries.slice(0, limit);
265
- // v1.12.13 / C5 — WYSIATI dropped_by_budget counter (candidates loaded but
266
- // excluded by the final limit slice).
267
- droppedByBudgetCount = entries.length - baseSlice.length;
268
- // v1.7.4 -- single db handle for the goal-stack boost AND the audit-event
269
- // emit below (codex P1: do not open a second short-lived handle for the
270
- // appendAuditEvent call). The handle is closed in the matching `finally`
271
- // immediately above the continuity block.
272
- const db = openHippoDb(ctx.hippoRoot);
273
- // v1.7.4 -- declared outside the try so the return statement (which lives
274
- // outside, after the continuity block) can read the final values.
275
- let rankedOut = [];
276
- let tokensOut = 0;
277
- let totalOut = 0;
278
- // v1.7.4 -- dlPFC goal-stack boost on the PRIMARY band only. Appendix paths
279
- // (fresh-tail, summary substitutions) are appended AFTER and keep their
280
- // semantically-special placement.
281
- let baseScored = baseSlice.map((entry, idx) => ({
282
- entry,
283
- score: Math.max(0, 1 - idx / Math.max(1, limit)),
284
- }));
285
- // A7 recall-trace: separate side-channel accumulator, allocated ONLY under
286
- // explain. applyGoalStackBoost writes goal-boost steps here keyed by entry
287
- // id; the baseRanked map reads it. When !explain it stays undefined and is
288
- // never passed → the helper's default-path math is byte-identical.
289
- const explainTrace = opts.explain ? new Map() : undefined;
290
- try {
291
- if (opts.sessionId && !opts.goalTag) {
292
- baseScored = applyGoalStackBoost(db, baseScored, {
293
- sessionId: opts.sessionId,
294
- tenantId: ctx.tenantId,
295
- limit,
296
- // trace is optional on applyGoalStackBoost; explicitly passing
297
- // undefined when !explain is identical to omitting the key.
298
- trace: explainTrace,
299
- });
300
- baseSlice = baseScored.map((r) => r.entry);
301
- }
302
- // v1.5.0 DAG-aware substitution (Phase 1, Task 2). When entries overflow the
303
- // limit and ≥2 of them share a level-2 parent summary, append the parent
304
- // summary so the user sees a compact pointer to the dropped detail. Capped
305
- // at ceil(limit * 0.3) substitutions so a runaway DAG can't expand results.
306
- // Each substituted summary is tenant-scoped via loadEntriesByIds and
307
- // re-checked against the active scope filter (default-deny on private).
308
- // Drill-down (Task 3) reverses substitution: caller passes substitutedFor[]
309
- // ids back through `drillDown` to recover the children.
310
- const summarizeOverflow = opts.summarizeOverflow ?? true;
311
- let substituted = [];
312
- if (summarizeOverflow && entries.length > limit) {
313
- const overflow = entries.slice(limit);
314
- const baseIds = new Set(baseSlice.map((e) => e.id));
315
- const overflowByParent = new Map();
316
- for (const e of overflow) {
317
- const parentId = e.dag_parent_id;
318
- if (!parentId)
319
- continue;
320
- if ((e.dag_level ?? 0) > 1)
321
- continue;
322
- const list = overflowByParent.get(parentId) ?? [];
323
- list.push(e);
324
- overflowByParent.set(parentId, list);
325
- }
326
- const eligibleParentIds = Array.from(overflowByParent.keys()).filter((pid) => (overflowByParent.get(pid)?.length ?? 0) >= 2 && !baseIds.has(pid));
327
- if (eligibleParentIds.length > 0) {
328
- const parents = loadEntriesByIds(ctx.hippoRoot, eligibleParentIds, ctx.tenantId);
329
- const eligibleParents = parents.filter((p) => (p.dag_level ?? 0) === 2 && passesScopeFilterForRecall(p.scope ?? null, opts.scope));
330
- const maxSub = Math.max(1, Math.ceil(limit * 0.3));
331
- // Order parents by overflow count descending so the most
332
- // information-dense substitutions come first. Overflow count is the
333
- // true primary key (unchanged); compareEntryIdentity is only a TAIL
334
- // for the case two parents overflow the same number of children —
335
- // without it that tie fell to SQLite scan order / loadEntriesByIds
336
- // batch order (T2, deterministic tie keys).
337
- eligibleParents.sort((a, b) => {
338
- const ac = overflowByParent.get(a.id)?.length ?? 0;
339
- const bc = overflowByParent.get(b.id)?.length ?? 0;
340
- return bc !== ac ? bc - ac : compareEntryIdentity(a, b);
341
- });
342
- substituted = eligibleParents.slice(0, maxSub).map((p) => ({
343
- entry: p,
344
- childIds: (overflowByParent.get(p.id) ?? []).map((e) => e.id),
345
- }));
346
- }
347
- }
348
- // v1.12.13 / C5 — WYSIATI summary_substitutions_added counter.
349
- summarySubstitutionsCount = substituted.length;
350
- // v1.7.4 -- baseScored carries the (possibly boosted) per-row scores. When
351
- // the goal-stack boost did not run, scores are identical to the original
352
- // positional placeholder; when it did run, scores reflect the boost AND the
353
- // rows are in the boosted order (helper sort()).
354
- const baseRanked = baseScored.map((r) => {
355
- const item = {
356
- id: r.entry.id,
357
- content: r.entry.content,
358
- score: r.score,
359
- layer: r.entry.layer,
360
- strength: r.entry.strength,
361
- };
362
- // A7 recall-trace: under explain, every api band carries rerankPipeline:'api';
363
- // only baseRanked passes through the goal-boost helper, so only it can carry
364
- // a step (and only for rows that actually matched an active goal).
365
- if (opts.explain) {
366
- item.rerankPipeline = 'api';
367
- const step = explainTrace?.get(r.entry.id);
368
- if (step)
369
- item.rerankTrace = [step];
370
- }
371
- return item;
372
- });
373
- // Substituted summaries land at the end with score = 0.5 (mid-rank), so
374
- // they don't outrank top-N strong matches but stay above lowest-rank
375
- // leaves on the consumer side. Caller sorts/filters as it sees fit.
376
- const summaryRanked = substituted.map((s) => {
377
- const item = {
378
- id: s.entry.id,
379
- content: s.entry.content,
380
- score: 0.5,
381
- layer: s.entry.layer,
382
- strength: s.entry.strength,
383
- isSummary: true,
384
- substitutedFor: s.childIds,
385
- descendantCount: s.entry.descendant_count ?? s.childIds.length,
386
- };
387
- // A7 recall-trace: summary band runs no re-ranking, but under explain it
388
- // still carries the pipeline marker (no steps). Absent when !explain.
389
- if (opts.explain)
390
- item.rerankPipeline = 'api';
391
- return item;
392
- });
393
- // v1.5.2 fresh-tail. Surface the last N kind='raw' rows so an agent's
394
- // "what did I just see" recall path always covers the recent window even
395
- // when the query terms don't match. Tenant + scope filtered.
396
- //
397
- // Dual-membership semantics: `loadSearchEntries` returns all tenant-scoped
398
- // rows scored by BM25 (even rows with no token overlap can surface at
399
- // score≈0), so a row in the recent window often ALSO appears as a BM25
400
- // hit. We don't duplicate. Instead:
401
- // 1. Mark any baseRanked entry that's in the recent set with isFreshTail.
402
- // 2. Prepend genuinely-new recent rows (not in BM25 hits or summaries).
403
- // Net: every recent row carries `isFreshTail=true`, exactly once.
404
- const freshTailCount = opts.freshTailCount ?? 0;
405
- const freshRanked = [];
406
- if (freshTailCount > 0) {
407
- // F5 contract guard fires at recall() preflight (top of function).
408
- // No re-check needed here — by the time we reach this block the
409
- // env/session policy has already been validated.
410
- const recent = loadFreshRawMemories(ctx.hippoRoot, freshTailCount, ctx.tenantId, opts.freshTailSessionId);
411
- const recentScoped = recent.filter((m) => passesScopeFilterForRecall(m.scope ?? null, opts.scope));
412
- const recentIdSet = new Set(recentScoped.map((m) => m.id));
413
- for (const r of baseRanked) {
414
- if (recentIdSet.has(r.id))
415
- r.isFreshTail = true;
416
- }
417
- const seenIds = new Set([
418
- ...baseRanked.map((r) => r.id),
419
- ...summaryRanked.map((r) => r.id),
420
- ]);
421
- for (const m of recentScoped) {
422
- if (seenIds.has(m.id))
423
- continue;
424
- const item = {
425
- id: m.id,
426
- content: m.content,
427
- score: 1.0,
428
- layer: m.layer,
429
- strength: m.strength,
430
- isFreshTail: true,
431
- };
432
- // A7 recall-trace: fresh-tail band runs no re-ranking; under explain
433
- // it carries the pipeline marker (no steps). Absent when !explain.
434
- if (opts.explain)
435
- item.rerankPipeline = 'api';
436
- freshRanked.push(item);
437
- seenIds.add(m.id);
438
- }
439
- }
440
- // v1.12.13 / C5 — WYSIATI fresh_tail_added counter. Captures the new rows
441
- // prepended (NOT rows already in baseRanked that got tagged isFreshTail).
442
- freshTailAddedCount = freshRanked.length;
443
- rankedOut = [...freshRanked, ...baseRanked, ...summaryRanked];
444
- tokensOut = rankedOut.reduce((acc, r) => acc + Math.ceil(r.content.length / 4), 0);
445
- totalOut = entries.length;
446
- // TODO(a1-task-4): emit via the shared audit hook in store.ts so we don't
447
- // double-emit. Recall does not currently write through writeEntry, so no
448
- // duplicate exists today, but we keep the same shape for symmetry.
449
- // v1.7.4: reuse the `db` handle opened above for the goal-stack boost --
450
- // single open/close spans both side effects.
451
- // GDPR Path A: store a sha256 hash (16 hex chars) of the query text
452
- // instead of the truncated query itself. If a caller queries with content
453
- // that matches an archived (RTBF) memory, the original text must not
454
- // persist in audit_log. query_length is preserved for debugging
455
- // long-prompt patterns and compliance metrics.
456
- appendAuditEvent(db, {
457
- tenantId: ctx.tenantId,
458
- actor: ctx.actor.subject,
459
- op: 'recall',
460
- metadata: {
461
- query_hash: createHash('sha256').update(opts.query).digest('hex').slice(0, 16),
462
- query_length: opts.query.length,
463
- results: rankedOut.length,
464
- },
465
- });
466
- // LC1 (docs/plans/2026-08-02-lc1-recall-trace-persistence.md): trace the
467
- // returned ids+ranks+scores next to the audit emit, on the SAME open
468
- // handle. v1.11.5 contract lock holds — api.recall does NOT write
469
- // last_trace_id (tests/api-recall-no-side-effects.test.ts); a trace INSERT
470
- // is the same observability class as the audit row it sits beside, not
471
- // retrieval state. F2 fix: suppressed when the caller (currently only the
472
- // MCP handler) traces its own, different result set — see
473
- // opts.suppressRecallTrace JSDoc. Fail-soft internally; never throws.
474
- if (!opts.suppressRecallTrace) {
475
- writeRecallTrace(db, {
476
- tenantId: ctx.tenantId,
477
- sessionId: opts.sessionId ?? null,
478
- pipeline: 'api',
479
- query: opts.query,
480
- explainMode: opts.explain === true,
481
- results: rankedOut.map((r) => ({
482
- memoryId: r.id,
483
- score: r.score,
484
- rerankSteps: r.rerankTrace,
485
- })),
486
- });
487
- }
488
- }
489
- finally {
490
- closeHippoDb(db);
491
- }
492
- let continuity;
493
- let continuityTokens;
494
- if (opts.includeContinuity) {
495
- const snapshot = loadActiveTaskSnapshot(ctx.hippoRoot, ctx.tenantId);
496
- // No active snapshot = no anchor = no handoff/events. Avoids resurrecting
497
- // a stale handoff from a deleted/completed session.
498
- const sessionId = snapshot?.session_id ?? undefined;
499
- const sessionHandoff = sessionId
500
- ? loadLatestHandoff(ctx.hippoRoot, ctx.tenantId, sessionId)
501
- : null;
502
- const recentSessionEvents = sessionId
503
- ? listSessionEvents(ctx.hippoRoot, ctx.tenantId, { session_id: sessionId, limit: 5 })
504
- : [];
505
- // Scope filtering on continuity. Mirrors the memory-recall path:
506
- // - opts.scope set: EXACT match required (no cross-scope leakage)
507
- // - opts.scope unset: default-deny on ANY `<source>:private:*` AND on
508
- // legacy 'unknown:legacy' rows quarantined by the v23 migration.
509
- // Public and null scopes pass through.
510
- // v1.1.0 wrongly wrote this as `opts.scope || isPublic`, which allowed
511
- // ANY explicit scope to see ALL continuity rows. v1.2 closed the latent
512
- // leak. v1.2.1 generalizes the private check from slack-only to any
513
- // source so v1.3 GitHub (and future Jira/Linear/etc.) cannot leak.
514
- const rowScope = (r) => r?.scope ?? null;
515
- // v1.2: TaskSnapshot / SessionHandoff / SessionEvent now carry scope; the
516
- // wrapper just normalizes null vs undefined. W1: was its own copy of
517
- // passesScopeFilterForRecall (cloned 3x); calls the shared helper now.
518
- const filteredSnapshot = snapshot && passesScopeFilterForRecall(rowScope(snapshot), opts.scope) ? snapshot : null;
519
- const filteredHandoff = sessionHandoff && passesScopeFilterForRecall(rowScope(sessionHandoff), opts.scope) ? sessionHandoff : null;
520
- const filteredEvents = recentSessionEvents.filter((e) => passesScopeFilterForRecall(rowScope(e), opts.scope));
521
- continuity = {
522
- activeSnapshot: filteredSnapshot,
523
- sessionHandoff: filteredHandoff,
524
- recentSessionEvents: filteredEvents,
525
- };
526
- const tokenize = (s) => s ? Math.ceil(s.length / 4) : 0;
527
- continuityTokens =
528
- tokenize(filteredSnapshot?.task) +
529
- tokenize(filteredSnapshot?.summary) +
530
- tokenize(filteredSnapshot?.next_step) +
531
- tokenize(filteredHandoff?.summary) +
532
- tokenize(filteredHandoff?.nextAction) +
533
- (filteredHandoff?.artifacts ?? []).reduce((acc, a) => acc + tokenize(a), 0) +
534
- (filteredHandoff?.constraints ?? []).reduce((acc, c) => acc + tokenize(c), 0) +
535
- tokenize(filteredHandoff?.evidence ? formatHandoffEvidenceLine(filteredHandoff.evidence) : null) +
536
- tokenize(filteredHandoff?.outcome) +
537
- tokenize(filteredHandoff?.targetRuntime) +
538
- tokenize(filteredHandoff?.cardId) +
539
- filteredEvents.reduce((acc, e) => acc + tokenize(e.content), 0);
540
- }
541
- // v0.32 / J3.2 — auto-injection of reference-class baserate when the
542
- // query carries a forward-prediction phrase AND the closest matching
543
- // class has closed historical data. Pipeline-invariant (queryText-
544
- // derived), so MCP and CLI both read this as the single source of
545
- // truth instead of recomputing (unlike suppressionSummary which IS
546
- // per-pipeline). opts.actor threads through to the inner
547
- // computePredictionBaserate call so MCP/HTTP-originated hints attribute
548
- // correctly instead of defaulting to 'cli'. Disabled by HIPPO_AUTODEBIAS=off.
549
- // v1.13.4: switched from computePlanningFallacyHint to
550
- // computePlanningFallacyOutput so the no-class-match / tiebreak
551
- // watching variant can also reach the caller surface. The two
552
- // outputs are mutually exclusive; we splat both as optional fields.
553
- const planningFallacyOutput = computePlanningFallacyOutput(ctx.hippoRoot, ctx.tenantId, opts.query, { actor: ctx.actor.subject });
554
- const planningFallacyHint = planningFallacyOutput.hint ?? null;
555
- const planningFallacyWatching = planningFallacyOutput.watching ?? null;
556
- // v0.33 / J1 (v1.13.2) — recall-recurrence anchoring detection.
557
- // Uses opts.recallHistory (caller-supplied snapshot) + this pipeline's
558
- // own top-1 from rankedOut[0]. PURE read — does NOT mutate the snapshot
559
- // or any caller-side Map. Disabled by HIPPO_ANCHORING=off (which gates
560
- // even the detectAnchoring call so disabled tenants pay zero work on
561
- // this surface). On CLI-routed call paths opts.recallHistory is
562
- // undefined because cmdRecall computes its own hint separately; the
563
- // detect call returns null and api.recall's anchoringHint stays absent.
564
- let anchoringHint = null;
565
- let suppressedByInterferenceCount = 0;
566
- if (process.env.HIPPO_ANCHORING !== 'off' && opts.recallHistory) {
567
- const queryHash = hashQueryText(opts.query);
568
- const topMemoryId = rankedOut[0]?.id ?? null;
569
- anchoringHint = detectAnchoring(opts.recallHistory, queryHash, topMemoryId);
570
- if (anchoringHint?.reason === 'memory_dominance') {
571
- suppressedByInterferenceCount = 1;
572
- // Emit audit op for the memory-dominance detection.
573
- const db = openHippoDb(ctx.hippoRoot);
574
- try {
575
- appendAuditEvent(db, {
576
- tenantId: ctx.tenantId,
577
- actor: ctx.actor.subject,
578
- op: 'recall_anchor_detected_memory_dominance',
579
- targetId: anchoringHint.memoryId,
580
- metadata: {
581
- memory_id: anchoringHint.memoryId,
582
- query_count: anchoringHint.queryCount ?? null,
583
- },
584
- });
585
- }
586
- finally {
587
- closeHippoDb(db);
588
- }
589
- }
590
- else if (anchoringHint?.reason === 'query_repeat') {
591
- const db = openHippoDb(ctx.hippoRoot);
592
- try {
593
- appendAuditEvent(db, {
594
- tenantId: ctx.tenantId,
595
- actor: ctx.actor.subject,
596
- op: 'recall_anchor_detected_query_repeat',
597
- targetId: anchoringHint.memoryId,
598
- metadata: { memory_id: anchoringHint.memoryId },
599
- });
600
- }
601
- finally {
602
- closeHippoDb(db);
603
- }
604
- }
605
- }
606
- // v1.13.x / J2 — availability/recency-bias detection. PURE read: compares
607
- // the age distribution of the returned top-K (baseSlice, the post-goal-boost
608
- // slice) against the matched candidate pool it was drawn from (entries, the
609
- // scope/private-FILTERED candidate set baseSlice is sliced from — NOT `all`,
610
- // which still holds private/cross-scope rows the caller is not eligible to see
611
- // and that could never enter the top-K; counting them would leak hidden pool
612
- // shape and inflate the signal). Soft warning only — does NOT filter, reorder,
613
- // or suppress. Disabled by HIPPO_AVAILABILITY=off (gates even the detect call
614
- // so disabled tenants pay zero work). Suppressed via opts.suppressAvailabilityHint
615
- // when the caller computes its own per-pipeline hint (MCP), mirroring the J1
616
- // opts.recallHistory gate above so we never double-emit the audit op. Audit
617
- // emission is pipeline-local, mirroring the J1 block above.
618
- let availabilityHint = null;
619
- if (process.env.HIPPO_AVAILABILITY !== 'off' && !opts.suppressAvailabilityHint) {
620
- availabilityHint = detectAvailabilityBias({
621
- topK: baseSlice.map((e) => ({ id: e.id, created: e.created })),
622
- pool: entries.map((e) => ({ id: e.id, created: e.created })),
623
- });
624
- if (availabilityHint) {
625
- const db = openHippoDb(ctx.hippoRoot);
626
- try {
627
- appendAuditEvent(db, {
628
- tenantId: ctx.tenantId,
629
- actor: ctx.actor.subject,
630
- op: 'recall_availability_detected',
631
- metadata: {
632
- recent_fraction: availabilityHint.recentFraction,
633
- older_passed_over: availabilityHint.olderCandidatesPassedOver,
634
- returned_count: availabilityHint.returnedCount,
635
- },
636
- });
637
- }
638
- finally {
639
- closeHippoDb(db);
640
- }
641
- }
642
- }
643
- const result = {
644
- results: rankedOut,
645
- total: totalOut,
646
- tokens: tokensOut,
647
- continuity,
648
- continuityTokens,
649
- windowSize,
650
- suppressionSummary: buildSuppressionSummary({
651
- totalCandidates: totalCandidatesCount,
652
- droppedPreRank: droppedPreRankCount,
653
- droppedByBudget: droppedByBudgetCount,
654
- summarySubstitutionsAdded: summarySubstitutionsCount,
655
- freshTailAdded: freshTailAddedCount,
656
- suppressedByInterference: suppressedByInterferenceCount,
657
- }),
658
- };
659
- if (planningFallacyHint)
660
- result.planningFallacyHint = planningFallacyHint;
661
- if (planningFallacyWatching)
662
- result.planningFallacyWatching = planningFallacyWatching;
663
- if (anchoringHint)
664
- result.anchoringHint = anchoringHint;
665
- if (availabilityHint)
666
- result.availabilityHint = availabilityHint;
667
- return result;
668
- }
669
- /**
670
- * Build a chronologically-ordered context window for a session. Adapts the
671
- * lossless-claw context-engine pattern to Hippo's score-ranked memory store.
672
- *
673
- * Algorithm:
674
- * 1. Load all kind='raw' rows for the session, tenant + scope filtered.
675
- * 2. Split: newest `freshTailCount` are protected (fresh tail).
676
- * 3. For older rows, when ≥2 share a level-2 parent, substitute the
677
- * summary; everything else passes through as raw.
678
- * 4. Hippo-additive eviction: when over-budget, drop the lowest-strength
679
- * non-fresh-tail item first. Fresh-tail rows are never evicted.
680
- *
681
- * Strength-weighted eviction is the differentiator from lossless-claw,
682
- * which evicts oldest-first. A high-strength older row (high retrieval
683
- * count, slow decay) survives; a low-strength recent row (newer but
684
- * unimportant) goes first.
685
- *
686
- * Returns `items: []` cleanly when:
687
- * - sessionId is empty
688
- * - no raws exist for the session
689
- * - all rows fail the scope/tenant filter
690
- */
691
- export function assemble(ctx, sessionId, opts = {}) {
692
- const budget = opts.budget ?? 4000;
693
- const freshTailCount = opts.freshTailCount ?? 10;
694
- const summarizeOlder = opts.summarizeOlder ?? true;
695
- const rowCap = opts.rowCap ?? 5000;
696
- if (!sessionId) {
697
- return { sessionId, items: [], tokens: 0, totalRaw: 0, summarized: 0, evicted: 0, truncated: false };
698
- }
699
- const rows = loadSessionRawMemories(ctx.hippoRoot, sessionId, ctx.tenantId, rowCap);
700
- const truncated = rows.length === rowCap;
701
- // v1.6.3 senior-review P0-1: report the FULL post-filter row count even
702
- // when the cap windows the loaded set. Pre-v1.6.3 used `scoped.length`
703
- // which under-reported on long sessions and made consumers render
704
- // wrong "session has N msgs" UX.
705
- const scoped = rows.filter((r) => passesScopeFilterForRecall(r.scope ?? null, opts.scope));
706
- let totalRaw;
707
- if (truncated) {
708
- // v1.6.3 codex P1 / senior P0: scope-aware unbounded COUNT. The helper
709
- // SQL-encodes the same default-deny rule passesScopeFilterForRecall
710
- // applies in TS, so a no-scope caller cannot infer private rows by
711
- // comparing totalRaw to items.length on a truncated session.
712
- totalRaw = countSessionRawMemories(ctx.hippoRoot, sessionId, ctx.tenantId, opts.scope);
713
- }
714
- else {
715
- totalRaw = scoped.length;
716
- }
717
- if (scoped.length === 0) {
718
- return { sessionId, items: [], tokens: 0, totalRaw, summarized: 0, evicted: 0, truncated };
719
- }
720
- // Split newest N into fresh tail; rest is older.
721
- const tailStartIdx = Math.max(0, scoped.length - freshTailCount);
722
- const olderRows = scoped.slice(0, tailStartIdx);
723
- const tailRows = scoped.slice(tailStartIdx);
724
- // Substitute parent summaries for older rows that share one.
725
- const olderItems = [];
726
- let summarized = 0;
727
- if (summarizeOlder && olderRows.length > 0) {
728
- const olderByParent = new Map();
729
- for (const r of olderRows) {
730
- if (!r.dag_parent_id)
731
- continue;
732
- const list = olderByParent.get(r.dag_parent_id) ?? [];
733
- list.push(r);
734
- olderByParent.set(r.dag_parent_id, list);
735
- }
736
- const eligibleParentIds = Array.from(olderByParent.keys()).filter((pid) => (olderByParent.get(pid)?.length ?? 0) >= 2);
737
- const parents = eligibleParentIds.length > 0
738
- ? loadEntriesByIds(ctx.hippoRoot, eligibleParentIds, ctx.tenantId)
739
- .filter((p) => (p.dag_level ?? 0) === 2)
740
- .filter((p) => passesScopeFilterForRecall(p.scope ?? null, opts.scope))
741
- : [];
742
- const claimedRawIds = new Set();
743
- for (const parent of parents) {
744
- const claimed = (olderByParent.get(parent.id) ?? []).map((r) => r.id);
745
- claimed.forEach((id) => claimedRawIds.add(id));
746
- olderItems.push({
747
- id: parent.id,
748
- content: parent.content,
749
- createdAt: parent.earliest_at ?? parent.created,
750
- isSummary: true,
751
- substitutedFor: claimed,
752
- strength: parent.strength,
753
- });
754
- summarized += claimed.length;
755
- }
756
- for (const r of olderRows) {
757
- if (claimedRawIds.has(r.id))
758
- continue;
759
- olderItems.push({
760
- id: r.id,
761
- content: r.content,
762
- createdAt: r.created,
763
- strength: r.strength,
764
- });
765
- }
766
- }
767
- else {
768
- for (const r of olderRows) {
769
- olderItems.push({
770
- id: r.id,
771
- content: r.content,
772
- createdAt: r.created,
773
- strength: r.strength,
774
- });
775
- }
776
- }
777
- const tailItems = tailRows.map((r) => ({
778
- id: r.id,
779
- content: r.content,
780
- createdAt: r.created,
781
- isFreshTail: true,
782
- strength: r.strength,
783
- }));
784
- // F4 (v1.6.5): byte compare canonical UTC ISO timestamps. ~50× faster than
785
- // localeCompare and chronological by virtue of the timestamp invariant
786
- // documented in src/memory.ts above MemoryEntry.
787
- const cmpIso = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
788
- olderItems.sort((a, b) => cmpIso(a.createdAt, b.createdAt));
789
- tailItems.sort((a, b) => cmpIso(a.createdAt, b.createdAt));
790
- let items = [...olderItems, ...tailItems];
791
- let tokens = items.reduce((acc, it) => acc + Math.ceil(it.content.length / 4), 0);
792
- let evicted = 0;
793
- while (tokens > budget && items.length > 0) {
794
- let worstIdx = -1;
795
- let worstStrength = Infinity;
796
- for (let i = 0; i < items.length; i++) {
797
- if (items[i].isFreshTail)
798
- continue;
799
- if (items[i].strength < worstStrength) {
800
- worstStrength = items[i].strength;
801
- worstIdx = i;
802
- }
803
- }
804
- if (worstIdx === -1)
805
- break;
806
- const cost = Math.ceil(items[worstIdx].content.length / 4);
807
- items = items.filter((_, i) => i !== worstIdx);
808
- tokens -= cost;
809
- evicted++;
810
- }
811
- return { sessionId, items, tokens, totalRaw, summarized, evicted, truncated };
812
- }
813
- /**
814
- * Walk one step down the DAG from a level-2 (or higher) summary to its direct
815
- * children. Companion to `recall(... summarizeOverflow: true)` — when recall
816
- * surfaces a summary with `substitutedFor: [...]`, the caller drills into the
817
- * summary id to recover the original detail.
818
- *
819
- * Tenant scope: only summaries owned by `ctx.tenantId` are reachable. The same
820
- * scope filter that recall applies is enforced on the children — a level-2
821
- * summary in `slack:public:CGEN` cannot leak `slack:private:*` children even
822
- * if the underlying DAG accidentally linked across scopes.
823
- *
824
- * Returns a discriminated `DrillDownOutcome`: `DrillDownResult` on success,
825
- * or `{failure: '...'}` for `not_found` (covers genuinely-missing AND wrong-
826
- * tenant, intentionally indistinguishable), `not_drillable` (id is a leaf
827
- * row), or `scope_blocked` (caller has no scope grant for the row's scope).
828
- *
829
- * Pre-v1.6.4 returned null for all four cases. JS callers migrate via
830
- * `'failure' in result` checks; HTTP route maps `not_drillable` to 422.
831
- */
832
- export function drillDown(ctx, summaryId, opts = {}) {
833
- const limit = opts.limit ?? 50;
834
- // v0.30 / E5: depth defaults 1 (backward compat); hard cap 10 levels
835
- // prevents pathological deep trees. CLI/HTTP/MCP reject invalid values.
836
- const depth = Math.max(1, Math.min(Math.trunc(opts.depth ?? 1), 10));
837
- const summary = readEntry(ctx.hippoRoot, summaryId, ctx.tenantId);
838
- // No unscoped cross-tenant probe here — readEntry's null return covers
839
- // both "doesn't exist" and "exists in another tenant" by design.
840
- // Distinguishing them via an unscoped lookup would leak existence to
841
- // unauthorised tenants. The two cases collapse into not_found.
842
- if (!summary)
843
- return { failure: 'not_found' };
844
- if ((summary.dag_level ?? 0) < 2)
845
- return { failure: 'not_drillable' };
846
- if (!passesScopeFilterForRecall(summary.scope ?? null, undefined)) {
847
- // codex round 3 P1: collapse to not_found. A distinguishable
848
- // "scope_blocked" tells a no-scope caller "this row exists, just
849
- // not for you" — same existence-leak the HTTP 404 collapse was
850
- // already preventing. Match the HTTP behaviour at the API level.
851
- return { failure: 'not_found' };
852
- }
853
- // v0.30 / E5: BFS walk levels 1..depth with visited-Set dedup. Defensive
854
- // against shared-child data anomalies (dag_parent_id has no uniqueness
855
- // constraint, so a misconfigured tree could double-emit at depth > 1).
856
- // Each level uses loadChildrenOf which is tenant-scoped via ctx.tenantId.
857
- const collected = [];
858
- const visited = new Set([summaryId]);
859
- let frontier = [summaryId];
860
- // independent-review MED #4 fold: track level-0 direct-children count
861
- // separately so the descendantCount fallback (for legacy summaries with
862
- // null descendant_count) reflects DIRECT children, not BFS-collected total.
863
- let level0DirectCount = 0;
864
- for (let level = 0; level < depth; level++) {
865
- const nextFrontier = [];
866
- for (const parentId of frontier) {
867
- const kids = loadChildrenOf(ctx.hippoRoot, parentId, ctx.tenantId);
868
- const eligibleKids = kids.filter((c) => passesScopeFilterForRecall(c.scope ?? null, undefined));
869
- for (const k of eligibleKids) {
870
- if (visited.has(k.id))
871
- continue;
872
- visited.add(k.id);
873
- collected.push(k);
874
- nextFrontier.push(k.id);
875
- if (level === 0)
876
- level0DirectCount++;
877
- }
878
- }
879
- if (nextFrontier.length === 0)
880
- break;
881
- frontier = nextFrontier;
882
- }
883
- // Apply global cumulative token budget + limit cap on collected.
884
- let children = collected;
885
- let truncated = false;
886
- if (opts.budget !== undefined) {
887
- const out = [];
888
- let used = 0;
889
- for (const c of collected) {
890
- const t = Math.ceil(c.content.length / 4);
891
- if (out.length > 0 && used + t > opts.budget) {
892
- truncated = true;
893
- break;
894
- }
895
- out.push(c);
896
- used += t;
897
- }
898
- children = out;
899
- }
900
- if (children.length > limit) {
901
- children = children.slice(0, limit);
902
- truncated = true;
903
- }
904
- return {
905
- summary: {
906
- id: summary.id,
907
- content: summary.content,
908
- // v0.30 / E5: descendant_count stays the summary's STORED value
909
- // (direct children at creation time). totalChildren below reflects
910
- // the full BFS collection at the requested depth.
911
- // independent-review MED #4 fold: legacy fallback uses level-0 direct
912
- // count (NOT collected.length which is BFS-depth-N total).
913
- descendantCount: summary.descendant_count ?? level0DirectCount,
914
- earliestAt: summary.earliest_at ?? null,
915
- latestAt: summary.latest_at ?? null,
916
- },
917
- children: children.map((c) => ({
918
- id: c.id,
919
- content: c.content,
920
- layer: c.layer,
921
- dagLevel: c.dag_level ?? 0,
922
- created: c.created,
923
- })),
924
- // v0.30 / E5: totalChildren = BFS-collected count (depth-aware). For
925
- // depth=1 this equals the eligible direct-children count (backward
926
- // compat). For depth>1 it is the cumulative count across levels.
927
- totalChildren: collected.length,
928
- truncated,
929
- };
930
- }
931
- export function outcome(ctx, ids, good, opts) {
932
- const appliedIds = [];
933
- const db = openHippoDb(ctx.hippoRoot);
934
- try {
935
- for (const id of ids) {
936
- const entry = readEntry(ctx.hippoRoot, id, ctx.tenantId);
937
- if (!entry)
938
- continue;
939
- const updated = applyOutcome(entry, good);
940
- writeEntry(ctx.hippoRoot, updated, { actor: ctx.actor.subject });
941
- appendAuditEvent(db, {
942
- tenantId: ctx.tenantId,
943
- actor: ctx.actor.subject,
944
- op: 'outcome',
945
- targetId: id,
946
- metadata: { good },
947
- });
948
- appliedIds.push(id);
949
- }
950
- // LC1: link the outcome to its trace, recording only the ids actually
951
- // credited (post tenant-filtering, matches appliedIds). Lives in its own
952
- // append-only table so audit_log pruning can never erase training data.
953
- if (opts?.traceId !== undefined && appliedIds.length > 0) {
954
- recordTraceOutcome(db, {
955
- traceId: opts.traceId,
956
- tenantId: ctx.tenantId,
957
- outcome: good ? 'positive' : 'negative',
958
- memoryIds: appliedIds,
959
- });
960
- }
961
- }
962
- finally {
963
- closeHippoDb(db);
964
- }
965
- return { applied: appliedIds.length, appliedIds };
966
- }
967
- export function forget(ctx, id) {
968
- const db = openHippoDb(ctx.hippoRoot);
969
- try {
970
- // SAFETY: row's shape matches the single `tenant_id` column named in
971
- // the SELECT above.
972
- const row = db
973
- .prepare(`SELECT tenant_id FROM memories WHERE id = ?`)
974
- .get(id);
975
- if (!row || row.tenant_id !== ctx.tenantId) {
976
- throw new Error(`memory not found: ${id}`);
977
- }
978
- }
979
- finally {
980
- closeHippoDb(db);
981
- }
982
- const removed = deleteEntry(ctx.hippoRoot, id, { actor: ctx.actor.subject });
983
- if (!removed) {
984
- throw new Error(`memory not found: ${id}`);
985
- }
986
- // Counted here, not in the CLI: both callers of this function (cmdForget and
987
- // the HTTP route) are the two paths of one user command, so neither can miss
988
- // it. api.remember cannot take the same move; see the server route.
989
- updateStats(ctx.hippoRoot, { forgotten: 1 });
990
- return { ok: true, id };
991
- }
992
- /**
993
- * Reject a value: tombstone its normalized digest so a matching write is
994
- * refused everywhere (remember/capture/import/sync) until `unreject`. Two
995
- * forms — pass exactly one:
996
- * - `memoryId`: reject the CURRENT content of an existing memory. Removes
997
- * that row and every other live row in the tenant whose normalized
998
- * digest matches (not just the id passed).
999
- * - `value`: pre-emptive form — tombstone content that may not currently
1000
- * be stored (or is already gone). Zero removals.
1001
- *
1002
- * `reason` is required (the tombstone stores no content; reason is its
1003
- * only human-readable identity). Throws if the memory id is not found in
1004
- * `ctx.tenantId`, or if both/neither of `memoryId`/`value` are given.
1005
- */
1006
- export function reject(ctx, opts) {
1007
- if (opts.memoryId !== undefined) {
1008
- // Tenant scope, same not-found-shaped denial as forget/promote above:
1009
- // rejectValue itself also tenant-checks the id, but pre-checking here
1010
- // keeps the error message consistent with the rest of this module.
1011
- const db = openHippoDb(ctx.hippoRoot);
1012
- try {
1013
- // SAFETY: row's shape matches the single `tenant_id` column named in
1014
- // the SELECT above.
1015
- const row = db
1016
- .prepare(`SELECT tenant_id FROM memories WHERE id = ?`)
1017
- .get(opts.memoryId);
1018
- if (!row || row.tenant_id !== ctx.tenantId) {
1019
- throw new Error(`memory not found: ${opts.memoryId}`);
1020
- }
1021
- }
1022
- finally {
1023
- closeHippoDb(db);
1024
- }
1025
- }
1026
- const result = rejectValue({
1027
- hippoRoot: ctx.hippoRoot,
1028
- tenantId: ctx.tenantId,
1029
- actor: ctx.actor.subject,
1030
- reason: opts.reason,
1031
- memoryId: opts.memoryId,
1032
- value: opts.value,
1033
- });
1034
- return { digest: result.digest, removedIds: result.removedIds };
1035
- }
1036
- /**
1037
- * Delete a tombstone by exact digest or unambiguous prefix, restoring the
1038
- * value's writability — the only v1 escape hatch (no per-write force flag).
1039
- * Throws if `digestOrPrefix` matches no tombstone, is blank, or matches
1040
- * more than one (use a longer prefix).
1041
- */
1042
- export function unreject(ctx, digestOrPrefix) {
1043
- const outcome = unrejectValue(ctx.hippoRoot, ctx.tenantId, digestOrPrefix, ctx.actor.subject);
1044
- if (outcome.status === 'not_found') {
1045
- throw new Error(`no rejected value matches: ${digestOrPrefix}`);
1046
- }
1047
- if (outcome.status === 'ambiguous') {
1048
- throw new Error(`"${digestOrPrefix}" matches ${outcome.candidates.length} tombstones; use a longer prefix`);
1049
- }
1050
- return { ok: true, digest: outcome.digest };
1051
- }
1052
- /** List every rejected-value tombstone for `ctx.tenantId`, newest first. */
1053
- export function listRejections(ctx) {
1054
- return listRejectionsForTenant(ctx.hippoRoot, ctx.tenantId);
1055
- }
1056
- export function promote(ctx, id) {
1057
- // Tenant scope: promoteToGlobal reads the entry from the local root via
1058
- // readEntry without a tenant filter, so a Bearer for tenant A could
1059
- // promote tenant B's row by guessing or leaking the id. Pre-check the
1060
- // row's tenant_id and deny cross-tenant access with the same not-found
1061
- // wording archiveRaw uses (no info leak about whether the id exists in
1062
- // another tenant).
1063
- const ownerDb = openHippoDb(ctx.hippoRoot);
1064
- try {
1065
- // SAFETY: row's shape matches the single `tenant_id` column named in
1066
- // the SELECT above.
1067
- const row = ownerDb
1068
- .prepare(`SELECT tenant_id FROM memories WHERE id = ?`)
1069
- .get(id);
1070
- if (!row || row.tenant_id !== ctx.tenantId) {
1071
- throw new Error(`memory not found: ${id}`);
1072
- }
1073
- }
1074
- finally {
1075
- closeHippoDb(ownerDb);
1076
- }
1077
- // promoteToGlobal threads ctx.actor.subject into the writeEntry call on the global
1078
- // db, which emits a 'remember' audit row. We then add the user-facing
1079
- // 'promote' event on the global db so the audit trail keeps the intent
1080
- // distinct from the underlying upsert.
1081
- const globalEntry = promoteToGlobal(ctx.hippoRoot, id, { actor: ctx.actor.subject, tenantId: ctx.tenantId });
1082
- const db = openHippoDb(getGlobalRoot());
1083
- try {
1084
- appendAuditEvent(db, {
1085
- tenantId: ctx.tenantId,
1086
- actor: ctx.actor.subject,
1087
- op: 'promote',
1088
- targetId: globalEntry.id,
1089
- metadata: { sourceId: id },
1090
- });
1091
- }
1092
- finally {
1093
- closeHippoDb(db);
1094
- }
1095
- return { ok: true, sourceId: id, globalId: globalEntry.id };
1096
- }
1097
- export function supersede(ctx, oldId, newContent) {
1098
- // Read old (tenant-scoped). readEntry filters by tenantId, so a Bearer for
1099
- // tenant A on tenant B's id throws "Memory not found" here without any
1100
- // info leak.
1101
- const old = readEntry(ctx.hippoRoot, oldId, ctx.tenantId);
1102
- if (!old) {
1103
- throw new Error(`Memory not found: ${oldId}`);
1104
- }
1105
- // Guard: not already superseded. The CAS UPDATE below race-safely closes
1106
- // the window between this read and the write; this check just produces a
1107
- // clearer error in the common single-writer case.
1108
- if (old.superseded_by) {
1109
- throw new Error(`Memory ${oldId} is already superseded by ${old.superseded_by}. Supersede that one instead.`);
1110
- }
1111
- const newEntry = createMemory(newContent, {
1112
- layer: old.layer ?? Layer.Episodic,
1113
- tags: [...old.tags],
1114
- pinned: old.pinned,
1115
- source: old.source,
1116
- confidence: 'verified',
1117
- tenantId: ctx.tenantId,
1118
- });
1119
- // Race-safe transition: open a fresh db handle, BEGIN IMMEDIATE, run all
1120
- // three steps (CAS on old + writeEntryDbOnly(new) + supersede audit row)
1121
- // inside the same transaction. Two concurrent supersedes: exactly one CAS
1122
- // wins (changes=1), the other gets changes=0 and throws CONFLICT. No
1123
- // dangling-pointer window: the new memory's row commits atomically with
1124
- // the old.superseded_by pointer.
1125
- const db = openHippoDb(ctx.hippoRoot);
1126
- try {
1127
- db.exec('BEGIN IMMEDIATE');
1128
- try {
1129
- // 1. CAS update: only succeed if old.superseded_by IS NULL AND the
1130
- // row still belongs to ctx.tenantId. Tenant filter is belt-and-
1131
- // braces with the readEntry above — it costs nothing and closes
1132
- // a hypothetical window where ownership changes between read and
1133
- // update.
1134
- const result = db.prepare(`
1135
- UPDATE memories
1136
- SET superseded_by = ?
1137
- WHERE id = ? AND tenant_id = ? AND superseded_by IS NULL
1138
- `).run(newEntry.id, oldId, ctx.tenantId);
1139
- if ((result.changes ?? 0) === 0) {
1140
- db.exec('ROLLBACK');
1141
- throw new Error(`Memory ${oldId} already superseded by another writer`);
1142
- }
1143
- // v0.30 / E2 — DAG live-coupling: OLD entry just transitioned to
1144
- // superseded. Its parent (if any) needs rebuild. Lands strictly
1145
- // between the rollback guard above and the writeEntryDbOnly(NEW)
1146
- // below so a failed CAS hits throw before this hook. The NEW
1147
- // entry's parent (typically same parent) is auto-marked by the
1148
- // writeEntryDbOnly hook (same parent → idempotent, audits once).
1149
- if (old.dag_parent_id) {
1150
- markSummaryDirtyInTx(db, old.dag_parent_id, ctx.tenantId, ctx.actor.subject);
1151
- }
1152
- // 2. Write new memory inside same tx via writeEntryDbOnly (DB-only
1153
- // path). This emits its OWN 'remember' audit row for the new
1154
- // memory inside the SAVEPOINT — atomic with the row INSERT.
1155
- writeEntryDbOnly(db, stampOriginProject(ctx.hippoRoot, newEntry), { actor: ctx.actor.subject });
1156
- // 3. User-facing 'supersede' audit row inside the same tx so the
1157
- // chain pointer + audit trail commit atomically.
1158
- appendAuditEvent(db, {
1159
- tenantId: ctx.tenantId,
1160
- actor: ctx.actor.subject,
1161
- op: 'supersede',
1162
- targetId: oldId,
1163
- metadata: { newId: newEntry.id },
1164
- });
1165
- db.exec('COMMIT');
1166
- }
1167
- catch (err) {
1168
- try {
1169
- db.exec('ROLLBACK');
1170
- }
1171
- catch { /* already rolled back */ }
1172
- // AT1 (plan §3): refusal audit lands post-ROLLBACK, in a fresh
1173
- // implicit transaction the aborted outer one cannot claw back — then
1174
- // rethrow so the caller sees the refusal.
1175
- if (err instanceof RejectedValueError) {
1176
- auditRejectionRefusal(db, err, ctx.actor.subject);
1177
- }
1178
- throw err;
1179
- }
1180
- // Mirrors after COMMIT, while the db handle is still open. Same
1181
- // invariant as the original writeEntry: a mirror failure leaves disk
1182
- // MISSING the markdown for the new memory (self-heals on next backfill
1183
- // via writeIndexMirror reading the DB) but DOES NOT desync the DB or
1184
- // roll back the supersede. Logged + swallowed, non-fatal.
1185
- try {
1186
- writeEntryMirrors(ctx.hippoRoot, db, newEntry);
1187
- }
1188
- catch (mirrorErr) {
1189
- console.error('supersede: mirror write failed (non-fatal, will self-heal):', mirrorErr);
1190
- }
1191
- }
1192
- finally {
1193
- closeHippoDb(db);
1194
- }
1195
- return { ok: true, oldId, newId: newEntry.id };
1196
- }
1197
- export function archiveRaw(ctx, id, reason, opts = {}) {
1198
- const db = openHippoDb(ctx.hippoRoot);
1199
- let mirrorOk = false;
1200
- try {
1201
- // Tenant scope: archiveRawMemory looks up the row by id alone, so a
1202
- // Bearer for tenant A could archive tenant B's raw row without this
1203
- // pre-check. Deny cross-tenant access with the same not-found message
1204
- // archiveRawMemory itself would throw on a missing row, so we don't
1205
- // leak whether the id exists in another tenant.
1206
- // SAFETY: row's shape matches the single `tenant_id` column named in
1207
- // the SELECT above.
1208
- const row = db
1209
- .prepare(`SELECT tenant_id FROM memories WHERE id = ?`)
1210
- .get(id);
1211
- if (!row || row.tenant_id !== ctx.tenantId) {
1212
- throw new Error(`memory not found: ${id}`);
1213
- }
1214
- archiveRawMemory(db, id, {
1215
- reason,
1216
- who: ctx.actor.subject,
1217
- afterArchive: opts.afterArchive,
1218
- });
1219
- // archiveRawMemory deletes the memories row but leaves any legacy markdown
1220
- // mirror in <root>/{buffer,episodic,semantic}/<id>.md untouched. If we left
1221
- // the mirror in place, a subsequent initStore() on an empty memories table
1222
- // would silently re-import the row via bootstrapLegacyStore — defeating the
1223
- // archive (and the GDPR right-to-be-forgotten promise on raw rows). Mirror
1224
- // forget() at src/store.ts:1046, which uses the same removeEntryMirrors call.
1225
- // The DB transaction has already committed; if filesystem unlink fails here
1226
- // we log and continue. The mirror reaper in openHippoDb will catch it on
1227
- // next DB open: raw_archive.mirror_cleaned_at stays NULL until every layer
1228
- // mirror for this id is gone, so the reaper genuinely retries.
1229
- try {
1230
- removeEntryMirrors(ctx.hippoRoot, id);
1231
- mirrorOk = true;
1232
- }
1233
- catch (mirrorErr) {
1234
- console.error(`archiveRaw: mirror cleanup failed for ${id} (will retry via reaper on next openHippoDb):`, mirrorErr);
1235
- }
1236
- if (mirrorOk) {
1237
- // Stamp mirror_cleaned_at now so the next openHippoDb reaper SELECT
1238
- // returns empty for this row. NULL stays untouched on failure -> retry.
1239
- db.prepare(`UPDATE raw_archive SET mirror_cleaned_at = ? WHERE memory_id = ?`).run(new Date().toISOString(), id);
1240
- }
1241
- }
1242
- finally {
1243
- closeHippoDb(db);
1244
- }
1245
- // Counted here rather than in the CLI: the HTTP archive route calls this too,
1246
- // so a routed archive would otherwise never reach the forgotten counter.
1247
- updateStats(ctx.hippoRoot, { forgotten: 1 });
1248
- // archiveRawMemory does not return the archive_at timestamp it wrote. We
1249
- // emit a fresh ISO timestamp here for the API response. Within a millisecond
1250
- // of the actual write, fine for a server response shape.
1251
- return { ok: true, archivedAt: new Date().toISOString() };
1252
- }
1253
- /**
1254
- * Mint a new API key. The new key is ALWAYS bound to `ctx.tenantId`. Callers
1255
- * cannot override the tenant via the opts bag — a previous `tenantId` field
1256
- * was removed because the HTTP layer would happily forward `body.tenantId`,
1257
- * letting tenant A mint a key for tenant B. The HTTP route handler at
1258
- * `src/server.ts` POST /v1/auth/keys mirrors this: it ignores any body
1259
- * `tenantId` and uses the resolved Bearer's tenant exclusively.
1260
- *
1261
- * Per A5 v2 follow-ups (TODOS.md), `auth_create` is currently unaudited —
1262
- * we intentionally match that behavior here for consistency. When A5 v2
1263
- * lands and adds the audit op, this function should mirror the cli handler.
1264
- */
1265
- export function authCreate(ctx, opts) {
1266
- const db = openHippoDb(ctx.hippoRoot);
1267
- try {
1268
- const role = opts.role ?? 'admin';
1269
- const result = createApiKey(db, { tenantId: ctx.tenantId, label: opts.label, role });
1270
- // v1.12.4: audit emit (closes the gap v1.12.3 CHANGELOG flagged as deferred).
1271
- // Mirrors the auth_revoke pattern at authRevoke — same try/catch so audit
1272
- // failure can't crash a successful mint. The plaintext is NEVER logged;
1273
- // metadata carries label + role + the keyId (which is non-secret).
1274
- try {
1275
- appendAuditEvent(db, {
1276
- tenantId: ctx.tenantId,
1277
- actor: ctx.actor.subject,
1278
- op: 'auth_create',
1279
- targetId: result.keyId,
1280
- metadata: {
1281
- label: opts.label ?? null,
1282
- role,
1283
- },
1284
- });
1285
- }
1286
- catch {
1287
- // Audit must not crash a successful mint.
1288
- }
1289
- return { keyId: result.keyId, plaintext: result.plaintext, tenantId: ctx.tenantId, role };
1290
- }
1291
- finally {
1292
- closeHippoDb(db);
1293
- }
1294
- }
1295
- /**
1296
- * List API keys visible to the calling tenant.
1297
- *
1298
- * Divergence from `cmdAuthList` in src/cli.ts: the CLI today returns ALL keys
1299
- * regardless of tenant (single-tenant deployments). The API surface is tenant-
1300
- * scoped because future multi-tenant deployments will share a hippoRoot, and
1301
- * tenant A must not see tenant B's keys. Read-only — no audit emit (matches A5).
1302
- */
1303
- export function authList(ctx, opts) {
1304
- const db = openHippoDb(ctx.hippoRoot);
1305
- try {
1306
- const all = listApiKeys(db, opts);
1307
- return all.filter((k) => k.tenantId === ctx.tenantId);
1308
- }
1309
- finally {
1310
- closeHippoDb(db);
1311
- }
1312
- }
1313
- export function authRevoke(ctx, keyId) {
1314
- const db = openHippoDb(ctx.hippoRoot);
1315
- try {
1316
- // SAFETY: row's shape matches the three columns named in the SELECT
1317
- // above.
1318
- const row = db
1319
- .prepare(`SELECT key_id, tenant_id, revoked_at FROM api_keys WHERE key_id = ?`)
1320
- .get(keyId);
1321
- if (!row) {
1322
- throw new Error(`Unknown key_id: ${keyId}`);
1323
- }
1324
- // Cross-tenant access denied: same message as missing key, no info leak.
1325
- if (row.tenant_id !== ctx.tenantId) {
1326
- throw new Error(`Unknown key_id: ${keyId}`);
1327
- }
1328
- let revokedAt;
1329
- let alreadyRevoked = false;
1330
- if (row.revoked_at) {
1331
- alreadyRevoked = true;
1332
- revokedAt = row.revoked_at;
1333
- }
1334
- else {
1335
- revokeApiKey(db, keyId);
1336
- // SAFETY: updated's shape matches the single `revoked_at` column named
1337
- // in the SELECT above.
1338
- const updated = db
1339
- .prepare(`SELECT revoked_at FROM api_keys WHERE key_id = ?`)
1340
- .get(keyId);
1341
- revokedAt = updated?.revoked_at ?? new Date().toISOString();
1342
- }
1343
- if (!alreadyRevoked) {
1344
- try {
1345
- appendAuditEvent(db, {
1346
- tenantId: row.tenant_id, // M1: KEY's tenant, not ctx.tenantId.
1347
- actor: ctx.actor.subject,
1348
- op: 'auth_revoke',
1349
- targetId: keyId,
1350
- });
1351
- }
1352
- catch {
1353
- // Audit must not crash a successful revoke.
1354
- }
1355
- }
1356
- return { ok: true, revokedAt };
1357
- }
1358
- finally {
1359
- closeHippoDb(db);
1360
- }
1361
- }
1362
- /**
1363
- * Read audit events scoped to `ctx.tenantId`. Read-only — no audit emit (matches
1364
- * A5: cmdAuditList does not record a 'recall'-style read event).
1365
- */
1366
- export function auditList(ctx, opts) {
1367
- const db = openHippoDb(ctx.hippoRoot);
1368
- try {
1369
- return queryAuditEvents(db, {
1370
- tenantId: ctx.tenantId,
1371
- op: opts.op,
1372
- since: opts.since,
1373
- limit: opts.limit,
1374
- });
1375
- }
1376
- finally {
1377
- closeHippoDb(db);
1378
- }
1379
- }
1380
- /**
1381
- * Assemble a context bundle: recalled memories (pinned-only / strength-sorted
1382
- * fallback / hybrid search) + active task snapshot + session handoff + recent
1383
- * session events. Budget-bounded, tenant-scoped. Mutates `last_retrieval_ids`
1384
- * + emits a 'recall' audit row for non-pinned, non-'*' queries.
1385
- *
1386
- * Behaves like the pre-extraction `cmdContext` data-loading + selection
1387
- * pipeline. CLI presentation (markdown / json / additional-context rendering)
1388
- * stays in `cli.ts`.
1389
- *
1390
- * Tenant scope: all `loadAllEntries` / snapshot / handoff / events reads use
1391
- * `ctx.tenantId`. Cross-tenant rows are filtered out.
1392
- *
1393
- * Returns an empty result (`entries: []`, snapshot/handoff/events undefined)
1394
- * when there's nothing to surface (no memories AND no snapshot AND no handoff
1395
- * AND no recent events).
1396
- */
1397
- export async function getContext(ctx, opts = {}) {
1398
- const pinnedOnly = opts.pinnedOnly === true;
1399
- const budget = opts.budget ?? 1500;
1400
- const limit = opts.limit ?? Number.POSITIVE_INFINITY;
1401
- const includeRecent = opts.includeRecent ?? 0;
1402
- const activeScope = opts.scope ?? '';
1403
- if (budget <= 0) {
1404
- return { entries: [], tokens: 0 };
1405
- }
1406
- // Pinned-only path is allowed against an un-initialised local store (the
1407
- // UserPromptSubmit hook can run in directories without a .hippo). Non-pinned
1408
- // path requires an initialised local store; callers should check first.
1409
- const hasLocal = isInitialized(ctx.hippoRoot);
1410
- const query = (opts.q ?? '').trim() || '*';
1411
- const globalRoot = getGlobalRoot();
1412
- const hasGlobal = isInitialized(globalRoot);
1413
- // v39 memory scope isolation (docs/plans/2026-07-01-memory-scope-isolation.md).
1414
- // S2: envelope-filter parity with api.recall for AMBIENT context - private
1415
- // scopes and quarantine buckets never inject. `requested` is deliberately
1416
- // undefined: opts.scope is the scope-TAG boost input here, not an
1417
- // envelope-scope request (api.recall's exact-match semantics don't apply).
1418
- // S3: origin partition - other-project memories are excluded unless the
1419
- // caller explicitly asks for them (crossProject) or isolation is disabled.
1420
- const config = loadConfig(ctx.hippoRoot);
1421
- const isolationEnabled = config.contextProjectIsolation !== false;
1422
- const currentProjectName = opts.currentProject ?? resolveProjectIdentity(process.cwd()).name;
1423
- const includeCrossProject = opts.crossProject === true || !isolationEnabled;
1424
- const ambientAdmit = (e) => ambientAdmitEntry(e, currentProjectName, includeCrossProject);
1425
- // Superseded rows never inject, and ambientAdmitEntry regex-scans content for
1426
- // secrets, so WHICH rows reach this predicate is what loadAmbientEntries cares
1427
- // about below.
1428
- const admit = (e) => !e.superseded_by && ambientAdmit(e);
1429
- // Tenant-scoped loads (v1.11.1 lesson: NEVER resolveTenantId({}) here).
1430
- let localEntries = hasLocal
1431
- ? loadAmbientEntries(ctx.hippoRoot, ctx.tenantId, pinnedOnly, includeRecent, admit)
1432
- : [];
1433
- let globalEntries = hasGlobal
1434
- ? loadAmbientEntries(globalRoot, ctx.tenantId, pinnedOnly, includeRecent, admit)
1435
- : [];
1436
- // Computed below, after markRetrieved runs, so avgStrength reflects the
1437
- // post-retrieval strengths rather than a stale pre-mutation snapshot.
1438
- let ambientState;
1439
- // DF1 T2: bounded read — an orphaned snapshot (no later pre-compact
1440
- // superseded it, no session-end closed it) must age out of this ambient
1441
- // surface instead of injecting into every future prompt forever. Owner
1442
- // reads (opts.currentSessionId matches the snapshot's session_id) stay
1443
- // unbounded; see loadFreshActiveTaskSnapshot's own doc comment for the
1444
- // exact null/empty-id matching rules.
1445
- const rowScope = (r) => r?.scope ?? null;
1446
- const rawActiveSnapshot = hasLocal
1447
- ? loadFreshActiveTaskSnapshot(ctx.hippoRoot, ctx.tenantId, {
1448
- sessionId: opts.currentSessionId,
1449
- })
1450
- : null;
1451
- // W1: pre-existing leak; same `requested: undefined` ambientAdmitEntry
1452
- // already uses when it scope-filters memory rows above.
1453
- const activeSnapshot = rawActiveSnapshot && passesScopeFilterForRecall(rowScope(rawActiveSnapshot), undefined)
1454
- ? rawActiveSnapshot
1455
- : null;
1456
- // Key on the RAW snapshot: a scope-hidden active session must not fall through to another session's ambient handoff.
1457
- const rawSessionHandoff = !hasLocal
1458
- ? null
1459
- : rawActiveSnapshot?.session_id
1460
- ? loadLatestHandoff(ctx.hippoRoot, ctx.tenantId, rawActiveSnapshot.session_id)
1461
- : loadLatestHandoff(ctx.hippoRoot, ctx.tenantId, undefined, {
1462
- unfinishedOnly: true,
1463
- maxAgeMs: SNAPSHOT_AMBIENT_MAX_AGE_MS,
1464
- // codex P2: admit scope in SQL so a newer denied row can't hide an older eligible one before LIMIT 1.
1465
- scopeFilter: 'default-deny',
1466
- });
1467
- const sessionHandoff = rawSessionHandoff && passesScopeFilterForRecall(rowScope(rawSessionHandoff), undefined)
1468
- ? rawSessionHandoff
1469
- : null;
1470
- // Raw session id here too: each event is admitted on its own scope, same as recall and the CLI.
1471
- const recentSessionEvents = hasLocal && rawActiveSnapshot?.session_id
1472
- ? listSessionEvents(ctx.hippoRoot, ctx.tenantId, {
1473
- session_id: rawActiveSnapshot.session_id,
1474
- limit: 5,
1475
- }).filter((e) => passesScopeFilterForRecall(rowScope(e), undefined))
1476
- : [];
1477
- if (localEntries.length === 0 &&
1478
- globalEntries.length === 0 &&
1479
- !activeSnapshot &&
1480
- !sessionHandoff &&
1481
- recentSessionEvents.length === 0) {
1482
- return { entries: [], tokens: 0 };
1483
- }
1484
- let selectedItems = [];
1485
- let totalTokens = 0;
1486
- if (pinnedOnly) {
1487
- // loadConfig is safe even when local isn't initialised — returns defaults.
1488
- const pinnedCfg = loadConfig(ctx.hippoRoot);
1489
- if (!pinnedCfg.pinnedInject.enabled) {
1490
- return { entries: [], tokens: 0 };
1491
- }
1492
- // Effective budget: explicit opts.budget wins over config.
1493
- const effBudget = opts.budget !== undefined ? budget : pinnedCfg.pinnedInject.budget;
1494
- const nowP = evalNow(); // honors HIPPO_FAKE_NOW (eval-only; see ablation.ts)
1495
- const selectedIds = new Set();
1496
- let usedP = 0;
1497
- // Pinned entries are explicit user intent, the recent-N list an automatic
1498
- // backfill. Both loops share ONE budget and the recent loop runs first, so
1499
- // pins are ranked here and reserve their share before it can spend.
1500
- const pinnedLocal = localEntries.filter((e) => e.pinned);
1501
- const pinnedGlobal = globalEntries.filter((e) => e.pinned);
1502
- const rankedPinned = [
1503
- ...pinnedLocal.map((e) => ({ entry: e, isGlobal: false })),
1504
- ...pinnedGlobal.map((e) => ({ entry: e, isGlobal: true })),
1505
- ]
1506
- .map(({ entry, isGlobal }) => {
1507
- const scopeSig = scopeMatch(entry.tags, activeScope);
1508
- const sBst = scopeSig === 1 ? 1.5 : scopeSig === -1 ? 0.5 : 1.0;
1509
- return {
1510
- entry,
1511
- score: calculateStrength(entry, nowP) * (isGlobal ? 1 / 1.2 : 1) * sBst,
1512
- tokens: estimateTokens(entry.content),
1513
- isGlobal,
1514
- };
1515
- })
1516
- .sort(compareScoredResults);
1517
- // Mirror the pinned admission loop's own `continue`-not-`break`
1518
- // semantics (further down) so the reserve equals what that loop will
1519
- // actually admit -- a big pin near the front should not block smaller
1520
- // pins behind it from reserving their share too.
1521
- // Dedupe by id: `syncGlobalToLocal` copies global rows into the local
1522
- // store preserving `entry.id`, so a synced pin appears in BOTH
1523
- // `pinnedLocal` and `pinnedGlobal` and would otherwise reserve its cost
1524
- // twice. The admission loop already dedupes via `selectedIds`; the
1525
- // reserve has to mirror that or it silently starves recents of budget a
1526
- // single returned pin never needed.
1527
- let pinnedReserve = 0;
1528
- const reservedIds = new Set();
1529
- for (const r of rankedPinned) {
1530
- if (reservedIds.has(r.entry.id))
1531
- continue;
1532
- if (pinnedReserve + r.tokens <= effBudget) {
1533
- pinnedReserve += r.tokens;
1534
- reservedIds.add(r.entry.id);
1535
- }
1536
- }
1537
- // Known, accepted tradeoff: a pin that also lands in the recent-N slice
1538
- // is counted once in `pinnedReserve` (here) AND admitted again by the
1539
- // recent loop below, so a little budget goes unused (`recentBudget` is
1540
- // more conservative than it needs to be in that case). That only
1541
- // under-fills recents slightly -- it never displaces a pin -- so it is
1542
- // the safe direction and is not worth extra bookkeeping to recover.
1543
- const recentBudget = Math.max(0, effBudget - pinnedReserve);
1544
- if (includeRecent > 0) {
1545
- const recent = [
1546
- ...localEntries.map((entry) => ({ entry, isGlobal: false })),
1547
- ...globalEntries.map((entry) => ({ entry, isGlobal: true })),
1548
- ]
1549
- // T2 (src/compare.ts) note: this already carries an explicit
1550
- // per-instance tiebreak (created desc -> id localeCompare) and is
1551
- // deliberately left as-is rather than routed through
1552
- // compareEntryIdentity. `created` reflects ingest order, so it is
1553
- // cross-ingest stable at ms granularity; the residual is honest,
1554
- // not silently ignored — rows created in the same millisecond fall
1555
- // to `id.localeCompare`, which is per-instance random (id is
1556
- // crypto.randomUUID()), so this listing is per-instance-
1557
- // deterministic but NOT cross-ingest-stable under same-ms
1558
- // collisions.
1559
- .sort((a, b) => {
1560
- const byCreated = Date.parse(b.entry.created) - Date.parse(a.entry.created);
1561
- return byCreated !== 0 ? byCreated : b.entry.id.localeCompare(a.entry.id);
1562
- })
1563
- // DF3 (docs/plans/2026-08-23-df3-include-recent-quality-floor.md):
1564
- // filter before slice, not after — the caller asked for N recent
1565
- // *useful* entries, so a junk row must be skipped and backfilled
1566
- // past, not counted against the N. Skip-only: no mutation, no audit
1567
- // row, nothing becomes unrecoverable.
1568
- //
1569
- // `entry.pinned ||` bypass IS needed here (codex review finding,
1570
- // corrects the earlier claim in this comment that it wasn't): under
1571
- // budget pressure, a pinned entry that fails the heuristic gets
1572
- // dropped from this recent slice, and an unpinned entry backfills
1573
- // into its slot and consumes `usedP` in the loop below. By the time
1574
- // the pinned block runs (further down), the budget it needed is
1575
- // already spent, so it hits `continue` and the pinned entry is
1576
- // omitted entirely — the pinned block is NOT a safety net once the
1577
- // recent loop has already spent the shared budget.
1578
- .filter(({ entry }) => entry.pinned || isContentWorthStoring(entry.content))
1579
- .slice(0, includeRecent)
1580
- .map(({ entry, isGlobal }) => ({
1581
- entry,
1582
- score: calculateStrength(entry, nowP) * (isGlobal ? 1 / 1.2 : 1),
1583
- tokens: estimateTokens(entry.content),
1584
- isGlobal,
1585
- }));
1586
- for (const r of recent) {
1587
- if (selectedIds.has(r.entry.id))
1588
- continue;
1589
- if (usedP + r.tokens > recentBudget)
1590
- continue;
1591
- selectedItems.push(r);
1592
- selectedIds.add(r.entry.id);
1593
- usedP += r.tokens;
1594
- }
1595
- }
1596
- if (pinnedLocal.length === 0 &&
1597
- pinnedGlobal.length === 0 &&
1598
- selectedItems.length === 0) {
1599
- return { entries: [], tokens: 0 };
1600
- }
1601
- for (const r of rankedPinned) {
1602
- if (selectedIds.has(r.entry.id))
1603
- continue;
1604
- if (usedP + r.tokens > effBudget)
1605
- continue;
1606
- selectedItems.push(r);
1607
- selectedIds.add(r.entry.id);
1608
- usedP += r.tokens;
1609
- }
1610
- totalTokens = usedP;
1611
- }
1612
- else if (query === '*') {
1613
- // No query: return strongest memories by strength, up to budget.
1614
- const now = evalNow(); // honors HIPPO_FAKE_NOW (eval-only; see ablation.ts)
1615
- const localRanked = localEntries
1616
- .map((e) => ({
1617
- entry: e,
1618
- score: calculateStrength(e, now),
1619
- tokens: estimateTokens(e.content),
1620
- isGlobal: false,
1621
- }))
1622
- .sort(compareScoredResults);
1623
- const globalRanked = globalEntries
1624
- .map((e) => ({
1625
- entry: e,
1626
- score: calculateStrength(e, now) * (1 / 1.2),
1627
- tokens: estimateTokens(e.content),
1628
- isGlobal: true,
1629
- }))
1630
- .sort(compareScoredResults);
1631
- const combined = [...localRanked, ...globalRanked].sort(compareScoredResults);
1632
- let used = 0;
1633
- for (const r of combined) {
1634
- if (used + r.tokens > budget)
1635
- continue;
1636
- selectedItems.push(r);
1637
- used += r.tokens;
1638
- }
1639
- totalTokens = used;
1640
- }
1641
- else {
1642
- // Real query: hybrid search (global + local) or physics+hybrid (local only).
1643
- let results;
1644
- if (hasGlobal) {
1645
- // searchBothHybrid loads from the store roots itself, so the ambient
1646
- // filter above never saw its candidates. Admission runs INSIDE the
1647
- // search via the opt-in entryFilter, BEFORE ranking, cross-store
1648
- // content-dedupe, and budgeting - a post-filter instead would let an
1649
- // excluded row saturate the budget (codex rounds 1+3) or shadow its
1650
- // admitted duplicate in the dedupe pass (codex round 4). Recall paths
1651
- // never set entryFilter, so their behavior is unchanged.
1652
- const merged = await searchBothHybrid(query, ctx.hippoRoot, globalRoot, {
1653
- budget,
1654
- scope: activeScope,
1655
- tenantId: ctx.tenantId,
1656
- entryFilter: ambientAdmit,
1657
- });
1658
- const localIndex = loadIndex(ctx.hippoRoot);
1659
- results = merged.map((r) => ({
1660
- entry: r.entry,
1661
- score: r.score,
1662
- tokens: r.tokens,
1663
- isGlobal: !localIndex.entries[r.entry.id],
1664
- }));
1665
- }
1666
- else {
1667
- const ctxConfig = loadConfig(ctx.hippoRoot);
1668
- const usePhysicsCtx = ctxConfig.physics?.enabled !== false;
1669
- const ctxResults = usePhysicsCtx
1670
- ? await physicsSearch(query, localEntries, {
1671
- budget,
1672
- hippoRoot: ctx.hippoRoot,
1673
- physicsConfig: ctxConfig.physics,
1674
- scope: activeScope,
1675
- })
1676
- : await hybridSearch(query, localEntries, {
1677
- budget,
1678
- hippoRoot: ctx.hippoRoot,
1679
- scope: activeScope,
1680
- });
1681
- results = ctxResults.map((r) => ({
1682
- entry: r.entry,
1683
- score: r.score,
1684
- tokens: r.tokens,
1685
- isGlobal: false,
1686
- }));
1687
- }
1688
- selectedItems = results;
1689
- totalTokens = results.reduce((sum, r) => sum + r.tokens, 0);
1690
- // A5 H4: emit recall audit row for context-mode searches (matches the
1691
- // 'recall' op emitted by api.recall for parity). pinnedOnly + '*' fallback
1692
- // never hit the search engines, so they don't emit (matches cmdContext).
1693
- const ctxRecallMetadata = {
1694
- query: query.slice(0, 200),
1695
- results: selectedItems.length,
1696
- mode: 'context',
1697
- };
1698
- if (hasLocal) {
1699
- const localDb = openHippoDb(ctx.hippoRoot);
1700
- try {
1701
- appendAuditEvent(localDb, {
1702
- tenantId: ctx.tenantId,
1703
- actor: ctx.actor.subject,
1704
- op: 'recall',
1705
- metadata: ctxRecallMetadata,
1706
- });
1707
- }
1708
- finally {
1709
- closeHippoDb(localDb);
1710
- }
1711
- }
1712
- if (hasGlobal) {
1713
- const globalDb = openHippoDb(globalRoot);
1714
- try {
1715
- appendAuditEvent(globalDb, {
1716
- tenantId: ctx.tenantId,
1717
- actor: ctx.actor.subject,
1718
- op: 'recall',
1719
- metadata: ctxRecallMetadata,
1720
- });
1721
- }
1722
- finally {
1723
- closeHippoDb(globalDb);
1724
- }
1725
- }
1726
- }
1727
- if (limit < selectedItems.length) {
1728
- selectedItems = selectedItems.slice(0, limit);
1729
- totalTokens = selectedItems.reduce((sum, r) => sum + r.tokens, 0);
1730
- }
1731
- // v39: annotate every returned entry with its origin and how it relates to
1732
- // the active project, so renderers can demarcate cross-project inclusions.
1733
- selectedItems = selectedItems.map((r) => ({
1734
- ...r,
1735
- origin: r.entry.origin_project ?? null,
1736
- category: classifyOriginProject(r.entry.origin_project, currentProjectName),
1737
- }));
1738
- if (selectedItems.length === 0 &&
1739
- !activeSnapshot &&
1740
- !sessionHandoff &&
1741
- recentSessionEvents.length === 0) {
1742
- // LC1 F5 fix: this bare early-return used to skip tracing entirely — a
1743
- // query that found nothing is exactly the coverage-gap signal Track LC
1744
- // needs. Write an empty trace (result_count 0, no result rows) so it
1745
- // lands in the training corpus. Never touches localIndex/
1746
- // last_retrieval_ids/last_trace_id — by construction it can't desync
1747
- // (mirrors the CLI zero-result path). Skipped under pinnedOnly (hot
1748
- // path stays read-only, same reason it skips markRetrieved). Fail-soft
1749
- // internally; never throws.
1750
- if (!pinnedOnly) {
1751
- // No snapshot in this branch, so the caller's own id is the only session to stamp.
1752
- writeRecallTraceAtRoot(ctx.hippoRoot, {
1753
- tenantId: ctx.tenantId,
1754
- sessionId: opts.currentSessionId || null,
1755
- pipeline: 'context',
1756
- query,
1757
- explainMode: false,
1758
- results: [],
1759
- });
1760
- }
1761
- return { entries: [], tokens: 0 };
1762
- }
1763
- // pinnedOnly is the UserPromptSubmit hot path — read-only so pinned
1764
- // memories don't inflate retrieval_count or extend half_life by 2 days per
1765
- // turn over a long session.
1766
- if (!pinnedOnly) {
1767
- const toUpdate = selectedItems.map((s) => s.entry);
1768
- const updatedEntries = markRetrieved(toUpdate);
1769
- const localIndex = loadIndex(ctx.hippoRoot);
1770
- // EVAL-ONLY ablation (see ablation.ts): under the recall flag,
1771
- // markRetrieved returns unmutated entries (ids preserved for outcome
1772
- // attribution) and persistence is skipped (identical-row writes still
1773
- // refresh updated_at / mirrors / DAG dirty flags).
1774
- if (!isRecallBoostAblated()) {
1775
- for (const u of updatedEntries) {
1776
- const targetRoot = localIndex.entries[u.id]
1777
- ? ctx.hippoRoot
1778
- : hasGlobal
1779
- ? globalRoot
1780
- : ctx.hippoRoot;
1781
- writeEntry(targetRoot, u);
1782
- }
1783
- }
1784
- localIndex.last_retrieval_ids = updatedEntries.map((u) => u.id);
1785
- // LC1 F1 structural fix (docs/plans/2026-08-02-lc1-recall-trace-persistence.md):
1786
- // write the trace FIRST — post-limit, post-annotation `selectedItems`
1787
- // actually returned, on a fresh short-lived connection (the audit
1788
- // handles above ~2410 are already closed by this point, matching this
1789
- // block's own per-call-handle convention: writeEntry, saveIndex) — then
1790
- // fold the resulting id into `localIndex` so the SAME `saveIndex` call
1791
- // below persists last_retrieval_ids + last_trace_id atomically.
1792
- // LOCKSTEP INVARIANT: last_trace_id must only ever advance together
1793
- // with last_retrieval_ids; a two-connection stamp-then-clear design
1794
- // could desync them on a crash between writes. A failed trace write
1795
- // (traceId null) sets last_trace_id to null rather than leaving the
1796
- // OLD id pointing at ids that are about to be overwritten. Fail-soft
1797
- // internally; never throws.
1798
- const traceId = writeRecallTraceAtRoot(ctx.hippoRoot, {
1799
- tenantId: ctx.tenantId,
1800
- sessionId: opts.currentSessionId || activeSnapshot?.session_id || null,
1801
- pipeline: 'context',
1802
- query,
1803
- explainMode: false,
1804
- results: selectedItems.map((s) => ({
1805
- memoryId: s.entry.id,
1806
- score: s.score,
1807
- })),
1808
- });
1809
- localIndex.last_trace_id = traceId !== null ? String(traceId) : null;
1810
- saveIndex(ctx.hippoRoot, localIndex);
1811
- updateStats(ctx.hippoRoot, { recalled: selectedItems.length });
1812
- // Replace selectedItems entries with markRetrieved-updated copies so
1813
- // the returned ContextResult reflects post-recall state.
1814
- selectedItems = selectedItems.map((s) => ({
1815
- ...s,
1816
- entry: updatedEntries.find((u) => u.id === s.entry.id) ?? s.entry,
1817
- }));
1818
- // Overlay by id (no re-read) so avgStrength reflects post-retrieval strength.
1819
- if (config.ambient.enabled) {
1820
- const updatedById = new Map(updatedEntries.map((u) => [u.id, u]));
1821
- const overlaid = [...localEntries, ...globalEntries].map((e) => updatedById.get(e.id) ?? e);
1822
- if (overlaid.length > 0) {
1823
- ambientState = computeAmbientState(overlaid);
1824
- }
1825
- }
1826
- }
1827
- return {
1828
- entries: selectedItems,
1829
- tokens: totalTokens,
1830
- activeSnapshot: activeSnapshot ?? undefined,
1831
- sessionHandoff: sessionHandoff ?? undefined,
1832
- recentEvents: recentSessionEvents.length > 0 ? recentSessionEvents : undefined,
1833
- ambientState,
1834
- };
1835
- }
1836
- const DEFAULT_SLEEP_PHASES = {
1837
- consolidate,
1838
- deduplicateStore,
1839
- auditMemories,
1840
- autoShare,
1841
- loadAllEntries,
1842
- deleteEntry,
1843
- computeAmbientState,
1844
- loadConfig,
1845
- loadPendingExtractionTenants,
1846
- extractGraph,
1847
- };
1848
- export async function sleep(ctx, opts = {}) {
1849
- const dryRun = Boolean(opts.dryRun);
1850
- // v1.12.2: resolve phase dependencies, allowing test-only `__phases`
1851
- // override to inject deterministic throws for mid-phase failure coverage.
1852
- const phases = { ...DEFAULT_SLEEP_PHASES, ...(opts.__phases ?? {}) };
1853
- // v1.11.5: phase counters for the consolidate audit emit (in finally).
1854
- // Accumulated as each phase completes so partial-failure paths still report
1855
- // accurate "what got done before the failure" data.
1856
- let consolidationCount = 0;
1857
- let dedupCount = 0;
1858
- let auditDeletedCount = 0;
1859
- let ambientTotal = 0;
1860
- let phaseError = null;
1861
- let graphSnapshotError = null;
1862
- let result = null;
1863
- try {
1864
- // Snapshot dirty tenants BEFORE any memory-deleting phase (consolidate /
1865
- // dedup / audit). The graph_extraction_queue rows are FK'd to mirror
1866
- // memories with ON DELETE CASCADE, so a phase that deletes a queued mirror
1867
- // (e.g. dedup removing a near-duplicate superseding decision) would drop the
1868
- // tenant from a drain-time load and leave its graph stale (codex P1). The
1869
- // MAX(id) watermark captured here stays valid: arrivals during sleep get a
1870
- // higher id and remain pending.
1871
- //
1872
- // Fail-soft (codex P2): a queue-read failure here must NOT abort core sleep
1873
- // (consolidation / dedup / audit run regardless). On failure, skip graph
1874
- // refresh this sleep (recovered next sleep) and surface a detail once
1875
- // `result` exists (Phase 6).
1876
- let dirtyTenants = [];
1877
- if (!dryRun) {
1878
- try {
1879
- dirtyTenants = phases.loadPendingExtractionTenants(ctx.hippoRoot);
1880
- }
1881
- catch (snapErr) {
1882
- // SAFETY: this is a best-effort log message only; property access on
1883
- // any JS value is safe (undefined if absent), preserving the existing
1884
- // lenient formatting even when something non-Error was thrown.
1885
- graphSnapshotError = snapErr.message;
1886
- }
1887
- }
1888
- // Phase 1: Consolidation.
1889
- const consolidateResult = await phases.consolidate(ctx.hippoRoot, { dryRun });
1890
- consolidationCount = consolidateResult.semanticCreated + consolidateResult.merged;
1891
- result = {
1892
- active: consolidateResult.decayed,
1893
- removed: consolidateResult.removed,
1894
- mergedEpisodic: consolidateResult.merged,
1895
- newSemantic: consolidateResult.semanticCreated,
1896
- dryRun,
1897
- details: consolidateResult.details,
1898
- };
1899
- if (dryRun)
1900
- return result;
1901
- // Phase 2: Dedup (post-consolidate near-duplicate cleanup).
1902
- const dedupResult = phases.deduplicateStore(ctx.hippoRoot);
1903
- dedupCount = dedupResult.removed;
1904
- if (dedupResult.removed > 0) {
1905
- const semDups = dedupResult.pairs.filter((p) => p.keptLayer === 'semantic' && p.removedLayer === 'semantic').length;
1906
- const epiDups = dedupResult.pairs.filter((p) => p.keptLayer === 'episodic' && p.removedLayer === 'episodic').length;
1907
- const crossDups = dedupResult.pairs.filter((p) => p.keptLayer !== p.removedLayer).length;
1908
- result.deduped = {
1909
- removed: dedupResult.removed,
1910
- semDups,
1911
- epiDups,
1912
- crossDups,
1913
- };
1914
- }
1915
- // Phase 3: Quality audit (remove junk, report warnings).
1916
- const allEntries = phases.loadAllEntries(ctx.hippoRoot);
1917
- const auditOut = phases.auditMemories(allEntries);
1918
- if (auditOut.issues.length > 0) {
1919
- const errors = auditOut.issues.filter((i) => i.severity === 'error');
1920
- const warnings = auditOut.issues.filter((i) => i.severity === 'warning');
1921
- if (errors.length > 0) {
1922
- for (const issue of errors) {
1923
- phases.deleteEntry(ctx.hippoRoot, issue.memoryId);
1924
- }
1925
- }
1926
- auditDeletedCount = errors.length;
1927
- if (errors.length > 0 || warnings.length > 0) {
1928
- result.audit = {
1929
- errorsRemoved: errors.length,
1930
- warningCount: warnings.length,
1931
- };
1932
- }
1933
- }
1934
- // Phase 4: Auto-share high-transfer-score memories to global.
1935
- if (!opts.noShare) {
1936
- const sleepConfig = phases.loadConfig(ctx.hippoRoot);
1937
- if (sleepConfig.autoShareOnSleep) {
1938
- // v1.25.0: surface the secret-veto skip count (v39 follow-up #2) so
1939
- // the veto is observable instead of silent.
1940
- // AT1: rejectedSkipped is autoShare's sibling counter for candidates
1941
- // the global store's rejection tombstone refused (threaded the same
1942
- // way as secretSkipped just below).
1943
- const autoShareStats = { secretSkipped: 0, rejectedSkipped: 0 };
1944
- const shared = phases.autoShare(ctx.hippoRoot, { minScore: 0.6, stats: autoShareStats });
1945
- if (shared.length > 0) {
1946
- result.shared = shared.length;
1947
- }
1948
- if (autoShareStats.secretSkipped > 0) {
1949
- result.secretSkipped = autoShareStats.secretSkipped;
1950
- }
1951
- if (autoShareStats.rejectedSkipped > 0) {
1952
- result.rejectedSkipped = autoShareStats.rejectedSkipped;
1953
- }
1954
- }
1955
- }
1956
- // Phase 5: Post-sleep ambient state summary.
1957
- const postSleepConfig = phases.loadConfig(ctx.hippoRoot);
1958
- if (postSleepConfig.ambient.enabled) {
1959
- const postSleepEntries = phases.loadAllEntries(ctx.hippoRoot).filter((e) => !e.superseded_by);
1960
- if (postSleepEntries.length > 0) {
1961
- result.ambient = phases.computeAmbientState(postSleepEntries);
1962
- ambientTotal = result.ambient.totalMemories;
1963
- }
1964
- }
1965
- // Phase 6: Graph extraction drain (E3 sleep enqueue-hook). Rebuild the
1966
- // entity/relation graph for every tenant marked dirty (by markGraphDirty)
1967
- // since the last sleep, so `recall --hops` + cross-object `references` edges
1968
- // run on fresh data without a manual `hippo graph extract`. Fully
1969
- // fault-isolated: the consolidation work above has already committed, so a
1970
- // failure here must never abort sleep; a per-tenant extract failure leaves
1971
- // that tenant's queue items pending for the next sleep. (Skipped under
1972
- // dryRun via the early return above.)
1973
- try {
1974
- if (graphSnapshotError) {
1975
- // The dirty-tenant snapshot failed (codex P2 fail-soft). Core sleep
1976
- // already succeeded; surface the skipped graph refresh as a detail.
1977
- result.details = [
1978
- ...(result.details ?? []),
1979
- `graph: dirty-tenant snapshot failed (skipped graph refresh): ${graphSnapshotError}`,
1980
- ];
1981
- }
1982
- let gTenants = 0;
1983
- let gEntities = 0;
1984
- let gRelations = 0;
1985
- // dirtyTenants was snapshotted before the memory-deleting phases above.
1986
- for (const { tenantId, maxPendingId } of dirtyTenants) {
1987
- try {
1988
- const ext = phases.extractGraph(ctx.hippoRoot, tenantId);
1989
- // Count the rebuild as soon as it succeeds — it happened regardless of
1990
- // the drain-mark below.
1991
- gTenants += 1;
1992
- gEntities += ext.entities;
1993
- gRelations += ext.relations;
1994
- // Watermark drain: mark processed only items enqueued before this
1995
- // rebuild started (id <= maxPendingId). Arrivals during the rebuild
1996
- // keep pending status and are caught next sleep; rows whose mirror was
1997
- // cascade-deleted earlier this sleep are already gone (no-op).
1998
- markPendingProcessedUpTo(ctx.hippoRoot, tenantId, maxPendingId);
1999
- }
2000
- catch (tenantErr) {
2001
- // SAFETY: this is a best-effort log message only; property access
2002
- // on any JS value is safe (undefined if absent), preserving the
2003
- // existing lenient formatting even when something non-Error was thrown.
2004
- result.details = [
2005
- ...(result.details ?? []),
2006
- `graph: extract failed for a dirty tenant (left pending): ${tenantErr.message}`,
2007
- ];
2008
- }
2009
- }
2010
- if (gTenants > 0) {
2011
- result.graph = { tenants: gTenants, entities: gEntities, relations: gRelations };
2012
- }
2013
- }
2014
- catch (graphErr) {
2015
- // SAFETY: this is a best-effort log message only; property access on
2016
- // any JS value is safe (undefined if absent), preserving the existing
2017
- // lenient formatting even when something non-Error was thrown.
2018
- result.details = [
2019
- ...(result.details ?? []),
2020
- `graph: drain phase failed (skipped): ${graphErr.message}`,
2021
- ];
2022
- }
2023
- return result;
2024
- }
2025
- catch (err) {
2026
- // SAFETY: phaseError is read via phaseError.message / (phaseError !==
2027
- // null) below, both safe even if a non-Error was thrown; this mirrors
2028
- // the existing lenient (err as Error) pattern used throughout this catch chain.
2029
- phaseError = err;
2030
- throw err;
2031
- }
2032
- finally {
2033
- // v1.11.5: emit one 'consolidate' audit_log row per api.sleep invocation,
2034
- // with phase counters in metadata. Closes the CLI/MCP parity gap that T6
2035
- // fixed for cmdOutcome (Episode A follow-up). In finally so partial-failure
2036
- // paths still emit; `partial: true` + errorMessage flag the failure.
2037
- // Dedicated handle for this emit only (phase helpers above each open their
2038
- // own handle via hippoRoot — SQLite single-writer makes parallel handles
2039
- // safe for the read-heavy phases).
2040
- //
2041
- // TODO(v1.12.0 + A5 v2): the audit row is tagged with ctx.tenantId but
2042
- // api.sleep is host-wide (cross-tenant dedup is intentional). When
2043
- // /v1/sleep moves off loopback-only, either tag with a synthetic "host"
2044
- // tenant or scope api.sleep per-tenant. Independent-review-critic flag,
2045
- // v1.11.5 ship.
2046
- //
2047
- // Error preservation: if openHippoDb or appendAuditEvent throws here, we
2048
- // do NOT let it replace the original phaseError (independent-review HIGH:
2049
- // would mask the underlying consolidation failure). Audit emit failure
2050
- // is logged to stderr but the original throw wins.
2051
- try {
2052
- const db = openHippoDb(ctx.hippoRoot);
2053
- try {
2054
- const sleepAuditMetadata = {
2055
- consolidationCount,
2056
- dedupCount,
2057
- auditDeletedCount,
2058
- ambientTotal,
2059
- dryRun,
2060
- noShare: opts.noShare ?? false,
2061
- partial: phaseError !== null,
2062
- triggeredByTenant: ctx.tenantId, // preserve for audit forensics
2063
- };
2064
- if (phaseError)
2065
- sleepAuditMetadata.errorMessage = phaseError.message;
2066
- appendAuditEvent(db, {
2067
- tenantId: '__host__',
2068
- actor: ctx.actor.subject,
2069
- op: 'consolidate',
2070
- metadata: { ...sleepAuditMetadata },
2071
- });
2072
- }
2073
- finally {
2074
- closeHippoDb(db);
2075
- }
2076
- }
2077
- catch (auditErr) {
2078
- // Audit emit failure must NOT mask the original phaseError. Log to
2079
- // stderr so the secondary failure is observable but does not throw.
2080
- // This guards the case where consolidation AND audit-emit fail in the
2081
- // same invocation against the same DB (correlated: same disk, same
2082
- // schema state) — losing the original error makes diagnosis much harder.
2083
- // SAFETY: this is a best-effort log message only; property access on
2084
- // any JS value is safe (undefined if absent), preserving the existing
2085
- // lenient formatting even when something non-Error was thrown.
2086
- // eslint-disable-next-line no-console
2087
- console.error(`[hippo] api.sleep audit emit failed: ${auditErr.message}`);
2088
- }
2089
- }
2090
- }
2091
- export function outcomeForLastRecall(ctx, good) {
2092
- const idx = loadIndex(ctx.hippoRoot);
2093
- const ids = idx.last_retrieval_ids;
2094
- if (ids.length === 0)
2095
- return { applied: 0, ids: [] };
2096
- // LC1 F1(d) structural fix (docs/plans/2026-08-02-lc1-recall-trace-persistence.md):
2097
- // read the trace id from the SAME `loadIndex` snapshot already in hand
2098
- // (idx.last_trace_id) — a single-snapshot read, not a second DB round
2099
- // trip via a now-deleted readLastTraceId helper. The value is already
2100
- // strict-parsed by buildIndexFromDb's parseLastTraceId (store.ts): every
2101
- // consumer gets a clean positive-integer string or null, never a garbage
2102
- // value that could reach outcome() and INSERT trace_id=0/NaN. null on a
2103
- // fresh store / pre-v40 flow / api.recall-only usage — outcome() skips
2104
- // linkage silently when traceId is undefined.
2105
- const traceId = idx.last_trace_id !== null ? Number(idx.last_trace_id) : null;
2106
- const { applied, appliedIds } = outcome(ctx, ids, good, traceId !== null ? { traceId } : undefined);
2107
- return { applied, ids: appliedIds };
2108
- }
2109
- //# sourceMappingURL=api.js.map