hippo-memory 1.59.0 → 1.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (596) hide show
  1. package/README.md +1 -1
  2. package/dist/ablation.js +9 -27
  3. package/dist/agent-memories/apply.js +4 -1
  4. package/dist/agent-memories/claude-code.js +1 -1
  5. package/dist/agent-memories/codex.js +1 -1
  6. package/dist/agent-memories/gemini.js +1 -1
  7. package/dist/agent-memories/legacy.js +1 -1
  8. package/dist/agent-memories/source.js +1 -1
  9. package/dist/agent-memories/sync.js +8 -3
  10. package/dist/ambient-store.d.ts +14 -0
  11. package/dist/ambient-store.js +90 -0
  12. package/dist/ambient.d.ts +23 -0
  13. package/dist/ambient.js +72 -50
  14. package/dist/api/assemble.d.ts +93 -0
  15. package/dist/api/assemble.js +152 -0
  16. package/dist/api/audit.d.ts +17 -0
  17. package/dist/api/audit.js +23 -0
  18. package/dist/api/auth.d.ts +79 -0
  19. package/dist/api/auth.js +178 -0
  20. package/dist/api/context-types.d.ts +105 -0
  21. package/dist/api/context-types.js +3 -0
  22. package/dist/api/context.d.ts +31 -0
  23. package/dist/api/context.js +705 -0
  24. package/dist/api/dormant.d.ts +30 -0
  25. package/dist/api/dormant.js +140 -0
  26. package/dist/api/drill-down.d.ts +84 -0
  27. package/dist/api/drill-down.js +123 -0
  28. package/dist/api/forget.d.ts +57 -0
  29. package/dist/api/forget.js +87 -0
  30. package/dist/api/goals.d.ts +18 -0
  31. package/dist/api/goals.js +33 -0
  32. package/dist/api/learn.d.ts +31 -0
  33. package/dist/api/learn.js +88 -0
  34. package/dist/api/outcome.d.ts +61 -0
  35. package/dist/api/outcome.js +66 -0
  36. package/dist/api/promote.d.ts +54 -0
  37. package/dist/api/promote.js +203 -0
  38. package/dist/api/quarantine.d.ts +24 -0
  39. package/dist/api/quarantine.js +121 -0
  40. package/dist/api/recall-types.d.ts +390 -0
  41. package/dist/api/recall-types.js +3 -0
  42. package/dist/api/recall.d.ts +36 -0
  43. package/dist/api/recall.js +634 -0
  44. package/dist/api/remember.d.ts +35 -0
  45. package/dist/api/remember.js +43 -0
  46. package/dist/api/sleep.d.ts +136 -0
  47. package/dist/api/sleep.js +271 -0
  48. package/dist/api/tokens.d.ts +26 -0
  49. package/dist/api/tokens.js +60 -0
  50. package/dist/api/types.d.ts +56 -0
  51. package/dist/api/types.js +38 -0
  52. package/dist/api.d.ts +20 -1260
  53. package/dist/api.js +25 -2711
  54. package/dist/audit.d.ts +3 -0
  55. package/dist/audit.js +7 -3
  56. package/dist/auth.d.ts +45 -4
  57. package/dist/auth.js +125 -48
  58. package/dist/autolearn.js +7 -4
  59. package/dist/capture/command.d.ts +33 -0
  60. package/dist/capture/command.js +264 -0
  61. package/dist/capture/compact.d.ts +44 -0
  62. package/dist/capture/compact.js +354 -0
  63. package/dist/capture/extract.d.ts +21 -0
  64. package/dist/capture/extract.js +464 -0
  65. package/dist/capture/transcript.d.ts +40 -0
  66. package/dist/capture/transcript.js +193 -0
  67. package/dist/capture-error.js +2 -1
  68. package/dist/churn-git.js +4 -2
  69. package/dist/cli/audit.d.ts +3 -0
  70. package/dist/cli/audit.js +159 -0
  71. package/dist/cli/auth.d.ts +2 -0
  72. package/dist/cli/auth.js +171 -0
  73. package/dist/cli/briefs.d.ts +4 -0
  74. package/dist/cli/briefs.js +435 -0
  75. package/dist/cli/card.d.ts +3 -0
  76. package/dist/cli/card.js +333 -0
  77. package/dist/cli/context.d.ts +15 -0
  78. package/dist/cli/context.js +366 -0
  79. package/dist/cli/continuity.d.ts +6 -0
  80. package/dist/cli/continuity.js +445 -0
  81. package/dist/cli/curate.d.ts +19 -0
  82. package/dist/cli/curate.js +555 -0
  83. package/dist/cli/dag.d.ts +5 -0
  84. package/dist/cli/dag.js +177 -0
  85. package/dist/cli/decisions.d.ts +4 -0
  86. package/dist/cli/decisions.js +528 -0
  87. package/dist/cli/eval.d.ts +5 -0
  88. package/dist/cli/eval.js +213 -0
  89. package/dist/cli/explain.d.ts +4 -0
  90. package/dist/cli/explain.js +150 -0
  91. package/dist/cli/goals.d.ts +2 -0
  92. package/dist/cli/goals.js +196 -0
  93. package/dist/cli/hook-blocks.d.ts +18 -0
  94. package/dist/cli/hook-blocks.js +233 -0
  95. package/dist/cli/init.d.ts +2 -0
  96. package/dist/cli/init.js +305 -0
  97. package/dist/cli/maintenance.d.ts +4 -0
  98. package/dist/cli/maintenance.js +179 -0
  99. package/dist/cli/playbooks.d.ts +4 -0
  100. package/dist/cli/playbooks.js +556 -0
  101. package/dist/cli/projects.js +1 -1
  102. package/dist/cli/recall.d.ts +7 -0
  103. package/dist/cli/recall.js +597 -0
  104. package/dist/cli/remember.d.ts +5 -0
  105. package/dist/cli/remember.js +442 -0
  106. package/dist/cli/serve.d.ts +5 -0
  107. package/dist/cli/serve.js +40 -0
  108. package/dist/cli/session-hooks.d.ts +29 -0
  109. package/dist/cli/session-hooks.js +637 -0
  110. package/dist/cli/setup.d.ts +4 -0
  111. package/dist/cli/setup.js +376 -0
  112. package/dist/cli/shared.d.ts +15 -19
  113. package/dist/cli/shared.js +50 -341
  114. package/dist/cli/slack.d.ts +2 -0
  115. package/dist/cli/slack.js +171 -0
  116. package/dist/cli/status.d.ts +18 -0
  117. package/dist/cli/status.js +400 -0
  118. package/dist/cli/transfer.d.ts +10 -0
  119. package/dist/cli/transfer.js +438 -0
  120. package/dist/cli/usage.d.ts +85 -0
  121. package/dist/cli/usage.js +741 -0
  122. package/dist/cli.d.ts +319 -13
  123. package/dist/cli.js +436 -8580
  124. package/dist/client.js +15 -8
  125. package/dist/compaction-record.js +12 -9
  126. package/dist/config.js +12 -11
  127. package/dist/connectors/github/backfill.js +94 -87
  128. package/dist/connectors/github/cli-impl.js +5 -3
  129. package/dist/connectors/github/dlq.js +67 -54
  130. package/dist/connectors/github/ingest.js +34 -35
  131. package/dist/connectors/github/tenant-routing.js +3 -2
  132. package/dist/connectors/github/webhook.js +135 -216
  133. package/dist/connectors/slack/dlq.js +49 -61
  134. package/dist/connectors/slack/ingest.js +55 -48
  135. package/dist/connectors/slack/tenant-routing.js +4 -3
  136. package/dist/connectors/slack/webhook.js +72 -74
  137. package/dist/consolidate/conflicts.d.ts +10 -0
  138. package/dist/consolidate/conflicts.js +178 -0
  139. package/dist/consolidate/decay.d.ts +12 -0
  140. package/dist/consolidate/decay.js +145 -0
  141. package/dist/consolidate/llm-passes.d.ts +3 -0
  142. package/dist/consolidate/llm-passes.js +141 -0
  143. package/dist/consolidate/merge.d.ts +7 -0
  144. package/dist/consolidate/merge.js +251 -0
  145. package/dist/consolidate/physics-pass.d.ts +3 -0
  146. package/dist/consolidate/physics-pass.js +60 -0
  147. package/dist/consolidate/run.d.ts +69 -0
  148. package/dist/consolidate/run.js +76 -0
  149. package/dist/consolidate/sleep.d.ts +18 -0
  150. package/dist/consolidate/sleep.js +209 -0
  151. package/dist/consolidate/traces.d.ts +4 -0
  152. package/dist/consolidate/traces.js +178 -0
  153. package/dist/context-auto.js +7 -11
  154. package/dist/context-render.d.ts +1 -1
  155. package/dist/context-render.js +1 -1
  156. package/dist/customer-notes.d.ts +3 -0
  157. package/dist/customer-notes.js +6 -4
  158. package/dist/dag.js +13 -12
  159. package/dist/dashboard-actions.d.ts +20 -0
  160. package/dist/dashboard-actions.js +88 -0
  161. package/dist/dashboard-params.d.ts +45 -0
  162. package/dist/dashboard-params.js +127 -0
  163. package/dist/dashboard-queries.d.ts +15 -0
  164. package/dist/dashboard-queries.js +355 -0
  165. package/dist/dashboard-snapshot.d.ts +138 -0
  166. package/dist/dashboard-snapshot.js +308 -0
  167. package/dist/dashboard-types.d.ts +163 -0
  168. package/dist/dashboard-types.js +3 -0
  169. package/dist/dashboard.d.ts +6 -7
  170. package/dist/dashboard.js +231 -203
  171. package/dist/db/busy.d.ts +5 -0
  172. package/dist/db/busy.js +24 -0
  173. package/dist/db/continuity.d.ts +5 -0
  174. package/dist/db/continuity.js +145 -0
  175. package/dist/db/meta.d.ts +8 -0
  176. package/dist/db/meta.js +35 -0
  177. package/dist/db/migrate.d.ts +9 -0
  178. package/dist/db/migrate.js +138 -0
  179. package/dist/db/migrations/index.d.ts +5 -0
  180. package/dist/db/migrations/index.js +109 -0
  181. package/dist/db/migrations/types.d.ts +14 -0
  182. package/dist/db/migrations/types.js +2 -0
  183. package/dist/db/migrations/v01.d.ts +3 -0
  184. package/dist/db/migrations/v01.js +38 -0
  185. package/dist/db/migrations/v02.d.ts +3 -0
  186. package/dist/db/migrations/v02.js +21 -0
  187. package/dist/db/migrations/v03.d.ts +3 -0
  188. package/dist/db/migrations/v03.js +22 -0
  189. package/dist/db/migrations/v04.d.ts +3 -0
  190. package/dist/db/migrations/v04.js +28 -0
  191. package/dist/db/migrations/v05.d.ts +3 -0
  192. package/dist/db/migrations/v05.js +21 -0
  193. package/dist/db/migrations/v06.d.ts +3 -0
  194. package/dist/db/migrations/v06.js +25 -0
  195. package/dist/db/migrations/v07.d.ts +3 -0
  196. package/dist/db/migrations/v07.js +13 -0
  197. package/dist/db/migrations/v08.d.ts +3 -0
  198. package/dist/db/migrations/v08.js +8 -0
  199. package/dist/db/migrations/v09.d.ts +3 -0
  200. package/dist/db/migrations/v09.js +13 -0
  201. package/dist/db/migrations/v10.d.ts +3 -0
  202. package/dist/db/migrations/v10.js +17 -0
  203. package/dist/db/migrations/v11.d.ts +3 -0
  204. package/dist/db/migrations/v11.js +15 -0
  205. package/dist/db/migrations/v12.d.ts +3 -0
  206. package/dist/db/migrations/v12.js +11 -0
  207. package/dist/db/migrations/v13.d.ts +3 -0
  208. package/dist/db/migrations/v13.js +15 -0
  209. package/dist/db/migrations/v14.d.ts +3 -0
  210. package/dist/db/migrations/v14.js +66 -0
  211. package/dist/db/migrations/v15.d.ts +3 -0
  212. package/dist/db/migrations/v15.js +42 -0
  213. package/dist/db/migrations/v16.d.ts +3 -0
  214. package/dist/db/migrations/v16.js +61 -0
  215. package/dist/db/migrations/v17.d.ts +3 -0
  216. package/dist/db/migrations/v17.js +46 -0
  217. package/dist/db/migrations/v18.d.ts +3 -0
  218. package/dist/db/migrations/v18.js +60 -0
  219. package/dist/db/migrations/v19.d.ts +3 -0
  220. package/dist/db/migrations/v19.js +28 -0
  221. package/dist/db/migrations/v20.d.ts +3 -0
  222. package/dist/db/migrations/v20.js +42 -0
  223. package/dist/db/migrations/v21.d.ts +3 -0
  224. package/dist/db/migrations/v21.js +16 -0
  225. package/dist/db/migrations/v22.d.ts +3 -0
  226. package/dist/db/migrations/v22.js +82 -0
  227. package/dist/db/migrations/v23.d.ts +3 -0
  228. package/dist/db/migrations/v23.js +48 -0
  229. package/dist/db/migrations/v24.d.ts +3 -0
  230. package/dist/db/migrations/v24.js +72 -0
  231. package/dist/db/migrations/v25.d.ts +3 -0
  232. package/dist/db/migrations/v25.js +46 -0
  233. package/dist/db/migrations/v26.d.ts +3 -0
  234. package/dist/db/migrations/v26.js +23 -0
  235. package/dist/db/migrations/v27.d.ts +3 -0
  236. package/dist/db/migrations/v27.js +57 -0
  237. package/dist/db/migrations/v28.d.ts +3 -0
  238. package/dist/db/migrations/v28.js +38 -0
  239. package/dist/db/migrations/v29.d.ts +3 -0
  240. package/dist/db/migrations/v29.js +78 -0
  241. package/dist/db/migrations/v30.d.ts +3 -0
  242. package/dist/db/migrations/v30.js +92 -0
  243. package/dist/db/migrations/v31.d.ts +3 -0
  244. package/dist/db/migrations/v31.js +74 -0
  245. package/dist/db/migrations/v32.d.ts +3 -0
  246. package/dist/db/migrations/v32.js +102 -0
  247. package/dist/db/migrations/v33.d.ts +3 -0
  248. package/dist/db/migrations/v33.js +104 -0
  249. package/dist/db/migrations/v34.d.ts +3 -0
  250. package/dist/db/migrations/v34.js +93 -0
  251. package/dist/db/migrations/v35.d.ts +3 -0
  252. package/dist/db/migrations/v35.js +98 -0
  253. package/dist/db/migrations/v36.d.ts +3 -0
  254. package/dist/db/migrations/v36.js +98 -0
  255. package/dist/db/migrations/v37.d.ts +3 -0
  256. package/dist/db/migrations/v37.js +219 -0
  257. package/dist/db/migrations/v38.d.ts +3 -0
  258. package/dist/db/migrations/v38.js +277 -0
  259. package/dist/db/migrations/v39.d.ts +3 -0
  260. package/dist/db/migrations/v39.js +59 -0
  261. package/dist/db/migrations/v40.d.ts +3 -0
  262. package/dist/db/migrations/v40.js +74 -0
  263. package/dist/db/migrations/v41.d.ts +3 -0
  264. package/dist/db/migrations/v41.js +43 -0
  265. package/dist/db/migrations/v42.d.ts +3 -0
  266. package/dist/db/migrations/v42.js +41 -0
  267. package/dist/db/migrations/v43.d.ts +3 -0
  268. package/dist/db/migrations/v43.js +67 -0
  269. package/dist/db/migrations/v44.d.ts +3 -0
  270. package/dist/db/migrations/v44.js +28 -0
  271. package/dist/db/migrations/v45.d.ts +3 -0
  272. package/dist/db/migrations/v45.js +30 -0
  273. package/dist/db/migrations/v46.d.ts +3 -0
  274. package/dist/db/migrations/v46.js +25 -0
  275. package/dist/db/migrations/v47.d.ts +3 -0
  276. package/dist/db/migrations/v47.js +17 -0
  277. package/dist/db/migrations/v48.d.ts +3 -0
  278. package/dist/db/migrations/v48.js +10 -0
  279. package/dist/db/migrations/v49.d.ts +3 -0
  280. package/dist/db/migrations/v49.js +31 -0
  281. package/dist/db/migrations/v50.d.ts +3 -0
  282. package/dist/db/migrations/v50.js +67 -0
  283. package/dist/db/migrations/v51.d.ts +3 -0
  284. package/dist/db/migrations/v51.js +14 -0
  285. package/dist/db/migrations/v52.d.ts +3 -0
  286. package/dist/db/migrations/v52.js +7 -0
  287. package/dist/db/open.d.ts +23 -0
  288. package/dist/db/open.js +146 -0
  289. package/dist/db/sqlite.d.ts +21 -0
  290. package/dist/db/sqlite.js +8 -0
  291. package/dist/db/tables.d.ts +7 -0
  292. package/dist/db/tables.js +38 -0
  293. package/dist/db.d.ts +6 -46
  294. package/dist/db.js +5 -3036
  295. package/dist/decisions.d.ts +4 -1
  296. package/dist/decisions.js +9 -7
  297. package/dist/dedupe.js +3 -2
  298. package/dist/delivery-recorder.js +5 -1
  299. package/dist/doctor.js +4 -3
  300. package/dist/dormant.js +1 -0
  301. package/dist/embedding-provider.d.ts +1 -1
  302. package/dist/embedding-provider.js +7 -5
  303. package/dist/embeddings.d.ts +9 -52
  304. package/dist/embeddings.js +47 -298
  305. package/dist/env.d.ts +75 -0
  306. package/dist/env.js +119 -0
  307. package/dist/eval-suite.js +1 -1
  308. package/dist/eval.js +2 -2
  309. package/dist/extract.js +5 -4
  310. package/dist/gated-write.js +3 -1
  311. package/dist/goals.d.ts +3 -1
  312. package/dist/goals.js +18 -0
  313. package/dist/graph/read.d.ts +73 -0
  314. package/dist/graph/read.js +325 -0
  315. package/dist/graph/rows.d.ts +45 -0
  316. package/dist/graph/rows.js +51 -0
  317. package/dist/graph/types.d.ts +83 -0
  318. package/dist/graph/types.js +11 -0
  319. package/dist/graph/write.d.ts +93 -0
  320. package/dist/{graph.js → graph/write.js} +8 -391
  321. package/dist/graph-extract.js +2 -1
  322. package/dist/graph-recall.d.ts +1 -1
  323. package/dist/graph-recall.js +2 -2
  324. package/dist/graph-stream.js +1 -1
  325. package/dist/graph-view.d.ts +1 -1
  326. package/dist/graph-view.js +1 -1
  327. package/dist/half-life-migration.d.ts +1 -1
  328. package/dist/half-life-migration.js +2 -1
  329. package/dist/handoff.js +3 -0
  330. package/dist/hooks/codex-session.d.ts +8 -0
  331. package/dist/hooks/codex-session.js +76 -0
  332. package/dist/hooks/codex-wrapper.d.ts +55 -0
  333. package/dist/hooks/codex-wrapper.js +288 -0
  334. package/dist/hooks/json-hooks.d.ts +63 -0
  335. package/dist/hooks/json-hooks.js +356 -0
  336. package/dist/hooks/opencode.d.ts +50 -0
  337. package/dist/hooks/opencode.js +202 -0
  338. package/dist/hooks/shared.d.ts +54 -0
  339. package/dist/hooks/shared.js +77 -0
  340. package/dist/http-retry.d.ts +2 -0
  341. package/dist/http-retry.js +4 -3
  342. package/dist/http-util.d.ts +3 -0
  343. package/dist/http-util.js +10 -0
  344. package/dist/{importers.d.ts → importers/core.d.ts} +13 -17
  345. package/dist/importers/core.js +141 -0
  346. package/dist/importers/markdown-parse.d.ts +41 -0
  347. package/dist/importers/markdown-parse.js +132 -0
  348. package/dist/importers/markdown.d.ts +3 -0
  349. package/dist/importers/markdown.js +92 -0
  350. package/dist/importers/sources.d.ts +7 -0
  351. package/dist/importers/sources.js +229 -0
  352. package/dist/importers/vault.d.ts +11 -0
  353. package/dist/importers/vault.js +352 -0
  354. package/dist/incidents.d.ts +3 -0
  355. package/dist/incidents.js +8 -5
  356. package/dist/index.d.ts +25 -6
  357. package/dist/index.js +23 -6
  358. package/dist/invalidation.js +2 -1
  359. package/dist/judgment.js +7 -3
  360. package/dist/keyset.d.ts +13 -0
  361. package/dist/keyset.js +8 -0
  362. package/dist/local-embedding.d.ts +13 -0
  363. package/dist/local-embedding.js +165 -0
  364. package/dist/log.d.ts +7 -0
  365. package/dist/log.js +19 -1
  366. package/dist/mcp/admin-tools.d.ts +8 -0
  367. package/dist/mcp/admin-tools.js +116 -0
  368. package/dist/mcp/format.d.ts +27 -0
  369. package/dist/mcp/format.js +135 -0
  370. package/dist/mcp/memory-tools.d.ts +5 -0
  371. package/dist/mcp/memory-tools.js +83 -0
  372. package/dist/mcp/protocol.d.ts +83 -0
  373. package/dist/mcp/protocol.js +55 -0
  374. package/dist/mcp/recall-tools.d.ts +6 -0
  375. package/dist/mcp/recall-tools.js +320 -0
  376. package/dist/mcp/request.d.ts +10 -0
  377. package/dist/mcp/request.js +163 -0
  378. package/dist/mcp/server.d.ts +4 -70
  379. package/dist/mcp/server.js +8 -1172
  380. package/dist/mcp/session-state.d.ts +11 -0
  381. package/dist/mcp/session-state.js +28 -0
  382. package/dist/mcp/stdio.d.ts +8 -0
  383. package/dist/mcp/stdio.js +79 -0
  384. package/dist/mcp/tools.d.ts +11 -0
  385. package/dist/mcp/tools.js +247 -0
  386. package/dist/memory.d.ts +8 -3
  387. package/dist/memory.js +27 -1
  388. package/dist/multihop.d.ts +1 -1
  389. package/dist/multihop.js +2 -1
  390. package/dist/owner-validation.js +2 -1
  391. package/dist/physics-state.js +10 -7
  392. package/dist/policies.d.ts +3 -0
  393. package/dist/policies.js +8 -6
  394. package/dist/postinstall.js +3 -2
  395. package/dist/predictions/planning-fallacy.d.ts +100 -0
  396. package/dist/predictions/planning-fallacy.js +190 -0
  397. package/dist/{predictions.d.ts → predictions/store.d.ts} +7 -102
  398. package/dist/predictions/store.js +434 -0
  399. package/dist/processes.d.ts +3 -0
  400. package/dist/processes.js +8 -5
  401. package/dist/project-briefs.d.ts +3 -0
  402. package/dist/project-briefs.js +7 -5
  403. package/dist/project-identity.d.ts +1 -1
  404. package/dist/project-identity.js +12 -6
  405. package/dist/project-merge.js +3 -1
  406. package/dist/quarantine.d.ts +2 -1
  407. package/dist/quarantine.js +8 -5
  408. package/dist/raw-archive-mirror-cleanup.js +2 -1
  409. package/dist/raw-archive.js +1 -1
  410. package/dist/recall-history.js +3 -2
  411. package/dist/recall-pipeline.d.ts +2 -2
  412. package/dist/recall-pipeline.js +16 -8
  413. package/dist/recall-scope.js +1 -1
  414. package/dist/recall-trace.d.ts +3 -3
  415. package/dist/recall-trace.js +10 -14
  416. package/dist/refine-llm.js +20 -11
  417. package/dist/reject-flow.js +6 -1
  418. package/dist/rerankers/clef.d.ts +2 -2
  419. package/dist/rerankers/clef.js +72 -30
  420. package/dist/rerankers/cross-encoder.js +6 -4
  421. package/dist/rerankers/jev.d.ts +4 -2
  422. package/dist/rerankers/jev.js +24 -19
  423. package/dist/rerankers/llm.d.ts +4 -2
  424. package/dist/rerankers/llm.js +61 -41
  425. package/dist/rerankers/types.d.ts +1 -1
  426. package/dist/salience.js +1 -1
  427. package/dist/same-text.d.ts +2 -0
  428. package/dist/same-text.js +4 -0
  429. package/dist/scheduler.d.ts +1 -0
  430. package/dist/scheduler.js +26 -3
  431. package/dist/scope.js +4 -3
  432. package/dist/search/as-of.d.ts +10 -0
  433. package/dist/search/as-of.js +22 -0
  434. package/dist/search/bm25-search.d.ts +14 -0
  435. package/dist/search/bm25-search.js +43 -0
  436. package/dist/search/bm25.d.ts +15 -0
  437. package/dist/search/bm25.js +54 -0
  438. package/dist/search/boosts.d.ts +54 -0
  439. package/dist/search/boosts.js +94 -0
  440. package/dist/search/breakdown.d.ts +7 -0
  441. package/dist/search/breakdown.js +20 -0
  442. package/dist/search/explain.d.ts +25 -0
  443. package/dist/search/explain.js +31 -0
  444. package/dist/search/finalize.d.ts +8 -0
  445. package/dist/search/finalize.js +52 -0
  446. package/dist/search/fusion.d.ts +27 -0
  447. package/dist/search/fusion.js +42 -0
  448. package/dist/search/hybrid-score.d.ts +20 -0
  449. package/dist/search/hybrid-score.js +73 -0
  450. package/dist/search/hybrid.d.ts +46 -0
  451. package/dist/search/hybrid.js +64 -0
  452. package/dist/search/physics-search.d.ts +29 -0
  453. package/dist/search/physics-search.js +162 -0
  454. package/dist/search/rerank.d.ts +10 -0
  455. package/dist/search/rerank.js +72 -0
  456. package/dist/search/temporal.d.ts +15 -0
  457. package/dist/search/temporal.js +45 -0
  458. package/dist/search/types.d.ts +91 -0
  459. package/dist/search/types.js +2 -0
  460. package/dist/search/vector.d.ts +30 -0
  461. package/dist/search/vector.js +71 -0
  462. package/dist/secret-detect.d.ts +2 -0
  463. package/dist/secret-detect.js +3 -1
  464. package/dist/server/auth.d.ts +52 -0
  465. package/dist/server/auth.js +221 -0
  466. package/dist/server/client-ip.d.ts +25 -0
  467. package/dist/server/client-ip.js +92 -0
  468. package/dist/server/cursor.d.ts +23 -0
  469. package/dist/server/cursor.js +58 -0
  470. package/dist/server/lifecycle.d.ts +7 -0
  471. package/dist/server/lifecycle.js +29 -0
  472. package/dist/server/mcp-http.d.ts +5 -0
  473. package/dist/server/mcp-http.js +199 -0
  474. package/dist/server/request.d.ts +33 -0
  475. package/dist/server/request.js +103 -0
  476. package/dist/server/routes/admin.d.ts +9 -0
  477. package/dist/server/routes/admin.js +158 -0
  478. package/dist/server/routes/customer-notes.d.ts +7 -0
  479. package/dist/server/routes/customer-notes.js +112 -0
  480. package/dist/server/routes/decisions.d.ts +7 -0
  481. package/dist/server/routes/decisions.js +133 -0
  482. package/dist/server/routes/incidents.d.ts +7 -0
  483. package/dist/server/routes/incidents.js +126 -0
  484. package/dist/server/routes/memories.d.ts +10 -0
  485. package/dist/server/routes/memories.js +179 -0
  486. package/dist/server/routes/policies.d.ts +8 -0
  487. package/dist/server/routes/policies.js +151 -0
  488. package/dist/server/routes/predictions.d.ts +7 -0
  489. package/dist/server/routes/predictions.js +164 -0
  490. package/dist/server/routes/processes.d.ts +7 -0
  491. package/dist/server/routes/processes.js +161 -0
  492. package/dist/server/routes/project-briefs.d.ts +8 -0
  493. package/dist/server/routes/project-briefs.js +136 -0
  494. package/dist/server/routes/recall.d.ts +8 -0
  495. package/dist/server/routes/recall.js +340 -0
  496. package/dist/server/routes/skills.d.ts +8 -0
  497. package/dist/server/routes/skills.js +150 -0
  498. package/dist/server/types.d.ts +56 -0
  499. package/dist/server/types.js +2 -0
  500. package/dist/server/validation.d.ts +14 -0
  501. package/dist/server/validation.js +91 -0
  502. package/dist/server.d.ts +7 -61
  503. package/dist/server.js +92 -2368
  504. package/dist/session-digest.d.ts +1 -1
  505. package/dist/session-digest.js +9 -2
  506. package/dist/shared.d.ts +3 -1
  507. package/dist/shared.js +46 -40
  508. package/dist/skills.d.ts +3 -0
  509. package/dist/skills.js +7 -5
  510. package/dist/stdin.js +3 -2
  511. package/dist/store/audit-event.d.ts +19 -0
  512. package/dist/store/audit-event.js +33 -0
  513. package/dist/store/candidates.d.ts +42 -0
  514. package/dist/store/candidates.js +152 -0
  515. package/dist/store/conflicts.d.ts +44 -0
  516. package/dist/store/conflicts.js +444 -0
  517. package/dist/store/delete-and-batch.d.ts +63 -0
  518. package/dist/store/delete-and-batch.js +310 -0
  519. package/dist/store/entry-reads.d.ts +92 -0
  520. package/dist/store/entry-reads.js +255 -0
  521. package/dist/store/entry-row.d.ts +67 -0
  522. package/dist/store/entry-row.js +208 -0
  523. package/dist/store/entry-writes.d.ts +46 -0
  524. package/dist/store/entry-writes.js +148 -0
  525. package/dist/store/handoffs.d.ts +26 -0
  526. package/dist/store/handoffs.js +184 -0
  527. package/dist/store/index-and-stats.d.ts +52 -0
  528. package/dist/store/index-and-stats.js +214 -0
  529. package/dist/store/markdown.d.ts +10 -0
  530. package/dist/store/markdown.js +108 -0
  531. package/dist/store/mirrors.d.ts +49 -0
  532. package/dist/store/mirrors.js +312 -0
  533. package/dist/store/open.d.ts +15 -0
  534. package/dist/store/open.js +223 -0
  535. package/dist/store/rows.d.ts +178 -0
  536. package/dist/store/rows.js +158 -0
  537. package/dist/store/search-rows.d.ts +86 -0
  538. package/dist/store/search-rows.js +254 -0
  539. package/dist/store/sessions.d.ts +83 -0
  540. package/dist/store/sessions.js +272 -0
  541. package/dist/store/summaries.d.ts +94 -0
  542. package/dist/store/summaries.js +377 -0
  543. package/dist/store/tenant-lookup.d.ts +12 -0
  544. package/dist/store/tenant-lookup.js +16 -0
  545. package/dist/store-cards.js +2 -1
  546. package/dist/summary-dirty.d.ts +4 -0
  547. package/dist/summary-dirty.js +32 -0
  548. package/dist/support-bundle.js +4 -3
  549. package/dist/tenant.js +2 -2
  550. package/dist/token-ledger.js +1 -0
  551. package/dist/tokenize.d.ts +2 -0
  552. package/dist/tokenize.js +16 -0
  553. package/dist/transcript-tail.d.ts +7 -0
  554. package/dist/transcript-tail.js +48 -0
  555. package/dist/vector-store.d.ts +27 -0
  556. package/dist/vector-store.js +210 -0
  557. package/dist/version.d.ts +2 -2
  558. package/dist/version.js +2 -2
  559. package/dist/working-memory.js +1 -1
  560. package/dist/yaml.js +36 -11
  561. package/dist-ui/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  562. package/dist-ui/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  563. package/dist-ui/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
  564. package/dist-ui/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  565. package/dist-ui/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
  566. package/dist-ui/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
  567. package/dist-ui/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
  568. package/dist-ui/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
  569. package/dist-ui/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
  570. package/dist-ui/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
  571. package/dist-ui/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
  572. package/dist-ui/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
  573. package/dist-ui/assets/index-DPN7cP19.js +33 -0
  574. package/dist-ui/assets/index-dFloKRVr.css +1 -0
  575. package/dist-ui/index.html +3 -25
  576. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  577. package/extensions/openclaw-plugin/package.json +1 -1
  578. package/openclaw.plugin.json +1 -1
  579. package/package.json +2 -1
  580. package/dist/capture.d.ts +0 -155
  581. package/dist/capture.js +0 -1293
  582. package/dist/consolidate.d.ts +0 -58
  583. package/dist/consolidate.js +0 -1123
  584. package/dist/graph.d.ts +0 -245
  585. package/dist/hooks.d.ts +0 -208
  586. package/dist/hooks.js +0 -1073
  587. package/dist/importers.js +0 -897
  588. package/dist/predictions.js +0 -620
  589. package/dist/search.d.ts +0 -320
  590. package/dist/search.js +0 -968
  591. package/dist/store.d.ts +0 -744
  592. package/dist/store.js +0 -3372
  593. package/dist-ui/assets/d3-BiWEKnn4.js +0 -1
  594. package/dist-ui/assets/index-BhT8RvO6.js +0 -61
  595. package/dist-ui/assets/index-RoXXJ5dq.css +0 -1
  596. package/dist-ui/assets/three-BDgTxR1l.js +0 -4112
package/dist/server.js CHANGED
@@ -1,47 +1,41 @@
1
+ import { envPort, envRequireAuth, envV1Rps } from './env.js';
1
2
  import { createServer } from 'node:http';
2
- import { createHash, randomUUID } from 'node:crypto';
3
- import { dirname, resolve } from 'node:path';
4
- import { resolveProjectIdentity } from './project-identity.js';
5
- import { assembleCost, contextCost, drillCost } from './context-render.js';
6
- import { detectServer, writePidfile, removePidfileIfOwned } from './server-detect.js';
7
- import { resolveTenantId } from './tenant.js';
8
- import { openHippoDb, closeHippoDb } from './db.js';
9
- import { updateStats } from './store.js';
10
- import { buildSessionKey, getOrCreateRing, appendRecall, snapshotRing, hashQueryText, biasHintEnabled, } from './recall-history.js';
11
- import { appendAuditEvent, auditQueryFields, auditWriteFailureCount, AUDIT_OPS } from './audit.js';
12
- // v0.33 / J1 — Module-level per-(tenant, session) recall-history ring map
13
- // for the HTTP pipeline. Separate from CLI/MCP rings per plan v3 (per-
14
- // pipeline rings; no IPC). HTTP is the only caller that threads its
15
- // snapshot through opts.recallHistory to api.recall — api.recall's
16
- // anchoringHint on the returned RecallResult IS the user-visible hint
17
- // here (no separate compute needed).
18
- const sessionRecallHistoryHttp = new Map();
19
- /** Test-only: reset the module-level recall-history Map. Call from beforeEach. */
20
- export function __resetSessionRecallHistoryHttp() {
21
- sessionRecallHistoryHttp.clear();
22
- }
3
+ import { existsSync } from 'node:fs';
4
+ import { detectServer, removePidfileIfOwned, writePidfile } from './server-detect.js';
5
+ import { closeHippoDb, getHippoDbPath, isSqliteBusy, openHippoDb, SERVER_DB_WAIT_MS, withBusyWait } from './db.js';
6
+ import { auditWriteFailureCount } from './audit.js';
23
7
  import { PACKAGE_VERSION } from './version.js';
24
- import { log } from './log.js';
25
- import { API_KEY_PREFIX, validateApiKey } from './auth.js';
8
+ import { errorFields, log } from './log.js';
26
9
  import { createRateLimiter } from './rate-limit.js';
27
- import { remember, retrieve, RecallContractError, drillDown, assemble, forget, promote, supersede, archiveRaw, authCreate, authList, authRevoke, auditList, outcome, outcomeForLastRecall, getContext, sleep, recordTokens, quarantineList, quarantineApprove, quarantineReject, } from './api.js';
28
- import { buildGraphModel } from './graph-view.js';
29
- import { MAX_ENTITY_NAME_LEN } from './graph.js';
30
- import { savePrediction, closePrediction, loadPredictionById, loadPredictionsByClass, loadOpenPredictions, computePredictionBaserate, VALID_CLOSURE_STATES, } from './predictions.js';
31
- import { saveDecision, closeDecision, loadDecisionById, loadDecisions, VALID_DECISION_STATES, } from './decisions.js';
32
- import { saveIncident, resolveIncident, closeIncident, loadIncidentById, loadIncidents, VALID_INCIDENT_STATES, } from './incidents.js';
33
- import { saveProcess, closeProcess, loadProcessById, loadProcesses, VALID_PROCESS_STATES, } from './processes.js';
34
- import { savePolicy, closePolicy, loadPolicyById, loadPolicies, loadPoliciesAsOf, VALID_POLICY_STATES, } from './policies.js';
35
- import { saveSkill, closeSkill, loadSkillById, loadSkills, exportSkills, VALID_SKILL_STATES, } from './skills.js';
36
- import { saveProjectBrief, closeProjectBrief, loadProjectBriefById, loadProjectBriefs, assembleBriefFromReceipts, refreshBrief, VALID_BRIEF_STATES, } from './project-briefs.js';
37
- import { saveCustomerNote, closeCustomerNote, loadCustomerNoteById, loadCustomerNotes, VALID_NOTE_STATES, } from './customer-notes.js';
38
- import { handleMcpRequest } from './mcp/server.js';
10
+ import { authRevoke, RecallContractError } from './api.js';
39
11
  import { handleSlackEventsWebhook } from './connectors/slack/webhook.js';
40
12
  import { handleGitHubEventsWebhook } from './connectors/github/webhook.js';
41
- import { HttpError, JSON_HEADERS, BodyTooLargeError, isHeaderString, isJsonObjectRecord, mapApiError, readBody, sendJson, } from './http-util.js';
42
- import { ForbiddenError, NotFoundError } from './api-errors.js';
13
+ import { BodyTooLargeError, HttpError, JSON_HEADERS, sendJson } from './http-util.js';
14
+ import { ForbiddenError } from './api-errors.js';
15
+ import { isLoopback } from './server/auth.js';
16
+ import { enforceRateLimit } from './server/client-ip.js';
17
+ import { drainAndClose } from './server/lifecycle.js';
18
+ import { handleMcpPost, handleMcpStream } from './server/mcp-http.js';
19
+ import { logRequestFailure, matchPath, parseRequest, rejectEncodedSlash, replyFor, requestIds, resolveRequestId, sendError } from './server/request.js';
20
+ import { handleApproveQuarantine, handleCreateAuthKey, handleListAudit, handleListAuthKeys, handleListQuarantine, handleRejectQuarantine, handleRevokeAuthKey } from './server/routes/admin.js';
21
+ import { handleCloseCustomerNote, handleCreateCustomerNote, handleGetCustomerNote, handleListCustomerNotes, handleSupersedeCustomerNote } from './server/routes/customer-notes.js';
22
+ import { handleCloseDecision, handleCreateDecision, handleGetDecision, handleListDecisions, handleSupersedeDecision } from './server/routes/decisions.js';
23
+ import { handleCloseIncident, handleCreateIncident, handleGetIncident, handleListIncidents, handleResolveIncident } from './server/routes/incidents.js';
24
+ import { handleApplyOutcome, handleArchiveMemory, handleCreateMemory, handleForgetMemory, handleGetGraph, handlePromoteMemory, handleSleep, handleSupersedeMemory } from './server/routes/memories.js';
25
+ import { handleClosePolicy, handleCreatePolicy, handleGetPolicy, handleListPolicies, handlePoliciesAsOf, handleSupersedePolicy } from './server/routes/policies.js';
26
+ import { handleClosePrediction, handleCreatePrediction, handleGetPrediction, handleListPredictions, handlePredictionStats } from './server/routes/predictions.js';
27
+ import { handleCloseProcess, handleCreateProcess, handleGetProcess, handleListProcesses, handleSupersedeProcess } from './server/routes/processes.js';
28
+ import { handleCloseProjectBrief, handleCreateProjectBrief, handleGetProjectBrief, handleListProjectBriefs, handleRefreshProjectBrief, handleSupersedeProjectBrief } from './server/routes/project-briefs.js';
29
+ import { handleAssembleSession, handleDrillRecall, handleGetContext, handleRecallMemories } from './server/routes/recall.js';
30
+ import { handleCloseSkill, handleCreateSkill, handleExportSkills, handleGetSkill, handleListSkills, handleSupersedeSkill } from './server/routes/skills.js';
43
31
  // Add-on packages revoke keys through these without importing the whole api surface.
44
32
  export { authRevoke, ForbiddenError };
33
+ // Published on the hippo-memory/server subpath before they moved to http-util.ts, so they stay exported here.
34
+ export { isCrossSite, LOOPBACK_HOST_HEADER } from './http-util.js';
35
+ // The code behind these lives in src/server/; this subpath keeps exporting them.
36
+ export { __resetSessionRecallHistoryHttp } from './server/routes/recall.js';
37
+ export { clientIpForRateLimit } from './server/client-ip.js';
38
+ export { isLoopback, isReservedActor } from './server/auth.js';
45
39
  // Review patch #2: explicit allow-list for unauthenticated /v1/* routes.
46
40
  // New unauth routes MUST be added here AND get a corresponding entry in
47
41
  // tests/server-bearer-lockdown.test.ts. Do not gate auth elsewhere by
@@ -58,101 +52,11 @@ const PUBLIC_ROUTES = new Set([
58
52
  function isPublicRoute(method, path) {
59
53
  return PUBLIC_ROUTES.has(`${method} ${path}`);
60
54
  }
61
- const VALID_AUDIT_OPS = new Set(AUDIT_OPS);
62
- // Cap on GET /v1/audit?limit=. Matches docs/api.md (when written) and is large
63
- // enough to dump a small deployment's full audit log without paginating, but
64
- // small enough that a malicious client can't ask for the world.
65
- const MAX_AUDIT_LIMIT = 10000;
66
- function isJsonString(value) {
67
- return typeof value === 'string';
68
- }
69
- function isJsonNumber(value) {
70
- return typeof value === 'number';
71
- }
72
- function isJsonBoolean(value) {
73
- return typeof value === 'boolean';
74
- }
75
55
  // server.address() returns AddressInfo once a TCP socket is bound; null before
76
56
  // listening, a string only for pipe/unix-socket listeners (never used here).
77
57
  function isAddressInfo(a) {
78
58
  return a !== null && typeof a !== 'string';
79
59
  }
80
- // Runtime membership check for a `ReadonlySet<T>` of string-literal union
81
- // members, used at every `body` field validated against a VALID_* set below.
82
- // Set<T>.has(value: T) itself gives no narrowing (its parameter type is T,
83
- // not a type predicate) so callers previously needed a separate `as T` cast
84
- // at both the check and the later usage; this helper is the one place that
85
- // assertion lives, so downstream call sites narrow via the `value is T`
86
- // return instead of re-asserting.
87
- function isSetMember(set, value) {
88
- // SAFETY: `value as T` is discarded unless `set.has` (the real runtime
89
- // check) confirms membership; the `value is T` return type is what
90
- // performs the actual narrowing for callers.
91
- return set.has(value);
92
- }
93
- // HTTP-boundary validation for a process `steps` body (untrusted). Returns the
94
- // step strings (saveProcess re-validates + trims, this is the fail-fast 400
95
- // gate). Caps mirror src/processes.ts MAX_PROCESS_STEPS / MAX_PROCESS_STEP_LEN.
96
- function validateProcessStepsBody(raw) {
97
- if (raw === undefined || raw === null)
98
- return [];
99
- if (!Array.isArray(raw)) {
100
- throw new HttpError(400, 'steps must be an array of strings');
101
- }
102
- if (raw.length > 200) {
103
- throw new HttpError(400, 'steps exceeds 200-step cap');
104
- }
105
- for (const item of raw) {
106
- if (!isJsonString(item)) {
107
- throw new HttpError(400, 'each step must be a string');
108
- }
109
- if (item.trim().length === 0) {
110
- throw new HttpError(400, 'a step is empty');
111
- }
112
- if (item.length > 2000) {
113
- throw new HttpError(400, 'a step exceeds the 2000-character cap');
114
- }
115
- }
116
- // SAFETY: every item in raw was confirmed to be a string in the loop above.
117
- return raw;
118
- }
119
- // HTTP-boundary check for an optional policy date field (validFrom/validTo).
120
- // Type + length only; savePolicy/loadPoliciesAsOf normalize + format-validate the
121
- // value (an unparseable date throws there -> mapped to 400). 64-char cap bounds a
122
- // junk string before it reaches the Date parser.
123
- function optionalDateField(raw, label) {
124
- if (raw === undefined || raw === null)
125
- return undefined;
126
- if (!isJsonString(raw)) {
127
- throw new HttpError(400, `${label} must be a string`);
128
- }
129
- if (raw.length > 64) {
130
- throw new HttpError(400, `${label} exceeds 64-character cap`);
131
- }
132
- return raw;
133
- }
134
- // Parse a `?limit=` query param for the E2 list routes. Defaults to 100; requires
135
- // a positive INTEGER <= 1000. Number.isInteger rejects fractional values like
136
- // "1.5" that Number.isFinite would pass but SQLite `LIMIT ?` rejects with a
137
- // datatype mismatch (a 500). Shared across the decision/incident/process/policy
138
- // list routes so the guard cannot drift (codex review 2026-05-30 P2: fractional
139
- // limit reached SQLite on the policy route; the same latent hole existed in the
140
- // sibling routes this was copied from).
141
- function parseListLimit(limitRaw) {
142
- if (limitRaw === null)
143
- return 100;
144
- const limit = Number(limitRaw);
145
- if (!Number.isInteger(limit) || limit <= 0 || limit > 1000) {
146
- throw new HttpError(400, 'limit must be a positive integer <= 1000');
147
- }
148
- return limit;
149
- }
150
- const VALID_KINDS = new Set([
151
- 'raw',
152
- 'distilled',
153
- 'superseded',
154
- 'archived',
155
- ]);
156
60
  // Pinned at module load. Bumped alongside package.json on releases. The
157
61
  // HTTP /health response uses this; reading package.json synchronously here
158
62
  // would couple the daemon to its on-disk install path, which we want to
@@ -160,2054 +64,6 @@ const VALID_KINDS = new Set([
160
64
  // v1.3.1: source from src/version.ts so /health no longer reports stale 0.39.0.
161
65
  const VERSION = PACKAGE_VERSION;
162
66
  const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']);
163
- // The caller's id lands in a response header and in logs, so only a short plain token is echoed back.
164
- const REQUEST_ID_RE = /^[A-Za-z0-9._:-]{1,128}$/;
165
- /** The caller's `X-Request-Id` when it is a short plain token, else a fresh UUID. */
166
- function resolveRequestId(header) {
167
- const value = Array.isArray(header) ? undefined : header;
168
- return value && REQUEST_ID_RE.test(value) ? value : randomUUID();
169
- }
170
- /** One line per failed request; 4xx is the caller's mistake, so it stays below the default level. */
171
- function logRequestFailure(req, err, requestId, status) {
172
- const message = err instanceof Error ? err.message : String(err);
173
- const line = `${req.method ?? 'GET'} ${(req.url ?? '/').split('?')[0]} failed: ${message}`;
174
- if (status >= 500)
175
- log.error(line, { requestId, status });
176
- else
177
- log.info(line, { requestId, status });
178
- }
179
- function sendError(res, status, message) {
180
- sendJson(res, status, { error: message });
181
- }
182
- async function parseJsonBody(req) {
183
- const raw = await readBody(req);
184
- if (raw.length === 0)
185
- return {};
186
- try {
187
- const parsed = JSON.parse(raw);
188
- if (!isJsonObjectRecord(parsed)) {
189
- throw new HttpError(400, 'request body must be a JSON object');
190
- }
191
- return parsed;
192
- }
193
- catch (e) {
194
- if (e instanceof HttpError)
195
- throw e;
196
- throw new HttpError(400, 'invalid JSON body');
197
- }
198
- }
199
- function parseRequest(req) {
200
- let url;
201
- try {
202
- url = new URL(req.url ?? '/', 'http://placeholder');
203
- }
204
- catch (e) {
205
- // A request target the URL parser rejects is the caller's fault, not a server failure.
206
- if (e instanceof TypeError)
207
- throw new HttpError(400, e.message);
208
- throw e;
209
- }
210
- return {
211
- method: req.method ?? 'GET',
212
- path: url.pathname,
213
- query: url.searchParams,
214
- };
215
- }
216
- /**
217
- * Lightweight pattern matcher for /v1/memories/:id/<action>. Avoids pulling
218
- * in a router dependency for the half-dozen patterns we actually use.
219
- *
220
- * Returns null if `path` does not match `pattern`. Otherwise returns an object
221
- * mapping each :param name to its value. Path segments are exact-matched
222
- * except for parameter slots.
223
- */
224
- function decodePathSegment(segment) {
225
- try {
226
- return decodeURIComponent(segment);
227
- }
228
- catch (e) {
229
- if (e instanceof URIError)
230
- throw new HttpError(400, e.message);
231
- throw e;
232
- }
233
- }
234
- function matchPath(pattern, path) {
235
- const patternParts = pattern.split('/');
236
- const pathParts = path.split('/');
237
- if (patternParts.length !== pathParts.length)
238
- return null;
239
- const params = {};
240
- for (let i = 0; i < patternParts.length; i++) {
241
- const pp = patternParts[i];
242
- const ap = pathParts[i];
243
- if (pp.startsWith(':')) {
244
- if (ap.length === 0)
245
- return null;
246
- params[pp.slice(1)] = decodePathSegment(ap);
247
- }
248
- else if (pp !== ap) {
249
- return null;
250
- }
251
- }
252
- return params;
253
- }
254
- /**
255
- * Recognise loopback remote addresses. Node reports IPv6-mapped IPv4 as
256
- * '::ffff:127.0.0.1' on dual-stack sockets, so we accept that alongside
257
- * the bare v4 and v6 loopbacks. Anything else is treated as remote.
258
- */
259
- export function isLoopback(remoteAddress) {
260
- if (!remoteAddress)
261
- return false;
262
- if (remoteAddress === '127.0.0.1')
263
- return true;
264
- if (remoteAddress === '::1')
265
- return true;
266
- if (remoteAddress === '::ffff:127.0.0.1')
267
- return true;
268
- return false;
269
- }
270
- // Any other Host on a loopback socket is DNS rebinding: a hostile page resolved to 127.0.0.1.
271
- export const LOOPBACK_HOST_HEADER = /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i;
272
- /** A browser request sent by another site. Non-browser clients send neither header and pass. */
273
- export function isCrossSite(req) {
274
- const site = req.headers['sec-fetch-site'];
275
- if (site !== undefined && site !== 'same-origin' && site !== 'none')
276
- return true;
277
- const origin = req.headers.origin;
278
- return origin !== undefined && origin !== `http://${req.headers.host}`;
279
- }
280
- // A proxy on this host (nginx, Caddy, cloudflared) connects from loopback, so these headers mean the caller is not local.
281
- const PROXY_HEADERS = [
282
- 'forwarded', 'x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-real-ip', 'cf-connecting-ip', 'true-client-ip',
283
- ];
284
- // The auth helpers only see the request, so its id rides here for their log lines.
285
- const requestIds = new WeakMap();
286
- // A browser on this machine is loopback too, so the no-key fallback also needs a local Host and a same-site caller.
287
- function assertLocalCaller(req) {
288
- if (!isLoopback(req.socket.remoteAddress))
289
- throw new HttpError(401, 'auth required');
290
- const proxyHeader = PROXY_HEADERS.find((name) => req.headers[name] !== undefined);
291
- if (proxyHeader !== undefined) {
292
- log.warn(`proxied loopback request refused: it carries ${proxyHeader}, so the no-key local fallback does not apply. ` +
293
- 'Send an API key (hippo auth create, then Authorization: Bearer hk_...).', { requestId: requestIds.get(req) });
294
- throw new HttpError(401, 'auth required');
295
- }
296
- const host = req.headers.host;
297
- if ((host !== undefined && !LOOPBACK_HOST_HEADER.test(host)) || isCrossSite(req)) {
298
- throw new HttpError(403, 'cross-site or non-local request refused; send an API key');
299
- }
300
- }
301
- function readAuthHeader(req) {
302
- const raw = req.headers['authorization'];
303
- if (raw === undefined)
304
- return { kind: 'absent' };
305
- const value = Array.isArray(raw) ? raw[0] : raw;
306
- if (!isHeaderString(value) || value.length === 0) {
307
- return { kind: 'malformed' };
308
- }
309
- const space = value.indexOf(' ');
310
- if (space < 0)
311
- return { kind: 'malformed' };
312
- const scheme = value.slice(0, space);
313
- const token = value.slice(space + 1).trim();
314
- if (scheme.toLowerCase() !== 'bearer')
315
- return { kind: 'malformed' };
316
- if (token.length === 0)
317
- return { kind: 'malformed' };
318
- return { kind: 'bearer', token };
319
- }
320
- /**
321
- * Build a per-client key for MCP state isolation under HTTP-MCP. Used by
322
- * mcp/server.ts to scope `lastRecalledIds` to the calling client so two
323
- * clients on the same tenant cannot poison each other's outcome feedback.
324
- *
325
- * Token is hashed (sha256, 16-hex-char prefix) so we never log or persist
326
- * the raw bearer. Combined with remoteAddress so two clients sharing a key
327
- * (e.g. on a shared Postman environment) are still separable in the common
328
- * case. 'noauth' covers loopback no-auth and is acceptable because that
329
- * path is single-host single-user.
330
- */
331
- function buildMcpClientKey(req) {
332
- const auth = readAuthHeader(req);
333
- const tokenHash = auth.kind === 'bearer'
334
- ? createHash('sha256').update(auth.token).digest('hex').slice(0, 16)
335
- : 'noauth';
336
- const addr = req.socket.remoteAddress ?? 'unknown';
337
- return `http:${tokenHash}:${addr}`;
338
- }
339
- /**
340
- * Rate-limit key for a request. Defaults to the socket's remote address.
341
- *
342
- * Behind a TLS-terminating proxy (Fly, most PaaS ingress) every socket
343
- * carries the proxy's address, so per-IP buckets collapse into one global
344
- * bucket that unauthenticated traffic can drain before auth runs. Set
345
- * HIPPO_CLIENT_IP_HEADER to the header the proxy stamps with the real
346
- * client address (fly-client-ip on Fly, which the edge always overwrites)
347
- * to key buckets per client instead.
348
- *
349
- * Only set this when a trusted proxy fronts EVERY request: a directly
350
- * reachable server honoring the header would let clients mint a fresh
351
- * bucket per request and bypass the limiter entirely.
352
- */
353
- export function clientIpForRateLimit(req) {
354
- const header = process.env.HIPPO_CLIENT_IP_HEADER?.toLowerCase();
355
- if (header) {
356
- const raw = req.headers[header];
357
- const first = Array.isArray(raw) ? raw[0] : raw;
358
- // Take the first entry of a comma-joined list (proxy chains append).
359
- const ip = first?.split(',')[0]?.trim();
360
- if (ip)
361
- return ip;
362
- }
363
- return req.socket.remoteAddress ?? 'unknown';
364
- }
365
- // Built-in actors are the bare names below or `<name>:<detail>`; a plain prefix would also reject `clinton@corp`.
366
- const RESERVED_ACTOR_NAMES = [
367
- 'api_key', 'localhost', 'cli', 'system', 'mcp', 'connector', 'sleep', 'post-compact', 'recall', 'agent-memories',
368
- ];
369
- /** Add-ons call this to refuse a subject that would collide with a built-in actor. */
370
- export function isReservedActor(subject) {
371
- const lower = subject.toLowerCase();
372
- return RESERVED_ACTOR_NAMES.some((n) => lower === n || lower.startsWith(`${n}:`));
373
- }
374
- function hasControlChar(s) {
375
- for (let i = 0; i < s.length; i++) {
376
- const c = s.charCodeAt(i);
377
- if (c < 0x20 || c === 0x7f)
378
- return true;
379
- }
380
- return false;
381
- }
382
- /** Core owns the checks so a resolver cannot mint an actor that collides with a built-in one. */
383
- function sanitiseResolved(r) {
384
- // Each field is read once: a getter on plugin code could answer differently the second time.
385
- const { tenantId, subject, role, scopes } = r;
386
- // A resolver is plugin code, so the runtime checks hold even though the static types say string.
387
- if (!isJsonString(tenantId) || tenantId.trim().length === 0)
388
- return null;
389
- // Core reserves `__`-prefixed tenants (`__host__`, `__unroutable__`).
390
- const tenant = tenantId.trim();
391
- if (tenant.startsWith('__') || tenant.length > 256 || hasControlChar(tenant))
392
- return null;
393
- if (!isJsonString(subject) || subject.length < 1 || subject.length > 256)
394
- return null;
395
- // Padding would let "system " pass the reserved-name check yet read as `system` in an audit log.
396
- if (hasControlChar(subject) || subject !== subject.trim())
397
- return null;
398
- if (isReservedActor(subject))
399
- return null;
400
- const clean = { tenantId: tenant, subject, role: role === 'admin' ? 'admin' : 'member' };
401
- if (Array.isArray(scopes))
402
- clean.scopes = scopes.filter((s) => isJsonString(s));
403
- return clean;
404
- }
405
- function logResolverFailure(what, raw, token) {
406
- // The plugin's message is logged, but never the token, even if the plugin echoed it.
407
- const msg = raw.split(token).join('[token]').replace(/[\r\n]/g, ' ');
408
- process.stderr.write(`[hippo] auth resolver ${what}: ${msg}\n`);
409
- }
410
- /** 503 when upstream throws or misses the deadline, so a stream heartbeat can tell an outage from a revocation. */
411
- async function askResolver(resolver, token, deadlineMs) {
412
- let timer;
413
- const deadline = new Promise((_, reject) => {
414
- timer = setTimeout(() => reject(new Error(`no answer within ${deadlineMs} ms`)), deadlineMs);
415
- });
416
- let resolved;
417
- try {
418
- resolved = await Promise.race([(async () => resolver(token))(), deadline]);
419
- }
420
- catch (err) {
421
- logResolverFailure('threw', err instanceof Error ? err.message : 'unknown error', token);
422
- throw new HttpError(503, 'auth provider unavailable');
423
- }
424
- finally {
425
- clearTimeout(timer);
426
- }
427
- let clean = null;
428
- try {
429
- clean = resolved ? sanitiseResolved(resolved) : null;
430
- }
431
- catch (err) {
432
- // A throwing getter is a resolver bug, not an outage, so it is a 401 like any malformed answer.
433
- logResolverFailure('threw', err instanceof Error ? err.message : 'unknown error', token);
434
- }
435
- if (!clean)
436
- throw new HttpError(401, 'invalid api key');
437
- return clean;
438
- }
439
- const DEFAULT_RESOLVER_DEADLINE_MS = 5000;
440
- /** Shared by buildContextWithAuth and requireAuth so the two cannot drift. */
441
- async function resolveBearer(token, opts) {
442
- // Routing by shape keeps key plaintext out of plugin code and stops a resolver overriding a key's identity.
443
- if (opts.authResolver && !token.startsWith(API_KEY_PREFIX)) {
444
- const t = opts.authResolverTimeoutMs;
445
- const deadlineMs = t !== undefined && Number.isFinite(t) && t > 0 ? t : DEFAULT_RESOLVER_DEADLINE_MS;
446
- return { ...(await askResolver(opts.authResolver, token, deadlineMs)), viaAuthResolver: true };
447
- }
448
- const db = openHippoDb(opts.hippoRoot);
449
- try {
450
- const result = validateApiKey(db, token);
451
- if (!result.valid || !result.tenantId || !result.keyId || !result.role) {
452
- throw new HttpError(401, 'invalid api key');
453
- }
454
- return {
455
- tenantId: result.tenantId,
456
- subject: `api_key:${result.keyId}`,
457
- role: result.role,
458
- scopes: result.scopes,
459
- };
460
- }
461
- finally {
462
- closeHippoDb(db);
463
- }
464
- }
465
- /**
466
- * Build a per-request Context from the Authorization header and remote
467
- * address. Throws HttpError(401) for invalid / missing credentials. Opens
468
- * the DB only for an API-key-shaped Bearer token (or any Bearer token when no
469
- * auth resolver is registered), so loopback no-auth requests stay cheap.
470
- */
471
- async function buildContextWithAuth(req, opts) {
472
- const auth = readAuthHeader(req);
473
- if (auth.kind === 'malformed') {
474
- throw new HttpError(401, 'invalid api key');
475
- }
476
- if (auth.kind === 'bearer') {
477
- const id = await resolveBearer(auth.token, opts);
478
- const actor = { subject: id.subject, role: id.role, scopes: id.scopes };
479
- if (id.viaAuthResolver)
480
- actor.viaAuthResolver = true;
481
- return { hippoRoot: opts.hippoRoot, tenantId: id.tenantId, actor };
482
- }
483
- // No Authorization header. Loopback-only fallback for a direct local caller (no proxy headers),
484
- // unless HIPPO_REQUIRE_AUTH=1 forbids the local-CLI escape hatch.
485
- if (process.env.HIPPO_REQUIRE_AUTH === '1') {
486
- throw new HttpError(401, 'auth required');
487
- }
488
- assertLocalCaller(req);
489
- // v1.12.0: loopback fallback is process-local, treat as admin.
490
- return {
491
- hippoRoot: opts.hippoRoot,
492
- tenantId: resolveTenantId({}),
493
- actor: { subject: 'localhost:cli', role: 'admin' },
494
- };
495
- }
496
- /**
497
- * Auth check for routes that do not need a tenant Context (e.g. MCP transport,
498
- * which builds its own root resolution via findHippoRoot). Throws HttpError
499
- * 401 the same way buildContextWithAuth does, but skips building the Context
500
- * envelope. Loopback no-auth still passes.
501
- */
502
- async function requireAuth(req, opts) {
503
- const auth = readAuthHeader(req);
504
- if (auth.kind === 'malformed') {
505
- throw new HttpError(401, 'invalid api key');
506
- }
507
- if (auth.kind === 'bearer') {
508
- await resolveBearer(auth.token, opts);
509
- return;
510
- }
511
- if (process.env.HIPPO_REQUIRE_AUTH === '1') {
512
- throw new HttpError(401, 'auth required');
513
- }
514
- assertLocalCaller(req);
515
- }
516
- /** Never rejects: an outage (5xx) skips one heartbeat tick, only a definite 4xx denial closes the stream. */
517
- async function heartbeatVerdict(req, opts) {
518
- try {
519
- await requireAuth(req, opts);
520
- return 'ok';
521
- }
522
- catch (err) {
523
- return err instanceof HttpError && err.status < 500 ? 'revoked' : 'unavailable';
524
- }
525
- }
526
- /** Gate for any action beyond the caller's own tenant: a resolver admin is a customer's tenant admin, never a host admin. */
527
- function assertCrossTenantAdmin(ctx, what) {
528
- if (ctx.actor.role !== 'admin')
529
- throw new HttpError(403, `${what} requires admin role`);
530
- if (ctx.actor.viaAuthResolver)
531
- throw new HttpError(403, `${what} requires an API-key admin`);
532
- }
533
- function getString(obj, key) {
534
- const v = obj[key];
535
- return isJsonString(v) ? v : undefined;
536
- }
537
- function getStringArray(obj, key) {
538
- const v = obj[key];
539
- if (!Array.isArray(v))
540
- return undefined;
541
- if (!v.every(isJsonString))
542
- return undefined;
543
- return v;
544
- }
545
- /**
546
- * Reject URL-encoded slashes in path segments BEFORE the URL parser decodes
547
- * them — otherwise `%2F` becomes `/`, path-split runs, and the route either
548
- * silently 404s or matches the wrong template.
549
- *
550
- * codex round 3 P2: only scan the PATHNAME portion of the raw URL, not the
551
- * query string. Pre-fix, `?q=https%3A%2F%2Fexample.com` would 400 because
552
- * the regex matched `%2F` anywhere in `req.url`. Recall queries containing
553
- * URLs would have been rejected as bypass attempts. Splitting on the first
554
- * `?` confines the check to the path.
555
- */
556
- function rejectEncodedSlash(rawUrl) {
557
- const queryIdx = rawUrl.indexOf('?');
558
- const pathname = queryIdx === -1 ? rawUrl : rawUrl.slice(0, queryIdx);
559
- if (/%2[Ff]/.test(pathname)) {
560
- throw new HttpError(400, 'URL-encoded slash (%2F) not allowed in path segments');
561
- }
562
- }
563
- /**
564
- * v1.6.4: charset + length validation for `:id` route captures. Routes call
565
- * this immediately after `matchPath` to reject empty / overlong / illegal
566
- * ids with a useful 400 instead of silently falling through to "not found".
567
- *
568
- * Allowed charset matches all production id shapes Hippo emits: `mem_<hex>`,
569
- * `sum_<hex>`, `sess-<id>`, Slack bot ids like `B01ABCD`, etc. The `:` and
570
- * `.` are allowed for forward-compat. The `/` is intentionally absent —
571
- * Hippo never emits ids with slashes, and `rejectEncodedSlash` already
572
- * stops `%2F`-smuggled ones at the front door.
573
- */
574
- const ID_SEGMENT_RE = /^[A-Za-z0-9_:.\-]+$/;
575
- function validateIdSegment(id, fieldName) {
576
- if (id.length === 0)
577
- throw new HttpError(400, `${fieldName} is required`);
578
- if (id.length > 256)
579
- throw new HttpError(400, `${fieldName} exceeds 256-character cap`);
580
- if (!ID_SEGMENT_RE.test(id)) {
581
- throw new HttpError(400, `${fieldName} contains invalid characters; allowed: A-Z a-z 0-9 _ : . -`);
582
- }
583
- }
584
- // POST /v1/memories
585
- async function handleCreateMemory({ req, res, opts }) {
586
- const body = await parseJsonBody(req);
587
- const content = getString(body, 'content');
588
- if (!content) {
589
- throw new HttpError(400, 'content is required');
590
- }
591
- const kindRaw = getString(body, 'kind');
592
- if (kindRaw !== undefined && !isSetMember(VALID_KINDS, kindRaw)) {
593
- throw new HttpError(400, `invalid kind: ${kindRaw}`);
594
- }
595
- const ctx = await buildContextWithAuth(req, opts);
596
- const result = remember(ctx, {
597
- content,
598
- kind: kindRaw,
599
- scope: getString(body, 'scope'),
600
- owner: getString(body, 'owner'),
601
- artifactRef: getString(body, 'artifactRef'),
602
- tags: getStringArray(body, 'tags'),
603
- });
604
- sendJson(res, 200, result);
605
- return;
606
- }
607
- // GET /v1/graph?entity=NAME&limit=N — read-only entity/relation graph (tenant-scoped)
608
- async function handleGetGraph({ req, res, opts, query }) {
609
- const entityRaw = query.get('entity');
610
- // Cap at the graph entity-name cap (512), not the id-shaped 256, so a valid
611
- // long decision/policy name remains focusable over HTTP (codex P2).
612
- if (entityRaw !== null && entityRaw.length > MAX_ENTITY_NAME_LEN) {
613
- throw new HttpError(400, `entity exceeds the ${MAX_ENTITY_NAME_LEN}-character cap`);
614
- }
615
- const limit = parseListLimit(query.get('limit'));
616
- const ctx = await buildContextWithAuth(req, opts);
617
- const model = buildGraphModel(ctx.hippoRoot, ctx.tenantId, {
618
- entity: entityRaw ?? undefined,
619
- limit,
620
- });
621
- sendJson(res, 200, model);
622
- return;
623
- }
624
- // GET /v1/memories?q=...&limit=...&mode=...&scope=...&include_continuity=1
625
- async function handleRecallMemories({ req, res, opts, query }) {
626
- const q = query.get('q');
627
- if (!q) {
628
- throw new HttpError(400, 'q is required');
629
- }
630
- const limitRaw = query.get('limit');
631
- const limit = limitRaw === null ? undefined : parseListLimit(limitRaw);
632
- const mode = query.get('mode');
633
- if (mode !== null && mode !== 'bm25' && mode !== 'hybrid' && mode !== 'physics') {
634
- throw new HttpError(400, "mode must be 'bm25', 'hybrid', or 'physics'");
635
- }
636
- const scope = query.get('scope');
637
- const includeContinuityRaw = query.get('include_continuity');
638
- const includeContinuity = includeContinuityRaw === '1'
639
- || includeContinuityRaw === 'true';
640
- // v1.6.2: surface the v1.5.0/v1.5.2 RecallOpts additions to HTTP
641
- // callers. Pre-v1.6.2 the route silently ignored these so the
642
- // session-scoped fresh-tail and summary substitution were JS-only.
643
- const freshTailCountRaw = query.get('fresh_tail_count');
644
- const freshTailCount = freshTailCountRaw === null ? undefined : Number(freshTailCountRaw);
645
- if (freshTailCount !== undefined && (!Number.isFinite(freshTailCount) || freshTailCount < 0)) {
646
- throw new HttpError(400, 'fresh_tail_count must be a non-negative number');
647
- }
648
- // v1.6.3 senior-review P1-3: cap session_id length consistent with the
649
- // rest of the API. Untrimmed strings round-trip through the SQL layer
650
- // and through any downstream metric/log; 256 is generous for a session
651
- // id and matches the rest of this file's id-shaped param parsers.
652
- const freshTailSessionIdRaw = query.get('fresh_tail_session_id');
653
- if (freshTailSessionIdRaw !== null && freshTailSessionIdRaw.length > 256) {
654
- throw new HttpError(400, 'fresh_tail_session_id exceeds 256-character cap');
655
- }
656
- const freshTailSessionId = freshTailSessionIdRaw && freshTailSessionIdRaw.length > 0
657
- ? freshTailSessionIdRaw
658
- : undefined;
659
- // v1.6.3 senior-review P1-4: tighten parser to match the includeContinuity
660
- // convention. Pre-v1.6.3 accepted any non-'0'/'false' value as `true`,
661
- // so `?summarize_overflow=banana` and `?summarize_overflow=` both
662
- // turned it on. Surface convention drift fixed.
663
- const summarizeOverflowRaw = query.get('summarize_overflow');
664
- const summarizeOverflow = summarizeOverflowRaw === null
665
- ? undefined
666
- : (summarizeOverflowRaw === '1' || summarizeOverflowRaw === 'true');
667
- // recall() owns the shape rule (NaN, 0 and negatives throw invalid_scorer_window); the transport caps remote cost.
668
- const scorerWindowRaw = query.get('scorer_window');
669
- const scorerWindow = scorerWindowRaw === null ? undefined : Number(scorerWindowRaw);
670
- if (scorerWindow !== undefined && scorerWindow > 1000) {
671
- throw new HttpError(400, 'scorer_window must be <= 1000');
672
- }
673
- // v1.7.4: session_id for the dlPFC goal-stack boost. 256-char cap mirrors
674
- // fresh_tail_session_id (above). Trim then drop if empty so api.recall
675
- // sees undefined when the param is omitted or whitespace-only.
676
- const sessionIdRaw = query.get('session_id');
677
- if (sessionIdRaw !== null && sessionIdRaw.length > 256) {
678
- throw new HttpError(400, 'session_id exceeds 256-character cap');
679
- }
680
- const sessionId = sessionIdRaw && sessionIdRaw.trim().length > 0
681
- ? sessionIdRaw.trim()
682
- : undefined;
683
- // A7 recall-trace: opt-in explain flag. When set, api.recall attaches the
684
- // lifecycle re-ranking trace (goal-boost step on the api pipeline) +
685
- // rerankPipeline:'api' to each result item; the field then rides on the
686
- // serialized RecallResult. Mirrors the include_continuity convention.
687
- const explainRaw = query.get('explain');
688
- const explain = explainRaw === '1' || explainRaw === 'true';
689
- const ctx = await buildContextWithAuth(req, opts);
690
- // v0.33 / J1 — HTTP per-pipeline anchoring detector. HTTP threads its
691
- // ring snapshot via opts.recallHistory so api.recall's own
692
- // anchoringHint compute path activates. Unlike CLI (which computes
693
- // its own hint separately because cmdRecall runs its own physics/
694
- // hybrid pipeline outside api.recall), HTTP's /v1/memories response
695
- // body IS api.recall's result directly. So the api.recall-computed
696
- // hint flows through. HIPPO_ANCHORING=off short-circuits.
697
- let httpRecallHistory;
698
- let httpRingKey;
699
- if (biasHintEnabled('anchoring')) {
700
- if (sessionId) {
701
- // Codex round-5 P2 catch: do NOT mutate sessionRecallHistoryHttp
702
- // before recall() preflight runs. A request with an invalid
703
- // scorer_window / fresh_tail_count would create-or-touch the
704
- // session ring (LRU-evicting valid sessions) even though recall
705
- // throws 400. Snapshot the EXISTING ring if present; only
706
- // create-or-touch after the recall returns successfully.
707
- httpRingKey = buildSessionKey(ctx.tenantId, sessionId);
708
- const existingRing = sessionRecallHistoryHttp.get(httpRingKey);
709
- httpRecallHistory = existingRing ? snapshotRing(existingRing) : [];
710
- }
711
- else {
712
- // Telemetry: caller had no session_id so ring tracking skipped.
713
- // Per the normal recall-audit convention (api.ts:854 stores
714
- // SHA-256/16 hash of the query, NOT raw text), avoid retaining
715
- // prompts in audit_log here too — query content can contain
716
- // secrets, PII, or RTBF-restricted material. Codex round-2 P2
717
- // catch: hashQueryText is a 32-bit FNV-1a designed for recall
718
- // matching, NOT a privacy hash; brute-force trivial for low-
719
- // entropy queries. Use the same SHA-256/16 truncation as the
720
- // canonical recall audit.
721
- const dbForAudit = openHippoDb(opts.hippoRoot);
722
- try {
723
- appendAuditEvent(dbForAudit, {
724
- tenantId: ctx.tenantId,
725
- actor: ctx.actor.subject,
726
- op: 'recall_anchor_skipped_no_session',
727
- targetId: undefined,
728
- metadata: auditQueryFields(q),
729
- });
730
- }
731
- finally {
732
- closeHippoDb(dbForAudit);
733
- }
734
- }
735
- }
736
- const recallExtra = {};
737
- if (freshTailCount !== undefined)
738
- recallExtra.freshTailCount = freshTailCount;
739
- if (freshTailSessionId !== undefined)
740
- recallExtra.freshTailSessionId = freshTailSessionId;
741
- if (summarizeOverflow !== undefined)
742
- recallExtra.summarizeOverflow = summarizeOverflow;
743
- if (scorerWindow !== undefined)
744
- recallExtra.scorerWindow = scorerWindow;
745
- if (sessionId !== undefined)
746
- recallExtra.sessionId = sessionId;
747
- if (httpRecallHistory !== undefined)
748
- recallExtra.recallHistory = httpRecallHistory;
749
- if (explain)
750
- recallExtra.explain = explain;
751
- const result = await retrieve(ctx, {
752
- query: q,
753
- limit,
754
- mode: mode ?? undefined,
755
- scope: scope ?? undefined,
756
- includeContinuity,
757
- ...recallExtra,
758
- });
759
- // v0.33 / J1 — append AFTER recall completes (snapshot was taken before
760
- // recall() ran). anchoredOn carries the memoryId of any hint that fired
761
- // (api.recall computed it from the same snapshot we passed in), feeding
762
- // the cooldown logic for the NEXT recall on this session.
763
- // Codex round-5 P2 fix: create-or-touch the ring ONLY HERE, after recall
764
- // returns successfully. Invalid requests that throw 400 in recall()
765
- // never reach this point, so they cannot LRU-evict valid sessions.
766
- if (httpRingKey) {
767
- const httpRing = getOrCreateRing(sessionRecallHistoryHttp, httpRingKey);
768
- const topId = result.results[0]?.id ?? null;
769
- appendRecall(httpRing, hashQueryText(q), topId, result.anchoringHint?.memoryId);
770
- }
771
- // Each recall surface counts its own hits; api.recall is no chokepoint,
772
- // since the CLI never calls it and MCP shows the user a different band.
773
- updateStats(opts.hippoRoot, { recalled: result.results.length });
774
- // Continuity payloads should never be cached. The caller is asking for
775
- // session-state-aware data; intermediaries must not reuse it across users.
776
- if (includeContinuity) {
777
- res.setHeader('Cache-Control', 'no-store');
778
- }
779
- recordTokens(ctx, 'http_recall', { items: result.results.length, tokens: result.tokens + (result.continuityTokens ?? 0), sessionId: sessionId ?? null });
780
- sendJson(res, 200, result);
781
- return;
782
- }
783
- // GET /v1/sessions/:id/assemble?budget=N&freshTail=N&summarizeOlder=0|1
784
- // Phase 2 context-engine API. Returns ordered AssembledContextItem[]
785
- // with fresh-tail raws + summary substitutions + bio-aware budget fit.
786
- // Tenant scope from Bearer; default-deny on private rows.
787
- async function handleAssembleSession({ req, res, opts, query }, assembleMatch) {
788
- validateIdSegment(assembleMatch.id, 'session id');
789
- const budgetRaw = query.get('budget');
790
- const budget = budgetRaw === null ? undefined : Number(budgetRaw);
791
- if (budget !== undefined && (!Number.isFinite(budget) || budget <= 0)) {
792
- throw new HttpError(400, 'budget must be a positive number');
793
- }
794
- const ftRaw = query.get('freshTail');
795
- const freshTailCount = ftRaw === null ? undefined : Number(ftRaw);
796
- if (freshTailCount !== undefined && (!Number.isFinite(freshTailCount) || freshTailCount < 0)) {
797
- throw new HttpError(400, 'freshTail must be a non-negative number');
798
- }
799
- // v1.6.3 senior review P1: same strict-parse convention as the v1.6.3
800
- // summarize_overflow tighten on /v1/memories. Pre-v1.6.3 accepted any
801
- // non-'0'/'false' as true; ?summarizeOlder=banana now correctly returns
802
- // false (matches includeContinuity convention).
803
- const sumOlderRaw = query.get('summarizeOlder');
804
- const summarizeOlder = sumOlderRaw === null
805
- ? undefined
806
- : (sumOlderRaw === '1' || sumOlderRaw === 'true');
807
- const scopeQ = query.get('scope');
808
- const scope = scopeQ !== null && scopeQ.length > 0 ? scopeQ : undefined;
809
- const ctx = await buildContextWithAuth(req, opts);
810
- const assembleExtra = {};
811
- if (budget !== undefined)
812
- assembleExtra.budget = budget;
813
- if (freshTailCount !== undefined)
814
- assembleExtra.freshTailCount = freshTailCount;
815
- if (summarizeOlder !== undefined)
816
- assembleExtra.summarizeOlder = summarizeOlder;
817
- if (scope !== undefined)
818
- assembleExtra.scope = scope;
819
- const result = assemble(ctx, assembleMatch.id, { ...assembleExtra, cost: assembleCost(assembleMatch.id) });
820
- recordTokens(ctx, 'http_assemble', { items: result.items.length, tokens: result.tokens, sessionId: assembleMatch.id });
821
- sendJson(res, 200, result);
822
- return;
823
- }
824
- // GET /v1/recall/drill/:id?limit=N&budget=N
825
- // Companion to /v1/memories. When recall surfaces a level-2 summary in
826
- // place of overflowed children (RecallResultItem.isSummary === true), the
827
- // caller drills into the summary id to recover the originals. Tenant
828
- // scoped via Bearer; default-deny on private scopes for both summary
829
- // and children.
830
- async function handleDrillRecall({ req, res, opts, query }, drillMatch) {
831
- validateIdSegment(drillMatch.id, 'summary id');
832
- const limitRaw = query.get('limit');
833
- const limit = limitRaw === null ? undefined : Number(limitRaw);
834
- if (limit !== undefined && (!Number.isFinite(limit) || limit <= 0)) {
835
- throw new HttpError(400, 'limit must be a positive number');
836
- }
837
- const budgetRaw = query.get('budget');
838
- const budget = budgetRaw === null ? undefined : Number(budgetRaw);
839
- if (budget !== undefined && (!Number.isFinite(budget) || budget <= 0)) {
840
- throw new HttpError(400, 'budget must be a positive number');
841
- }
842
- // v0.30 / E5: depth query param walks N levels (default 1, hard cap 10).
843
- const depthRaw = query.get('depth');
844
- let depth;
845
- if (depthRaw !== null) {
846
- const parsed = Number(depthRaw);
847
- // L4 fold: reject out-of-range explicitly (no silent clamp).
848
- if (!Number.isInteger(parsed) || parsed < 1 || parsed > 10) {
849
- throw new HttpError(400, 'depth must be a positive integer between 1 and 10');
850
- }
851
- depth = parsed;
852
- }
853
- const ctx = await buildContextWithAuth(req, opts);
854
- const drillExtra = {};
855
- if (limit !== undefined)
856
- drillExtra.limit = limit;
857
- if (budget !== undefined)
858
- drillExtra.budget = budget;
859
- if (depth !== undefined)
860
- drillExtra.depth = depth;
861
- const result = drillDown(ctx, drillMatch.id, { ...drillExtra, cost: drillCost });
862
- if ('failure' in result) {
863
- // v1.6.4: leaf id maps to 422 (caller-actionable). Other cases stay
864
- // as 404 to avoid leaking cross-tenant existence or scope grants.
865
- if (result.failure === 'not_drillable') {
866
- throw new HttpError(422, 'Id is a leaf row, not a level-2+ summary; nothing to drill into');
867
- }
868
- throw new HttpError(404, 'No drillable summary at this id');
869
- }
870
- sendJson(res, 200, result);
871
- return;
872
- }
873
- // /v1/memories/:id/* and DELETE /v1/memories/:id
874
- async function handleArchiveMemory({ req, res, opts }, archiveMatch) {
875
- validateIdSegment(archiveMatch.id, 'memory id');
876
- const body = await parseJsonBody(req);
877
- const reason = getString(body, 'reason');
878
- if (!reason) {
879
- throw new HttpError(400, 'reason is required');
880
- }
881
- const ctx = await buildContextWithAuth(req, opts);
882
- const result = archiveRaw(ctx, archiveMatch.id, reason);
883
- sendJson(res, 200, result);
884
- return;
885
- }
886
- async function handleSupersedeMemory({ req, res, opts }, supersedeMatch) {
887
- validateIdSegment(supersedeMatch.id, 'memory id');
888
- const body = await parseJsonBody(req);
889
- const content = getString(body, 'content');
890
- if (!content) {
891
- throw new HttpError(400, 'content is required');
892
- }
893
- const ctx = await buildContextWithAuth(req, opts);
894
- const result = supersede(ctx, supersedeMatch.id, content);
895
- sendJson(res, 200, result);
896
- return;
897
- }
898
- async function handlePromoteMemory({ req, res, opts }, promoteMatch) {
899
- validateIdSegment(promoteMatch.id, 'memory id');
900
- const ctx = await buildContextWithAuth(req, opts);
901
- const result = promote(ctx, promoteMatch.id);
902
- sendJson(res, 200, result);
903
- return;
904
- }
905
- async function handleForgetMemory({ req, res, opts }, idMatch) {
906
- validateIdSegment(idMatch.id, 'memory id');
907
- const ctx = await buildContextWithAuth(req, opts);
908
- const result = forget(ctx, idMatch.id);
909
- sendJson(res, 200, result);
910
- return;
911
- }
912
- // POST /v1/outcome — apply a positive/negative outcome to memory ids.
913
- // Body: {ids?: string[], good: boolean}. If ids omitted, falls back to
914
- // the last-recall path (api.outcomeForLastRecall); returned shape is
915
- // {applied, ids} in that case so callers can disambiguate "no recent
916
- // recall" from "all ids skipped". Each applied id writes one audit_log
917
- // row (op='outcome', actor from Bearer).
918
- async function handleApplyOutcome({ req, res, opts }) {
919
- const body = await parseJsonBody(req);
920
- const good = body['good'];
921
- if (!isJsonBoolean(good)) {
922
- throw new HttpError(400, 'good is required (boolean)');
923
- }
924
- const idsRaw = body['ids'];
925
- let ids;
926
- if (idsRaw !== undefined) {
927
- if (!Array.isArray(idsRaw)) {
928
- throw new HttpError(400, 'ids must be an array of non-empty strings');
929
- }
930
- const isNonEmptyId = (item) => isJsonString(item) && item.length > 0;
931
- if (!idsRaw.every(isNonEmptyId)) {
932
- throw new HttpError(400, 'ids must be an array of non-empty strings');
933
- }
934
- // v1.11.5: DoS cap on ids.length. Each id triggers ~3 DB ops (readEntry +
935
- // writeEntry + appendAuditEvent). N=1000 keeps per-request work bounded
936
- // to sub-second wall time on SQLite hot path. Cap BEFORE buildContextWithAuth
937
- // so attack traffic doesn't pay the api-key lookup cost.
938
- if (idsRaw.length > 1000) {
939
- throw new HttpError(400, 'ids exceeds 1000-id cap');
940
- }
941
- ids = idsRaw;
942
- }
943
- const ctx = await buildContextWithAuth(req, opts);
944
- if (ids !== undefined) {
945
- const { applied } = outcome(ctx, ids, good);
946
- sendJson(res, 200, { applied });
947
- }
948
- else {
949
- const result = outcomeForLastRecall(ctx, good);
950
- sendJson(res, 200, result);
951
- }
952
- return;
953
- }
954
- // GET /v1/context — assemble a budget-bounded context bundle. Returns
955
- // ContextResult JSON (entries + tokens + activeSnapshot + sessionHandoff
956
- // + recentEvents). No server-side rendering; clients render. Tenant-scoped
957
- // via the Bearer. Pinned-only + '*' fallback skip the recall audit emit
958
- // (matches cmdContext); real-query hybrid search emits one 'recall' row.
959
- async function handleGetContext({ req, res, opts, query }) {
960
- const q = query.get('q') ?? undefined;
961
- // v1.11.5: DoS cap on q-param length. 1024 covers real multi-clause queries
962
- // (pasted error messages, multi-stem searches) while bounding BM25
963
- // tokenisation cost (~150 tokens worst case at 1024 chars).
964
- if (q !== undefined && q.length > 1024) {
965
- throw new HttpError(400, 'q exceeds 1024-character cap');
966
- }
967
- const budgetRaw = query.get('budget');
968
- let budget;
969
- if (budgetRaw !== null) {
970
- budget = Number(budgetRaw);
971
- if (!Number.isFinite(budget) || budget < 0) {
972
- throw new HttpError(400, 'budget must be a non-negative number');
973
- }
974
- }
975
- const limitRaw = query.get('limit');
976
- let limit;
977
- if (limitRaw !== null) {
978
- limit = Number(limitRaw);
979
- if (!Number.isFinite(limit) || limit <= 0) {
980
- throw new HttpError(400, 'limit must be a positive number');
981
- }
982
- }
983
- const pinnedOnlyRaw = query.get('pinned_only');
984
- const pinnedOnly = pinnedOnlyRaw === '1' || pinnedOnlyRaw === 'true';
985
- const scopeRaw = query.get('scope');
986
- if (scopeRaw !== null && scopeRaw.length > 256) {
987
- throw new HttpError(400, 'scope exceeds 256-character cap');
988
- }
989
- const scope = scopeRaw === null ? undefined : scopeRaw;
990
- const includeRecentRaw = query.get('include_recent');
991
- let includeRecent;
992
- if (includeRecentRaw !== null) {
993
- includeRecent = Number(includeRecentRaw);
994
- if (!Number.isFinite(includeRecent) || includeRecent < 0) {
995
- throw new HttpError(400, 'include_recent must be a non-negative number');
996
- }
997
- }
998
- // v39 memory scope isolation: cross_project=1|true re-includes
999
- // other-project rows (tagged category 'cross-project' in the response).
1000
- // The partition identity comes from the SERVED STORE's location, not the
1001
- // daemon's process cwd - a daemon started from anywhere still isolates
1002
- // the project it serves.
1003
- const crossProjectRaw = query.get('cross_project');
1004
- const crossProject = crossProjectRaw === '1' || crossProjectRaw === 'true';
1005
- const ctx = await buildContextWithAuth(req, opts);
1006
- const result = await getContext(ctx, {
1007
- q,
1008
- budget,
1009
- limit,
1010
- pinnedOnly,
1011
- scope,
1012
- includeRecent,
1013
- crossProject,
1014
- currentProject: resolveProjectIdentity(dirname(resolve(opts.hippoRoot))).name,
1015
- cost: contextCost('markdown', 'observe'), // clients render; the budget prices the block `hippo context` would print
1016
- });
1017
- recordTokens(ctx, 'http_context', { items: result.entries.length, tokens: result.tokens });
1018
- sendJson(res, 200, result);
1019
- return;
1020
- }
1021
- // POST /v1/sleep — host-wide consolidation pipeline (consolidate + dedup +
1022
- // audit + share + ambient). serve() refuses non-loopback hosts at boot, AND
1023
- // this per-request loopback assertion makes the host-wide semantic fail-
1024
- // closed regardless of any future serve() boot-config change. Body:
1025
- // {dry_run?, no_share?}. Returns SleepResult JSON.
1026
- //
1027
- // Tenant scope (Episode A follow-up tracked in TODOS.md): api.sleep operates
1028
- // on the WHOLE hippoRoot (cross-tenant by design, matching CLI cmdSleep).
1029
- // The loopback-only guard is the trust boundary today. Future non-loopback
1030
- // serving must also zero the cross-tenant counters for other tenants
1031
- // (D1 in docs/decisions/2026-05-24-blocked-items.md).
1032
- async function handleSleep({ req, res, opts }) {
1033
- // Defensive per-request loopback guard. Uses the canonical isLoopback()
1034
- // helper above so any future extension (additional mapped/IPv6 forms,
1035
- // NAT64 prefixes) flows through without drift. serve()'s boot-time host
1036
- // check is the primary trust boundary; this is belt-and-suspenders.
1037
- if (!isLoopback(req.socket.remoteAddress)) {
1038
- throw new HttpError(403, '/v1/sleep is loopback-only (host-wide consolidation; see CHANGELOG v1.11.4)');
1039
- }
1040
- // v1.12.0 A5 v2 sub-1: admin-role gate. Forward-defensive — exists today
1041
- // under loopback-only enforcement (loopback fallback is admin by default;
1042
- // any Bearer-authed caller now carries an explicit role from the api_keys
1043
- // row). When non-loopback serving lands, this gate is the actual auth
1044
- // boundary on host-wide sleep.
1045
- const sleepCtx = await buildContextWithAuth(req, opts);
1046
- // Sleep consolidates every tenant under hippoRoot, so it is a cross-tenant action.
1047
- assertCrossTenantAdmin(sleepCtx, '/v1/sleep');
1048
- const body = await parseJsonBody(req);
1049
- const dryRunRaw = body['dry_run'];
1050
- if (dryRunRaw !== undefined && !isJsonBoolean(dryRunRaw)) {
1051
- throw new HttpError(400, 'dry_run must be a boolean');
1052
- }
1053
- const noShareRaw = body['no_share'];
1054
- if (noShareRaw !== undefined && !isJsonBoolean(noShareRaw)) {
1055
- throw new HttpError(400, 'no_share must be a boolean');
1056
- }
1057
- // v1.12.0: sleepCtx already built above for the admin-role gate; reuse.
1058
- const result = await sleep(sleepCtx, {
1059
- dryRun: dryRunRaw === true,
1060
- noShare: noShareRaw === true,
1061
- });
1062
- sendJson(res, 200, result);
1063
- return;
1064
- }
1065
- // POST /v1/auth/keys — mint a new API key. Plaintext lands in the response
1066
- // body (Task 8): the HTTP layer hands it to the client; the user-facing
1067
- // "store this somewhere safe" warning belongs in the CLI client, not here.
1068
- async function handleCreateAuthKey({ req, res, opts }) {
1069
- const body = await parseJsonBody(req);
1070
- const labelRaw = body['label'];
1071
- if (labelRaw !== undefined && !isJsonString(labelRaw)) {
1072
- throw new HttpError(400, 'label must be a string');
1073
- }
1074
- // v1.12.3: optional body.role mirrors the --role CLI flag. Validated
1075
- // strictly — anything other than 'admin'|'member' is a 400 (no silent
1076
- // fallback to admin). authCreate refuses a member caller with a 403.
1077
- const roleRaw = body['role'];
1078
- let role;
1079
- if (roleRaw !== undefined) {
1080
- if (roleRaw !== 'admin' && roleRaw !== 'member') {
1081
- throw new HttpError(400, "role must be 'admin' or 'member'");
1082
- }
1083
- role = roleRaw;
1084
- }
1085
- // Security: any `tenantId` in the body is IGNORED. The minted key is
1086
- // bound to the caller's authenticated tenant (ctx.tenantId, resolved
1087
- // from the Bearer token). Forwarding body.tenantId here would let
1088
- // tenant A mint a key for tenant B — see authCreate doc comment.
1089
- const ctx = await buildContextWithAuth(req, opts);
1090
- const result = authCreate(ctx, {
1091
- label: labelRaw,
1092
- role,
1093
- });
1094
- sendJson(res, 200, result);
1095
- return;
1096
- }
1097
- // GET /v1/auth/keys?active=true — list keys visible to ctx.tenantId.
1098
- // `active` defaults to true so the common case (show me usable keys) is
1099
- // a single GET; ?active=false includes revoked rows.
1100
- async function handleListAuthKeys({ req, res, opts, query }) {
1101
- const activeRaw = query.get('active');
1102
- let active = true;
1103
- if (activeRaw !== null) {
1104
- if (activeRaw === 'true')
1105
- active = true;
1106
- else if (activeRaw === 'false')
1107
- active = false;
1108
- else
1109
- throw new HttpError(400, "active must be 'true' or 'false'");
1110
- }
1111
- const ctx = await buildContextWithAuth(req, opts);
1112
- const result = authList(ctx, { active });
1113
- sendJson(res, 200, result);
1114
- return;
1115
- }
1116
- // DELETE /v1/auth/keys/:keyId — revoke. Missing or cross-tenant keys are 404
1117
- // (no info leak); a member key targeting any key but its own is 403.
1118
- // 200 with the body rather than 204 so the caller sees revokedAt.
1119
- async function handleRevokeAuthKey({ req, res, opts }, keyMatch) {
1120
- validateIdSegment(keyMatch.keyId, 'key id');
1121
- const ctx = await buildContextWithAuth(req, opts);
1122
- const result = authRevoke(ctx, keyMatch.keyId);
1123
- sendJson(res, 200, result);
1124
- return;
1125
- }
1126
- // GET /v1/quarantine?status=: CD5 review queue. quarantineList carries no role gate itself, so it's checked here.
1127
- async function handleListQuarantine({ req, res, opts, query }) {
1128
- const ctx = await buildContextWithAuth(req, opts);
1129
- if (ctx.actor.role !== 'admin') {
1130
- throw new HttpError(403, '/v1/quarantine requires admin role');
1131
- }
1132
- const statusRaw = query.get('status');
1133
- let status = 'pending';
1134
- if (statusRaw !== null) {
1135
- if (statusRaw !== 'pending' && statusRaw !== 'approved' && statusRaw !== 'rejected' && statusRaw !== 'all') {
1136
- throw new HttpError(400, 'status must be one of: pending | approved | rejected | all');
1137
- }
1138
- status = statusRaw;
1139
- }
1140
- sendJson(res, 200, { quarantine: quarantineList(ctx, { status }) });
1141
- return;
1142
- }
1143
- // POST /v1/quarantine/:id/approve: admin only; ForbiddenError falls through to mapApiError's 403.
1144
- async function handleApproveQuarantine({ req, res, opts }, quarantineApproveMatch) {
1145
- validateIdSegment(quarantineApproveMatch.id, 'memory id');
1146
- const ctx = await buildContextWithAuth(req, opts);
1147
- quarantineApprove(ctx, quarantineApproveMatch.id);
1148
- sendJson(res, 200, { approved: quarantineApproveMatch.id });
1149
- return;
1150
- }
1151
- // POST /v1/quarantine/:id/reject: admin only; ForbiddenError falls through to mapApiError's 403.
1152
- async function handleRejectQuarantine({ req, res, opts }, quarantineRejectMatch) {
1153
- validateIdSegment(quarantineRejectMatch.id, 'memory id');
1154
- const ctx = await buildContextWithAuth(req, opts);
1155
- quarantineReject(ctx, quarantineRejectMatch.id);
1156
- sendJson(res, 200, { rejected: quarantineRejectMatch.id });
1157
- return;
1158
- }
1159
- // GET /v1/audit?op=&since=&limit= — read audit events. All three filters
1160
- // validated at the route boundary so an invalid value lands a 400 before
1161
- // we hit the DB.
1162
- async function handleListAudit({ req, res, opts, query }) {
1163
- const opRaw = query.get('op');
1164
- let op;
1165
- if (opRaw !== null) {
1166
- if (!isSetMember(VALID_AUDIT_OPS, opRaw)) {
1167
- throw new HttpError(400, `invalid op: ${opRaw}`);
1168
- }
1169
- op = opRaw;
1170
- }
1171
- const sinceRaw = query.get('since');
1172
- let since;
1173
- if (sinceRaw !== null) {
1174
- const parsed = Date.parse(sinceRaw);
1175
- if (!Number.isFinite(parsed)) {
1176
- throw new HttpError(400, `invalid since: ${sinceRaw}`);
1177
- }
1178
- since = sinceRaw;
1179
- }
1180
- const limitRaw = query.get('limit');
1181
- let limit;
1182
- if (limitRaw !== null) {
1183
- const parsed = Number(limitRaw);
1184
- if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1 || parsed > MAX_AUDIT_LIMIT) {
1185
- throw new HttpError(400, `limit must be an integer between 1 and ${MAX_AUDIT_LIMIT}`);
1186
- }
1187
- limit = parsed;
1188
- }
1189
- const ctx = await buildContextWithAuth(req, opts);
1190
- // ?tenant=<t> reads another tenant (e.g. '__host__' for consolidate rows); admin only.
1191
- const tenantOverride = query.get('tenant');
1192
- const crossTenant = tenantOverride !== null && tenantOverride !== '' && tenantOverride !== ctx.tenantId;
1193
- if (crossTenant)
1194
- assertCrossTenantAdmin(ctx, '/v1/audit?tenant= for another tenant');
1195
- const effectiveCtx = crossTenant ? { ...ctx, tenantId: tenantOverride } : ctx;
1196
- const result = auditList(effectiveCtx, { op, since, limit });
1197
- sendJson(res, 200, result);
1198
- return;
1199
- }
1200
- // ── E2 prediction first-class object (v0.31) ──
1201
- // docs/plans/2026-05-26-e2-prediction-object.md
1202
- //
1203
- // 4 routes: POST /v1/predictions (create), GET /v1/predictions (list),
1204
- // GET /v1/predictions/:id (show), POST /v1/predictions/:id/close (close).
1205
- // All Bearer-authed + tenant-scoped via buildContextWithAuth. closure_state
1206
- // validated against VALID_CLOSURE_STATES (3 states). DoS caps on claim
1207
- // (4096 chars) + closureNote (2048 chars) per v1.11.4 pattern.
1208
- async function handleCreatePrediction({ req, res, opts }) {
1209
- const body = await parseJsonBody(req);
1210
- const claim = body['claim'];
1211
- if (!isJsonString(claim) || claim.length === 0) {
1212
- throw new HttpError(400, 'claim is required (non-empty string)');
1213
- }
1214
- if (claim.length > 4096) {
1215
- throw new HttpError(400, 'claim exceeds 4096-character cap');
1216
- }
1217
- const classTag = body['classTag'];
1218
- if (!isJsonString(classTag) || classTag.length === 0) {
1219
- throw new HttpError(400, 'classTag is required (non-empty string)');
1220
- }
1221
- const estimate = body['estimate'];
1222
- let estimateValue;
1223
- if (estimate !== undefined && estimate !== null) {
1224
- if (!isJsonNumber(estimate) || !Number.isFinite(estimate)) {
1225
- throw new HttpError(400, 'estimate must be a finite number');
1226
- }
1227
- estimateValue = estimate;
1228
- }
1229
- const unit = body['unit'];
1230
- let estimateUnit;
1231
- if (unit !== undefined && unit !== null) {
1232
- if (!isJsonString(unit)) {
1233
- throw new HttpError(400, 'unit must be a string');
1234
- }
1235
- estimateUnit = unit;
1236
- }
1237
- const targetDate = body['targetDate'];
1238
- let targetDateValue;
1239
- if (targetDate !== undefined && targetDate !== null) {
1240
- if (!isJsonString(targetDate)) {
1241
- throw new HttpError(400, 'targetDate must be an ISO date string');
1242
- }
1243
- targetDateValue = targetDate;
1244
- }
1245
- const ctx = await buildContextWithAuth(req, opts);
1246
- const prediction = savePrediction(opts.hippoRoot, ctx.tenantId, {
1247
- classTag,
1248
- claimText: claim,
1249
- estimateValue,
1250
- estimateUnit,
1251
- targetDate: targetDateValue,
1252
- }, ctx.actor.subject);
1253
- sendJson(res, 201, { prediction });
1254
- return;
1255
- }
1256
- async function handleListPredictions({ req, res, opts, query }) {
1257
- const classTag = query.get('class') ?? undefined;
1258
- const status = query.get('status') ?? 'all';
1259
- const limit = parseListLimit(query.get('limit'));
1260
- const ctx = await buildContextWithAuth(req, opts);
1261
- let predictions;
1262
- if (status === 'all') {
1263
- if (classTag) {
1264
- predictions = loadPredictionsByClass(opts.hippoRoot, ctx.tenantId, classTag, { limit });
1265
- }
1266
- else {
1267
- predictions = loadOpenPredictions(opts.hippoRoot, ctx.tenantId, { limit });
1268
- }
1269
- }
1270
- else if (status === 'open') {
1271
- predictions = loadOpenPredictions(opts.hippoRoot, ctx.tenantId, {
1272
- classTag: classTag || undefined,
1273
- limit,
1274
- });
1275
- }
1276
- else {
1277
- if (!isSetMember(VALID_CLOSURE_STATES, status)) {
1278
- throw new HttpError(400, `status must be one of: open | closed | closed-unknown | all (got "${status}")`);
1279
- }
1280
- if (!classTag) {
1281
- throw new HttpError(400, 'status filter (non-open) requires class param');
1282
- }
1283
- predictions = loadPredictionsByClass(opts.hippoRoot, ctx.tenantId, classTag, {
1284
- closureState: status,
1285
- limit,
1286
- });
1287
- }
1288
- sendJson(res, 200, { predictions });
1289
- return;
1290
- }
1291
- // J3 reference-class / planning-fallacy detector (v0.31).
1292
- // Order matters: this must match BEFORE /v1/predictions/:id since 'stats'
1293
- // is not a number — the :id regex requires \d+ so they don't conflict,
1294
- // but routing this first avoids the dispatch order risk.
1295
- async function handlePredictionStats({ req, res, opts, query }) {
1296
- const classTag = query.get('class');
1297
- if (!classTag || classTag.length === 0) {
1298
- throw new HttpError(400, 'class param is required');
1299
- }
1300
- if (classTag.length > 256) {
1301
- throw new HttpError(400, 'class exceeds 256-character cap');
1302
- }
1303
- const ctx = await buildContextWithAuth(req, opts);
1304
- const baserate = computePredictionBaserate(opts.hippoRoot, ctx.tenantId, classTag, ctx.actor.subject);
1305
- sendJson(res, 200, { baserate });
1306
- return;
1307
- }
1308
- async function handleGetPrediction({ req, res, opts }, predictionByIdMatch) {
1309
- const id = parseInt(predictionByIdMatch[1], 10);
1310
- const ctx = await buildContextWithAuth(req, opts);
1311
- const prediction = loadPredictionById(opts.hippoRoot, ctx.tenantId, id);
1312
- if (!prediction) {
1313
- throw new HttpError(404, `prediction ${id} not found`);
1314
- }
1315
- sendJson(res, 200, { prediction });
1316
- return;
1317
- }
1318
- async function handleClosePrediction({ req, res, opts }, predictionCloseMatch) {
1319
- const id = parseInt(predictionCloseMatch[1], 10);
1320
- const body = await parseJsonBody(req);
1321
- const state = body['state'];
1322
- if (!isJsonString(state) || !isSetMember(VALID_CLOSURE_STATES, state) || state === 'open') {
1323
- throw new HttpError(400, 'state is required and must be one of: closed | closed-unknown');
1324
- }
1325
- const actual = body['actual'];
1326
- let actualValue;
1327
- if (actual !== undefined && actual !== null) {
1328
- if (!isJsonNumber(actual) || !Number.isFinite(actual)) {
1329
- throw new HttpError(400, 'actual must be a finite number');
1330
- }
1331
- actualValue = actual;
1332
- }
1333
- const note = body['note'];
1334
- let closureNote;
1335
- if (note !== undefined && note !== null) {
1336
- if (!isJsonString(note)) {
1337
- throw new HttpError(400, 'note must be a string');
1338
- }
1339
- if (note.length > 2048) {
1340
- throw new HttpError(400, 'note exceeds 2048-character cap');
1341
- }
1342
- closureNote = note;
1343
- }
1344
- const ctx = await buildContextWithAuth(req, opts);
1345
- const prediction = closePrediction(opts.hippoRoot, ctx.tenantId, id, {
1346
- closureState: state,
1347
- actualValue,
1348
- closureNote,
1349
- }, ctx.actor.subject);
1350
- sendJson(res, 200, { prediction });
1351
- return;
1352
- }
1353
- // ── decisions (E2 first-class object) ──
1354
- //
1355
- // 5 routes: POST /v1/decisions (create, optional supersedesDecisionId),
1356
- // GET /v1/decisions (list, status filter), GET /v1/decisions/:id (show),
1357
- // POST /v1/decisions/:id/supersede (create a successor + supersede :id),
1358
- // POST /v1/decisions/:id/close (retire). Bearer-authed + tenant-scoped via
1359
- // buildContextWithAuth. status validated against VALID_DECISION_STATES.
1360
- // DoS caps: text 4096, context 4096 (v1.11.4 pattern). The HTTP surface is
1361
- // new (no legacy --supersedes <memory-id> constraint), so it supersedes by
1362
- // table id and never weakens a memory mirror.
1363
- async function handleCreateDecision({ req, res, opts }) {
1364
- const body = await parseJsonBody(req);
1365
- const text = body['text'];
1366
- if (!isJsonString(text) || text.length === 0) {
1367
- throw new HttpError(400, 'text is required (non-empty string)');
1368
- }
1369
- if (text.length > 4096) {
1370
- throw new HttpError(400, 'text exceeds 4096-character cap');
1371
- }
1372
- const contextRaw = body['context'];
1373
- let context;
1374
- if (contextRaw !== undefined && contextRaw !== null) {
1375
- if (!isJsonString(contextRaw)) {
1376
- throw new HttpError(400, 'context must be a string');
1377
- }
1378
- if (contextRaw.length > 4096) {
1379
- throw new HttpError(400, 'context exceeds 4096-character cap');
1380
- }
1381
- context = contextRaw;
1382
- }
1383
- const supRaw = body['supersedesDecisionId'];
1384
- let supersedesDecisionId;
1385
- if (supRaw !== undefined && supRaw !== null) {
1386
- if (!isJsonNumber(supRaw) || !Number.isInteger(supRaw) || supRaw <= 0) {
1387
- throw new HttpError(400, 'supersedesDecisionId must be a positive integer');
1388
- }
1389
- supersedesDecisionId = supRaw;
1390
- }
1391
- const ctx = await buildContextWithAuth(req, opts);
1392
- try {
1393
- const decision = saveDecision(opts.hippoRoot, ctx.tenantId, {
1394
- decisionText: text,
1395
- context,
1396
- supersedesDecisionId,
1397
- }, ctx.actor.subject);
1398
- sendJson(res, 201, { decision });
1399
- }
1400
- catch (e) {
1401
- // A missing referenced row is a conflict with the create, not a missing target.
1402
- if (e instanceof NotFoundError)
1403
- throw new HttpError(409, e.message);
1404
- throw e;
1405
- }
1406
- return;
1407
- }
1408
- async function handleListDecisions({ req, res, opts, query }) {
1409
- const status = query.get('status') ?? 'all';
1410
- const limit = parseListLimit(query.get('limit'));
1411
- const ctx = await buildContextWithAuth(req, opts);
1412
- let decisions;
1413
- if (status === 'all') {
1414
- decisions = loadDecisions(opts.hippoRoot, ctx.tenantId, { limit });
1415
- }
1416
- else {
1417
- if (!isSetMember(VALID_DECISION_STATES, status)) {
1418
- throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
1419
- }
1420
- decisions = loadDecisions(opts.hippoRoot, ctx.tenantId, {
1421
- status,
1422
- limit,
1423
- });
1424
- }
1425
- sendJson(res, 200, { decisions });
1426
- return;
1427
- }
1428
- async function handleSupersedeDecision({ req, res, opts }, decisionSupersedeMatch) {
1429
- const oldId = parseInt(decisionSupersedeMatch[1], 10);
1430
- const body = await parseJsonBody(req);
1431
- const text = body['text'];
1432
- if (!isJsonString(text) || text.length === 0) {
1433
- throw new HttpError(400, 'text is required (non-empty string)');
1434
- }
1435
- if (text.length > 4096) {
1436
- throw new HttpError(400, 'text exceeds 4096-character cap');
1437
- }
1438
- const contextRaw = body['context'];
1439
- let context;
1440
- if (contextRaw !== undefined && contextRaw !== null) {
1441
- if (!isJsonString(contextRaw)) {
1442
- throw new HttpError(400, 'context must be a string');
1443
- }
1444
- if (contextRaw.length > 4096) {
1445
- throw new HttpError(400, 'context exceeds 4096-character cap');
1446
- }
1447
- context = contextRaw;
1448
- }
1449
- const ctx = await buildContextWithAuth(req, opts);
1450
- const decision = saveDecision(opts.hippoRoot, ctx.tenantId, {
1451
- decisionText: text,
1452
- context,
1453
- supersedesDecisionId: oldId,
1454
- }, ctx.actor.subject);
1455
- sendJson(res, 201, { decision });
1456
- return;
1457
- }
1458
- async function handleCloseDecision({ req, res, opts }, decisionCloseMatch) {
1459
- const id = parseInt(decisionCloseMatch[1], 10);
1460
- const ctx = await buildContextWithAuth(req, opts);
1461
- const decision = closeDecision(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
1462
- sendJson(res, 200, { decision });
1463
- return;
1464
- }
1465
- async function handleGetDecision({ req, res, opts }, decisionByIdMatch) {
1466
- const id = parseInt(decisionByIdMatch[1], 10);
1467
- const ctx = await buildContextWithAuth(req, opts);
1468
- const decision = loadDecisionById(opts.hippoRoot, ctx.tenantId, id);
1469
- if (!decision) {
1470
- throw new HttpError(404, `decision ${id} not found`);
1471
- }
1472
- sendJson(res, 200, { decision });
1473
- return;
1474
- }
1475
- // ── incidents (E2 first-class object) ──
1476
- //
1477
- // 5 routes: POST /v1/incidents (open; body text + context + linkedMemoryIds[]),
1478
- // GET /v1/incidents (list, status filter), GET /v1/incidents/:id (show),
1479
- // POST /v1/incidents/:id/resolve (open -> resolved; body resolutionText),
1480
- // POST /v1/incidents/:id/close (open|resolved -> closed). Bearer-authed +
1481
- // tenant-scoped via buildContextWithAuth. status validated against
1482
- // VALID_INCIDENT_STATES. DoS caps: text 4096, context 4096, resolutionText
1483
- // 4096 (v1.11.4 pattern). Mirrors /v1/decisions; lifecycle is
1484
- // open->resolved->closed (no supersede), so linkedMemoryIds replaces
1485
- // supersedesDecisionId on create.
1486
- async function handleCreateIncident({ req, res, opts }) {
1487
- const body = await parseJsonBody(req);
1488
- const text = body['text'];
1489
- if (!isJsonString(text) || text.length === 0) {
1490
- throw new HttpError(400, 'text is required (non-empty string)');
1491
- }
1492
- if (text.length > 4096) {
1493
- throw new HttpError(400, 'text exceeds 4096-character cap');
1494
- }
1495
- const contextRaw = body['context'];
1496
- let context;
1497
- if (contextRaw !== undefined && contextRaw !== null) {
1498
- if (!isJsonString(contextRaw)) {
1499
- throw new HttpError(400, 'context must be a string');
1500
- }
1501
- if (contextRaw.length > 4096) {
1502
- throw new HttpError(400, 'context exceeds 4096-character cap');
1503
- }
1504
- context = contextRaw;
1505
- }
1506
- const linkedRaw = body['linkedMemoryIds'];
1507
- let linkedMemoryIds;
1508
- if (linkedRaw !== undefined && linkedRaw !== null) {
1509
- if (!Array.isArray(linkedRaw)) {
1510
- throw new HttpError(400, 'linkedMemoryIds must be an array of memory ids');
1511
- }
1512
- if (linkedRaw.length > 256) {
1513
- throw new HttpError(400, 'linkedMemoryIds exceeds 256-item cap');
1514
- }
1515
- const isValidMemoryId = (item) => isJsonString(item) && item.length > 0 && item.length <= 4096;
1516
- if (!linkedRaw.every(isValidMemoryId)) {
1517
- throw new HttpError(400, 'each linkedMemoryIds entry must be a non-empty string <= 4096 chars');
1518
- }
1519
- linkedMemoryIds = linkedRaw;
1520
- }
1521
- const ctx = await buildContextWithAuth(req, opts);
1522
- try {
1523
- const incident = saveIncident(opts.hippoRoot, ctx.tenantId, {
1524
- incidentText: text,
1525
- context,
1526
- linkedMemoryIds,
1527
- }, ctx.actor.subject);
1528
- sendJson(res, 201, { incident });
1529
- }
1530
- catch (e) {
1531
- // A missing referenced row is a conflict with the create, not a missing target.
1532
- if (e instanceof NotFoundError)
1533
- throw new HttpError(409, e.message);
1534
- throw e;
1535
- }
1536
- return;
1537
- }
1538
- async function handleListIncidents({ req, res, opts, query }) {
1539
- const status = query.get('status') ?? 'all';
1540
- const limit = parseListLimit(query.get('limit'));
1541
- const ctx = await buildContextWithAuth(req, opts);
1542
- let incidents;
1543
- if (status === 'all') {
1544
- incidents = loadIncidents(opts.hippoRoot, ctx.tenantId, { limit });
1545
- }
1546
- else {
1547
- if (!isSetMember(VALID_INCIDENT_STATES, status)) {
1548
- throw new HttpError(400, `status must be one of: open | resolved | closed | all (got "${status}")`);
1549
- }
1550
- incidents = loadIncidents(opts.hippoRoot, ctx.tenantId, {
1551
- status,
1552
- limit,
1553
- });
1554
- }
1555
- sendJson(res, 200, { incidents });
1556
- return;
1557
- }
1558
- async function handleResolveIncident({ req, res, opts }, incidentResolveMatch) {
1559
- const id = parseInt(incidentResolveMatch[1], 10);
1560
- const body = await parseJsonBody(req);
1561
- const resolutionText = body['resolutionText'];
1562
- if (!isJsonString(resolutionText) || resolutionText.trim().length === 0) {
1563
- throw new HttpError(400, 'resolutionText is required (non-empty string)');
1564
- }
1565
- if (resolutionText.length > 4096) {
1566
- throw new HttpError(400, 'resolutionText exceeds 4096-character cap');
1567
- }
1568
- const ctx = await buildContextWithAuth(req, opts);
1569
- const incident = resolveIncident(opts.hippoRoot, ctx.tenantId, id, resolutionText, ctx.actor.subject);
1570
- sendJson(res, 200, { incident });
1571
- return;
1572
- }
1573
- async function handleCloseIncident({ req, res, opts }, incidentCloseMatch) {
1574
- const id = parseInt(incidentCloseMatch[1], 10);
1575
- const ctx = await buildContextWithAuth(req, opts);
1576
- const incident = closeIncident(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
1577
- sendJson(res, 200, { incident });
1578
- return;
1579
- }
1580
- async function handleGetIncident({ req, res, opts }, incidentByIdMatch) {
1581
- const id = parseInt(incidentByIdMatch[1], 10);
1582
- const ctx = await buildContextWithAuth(req, opts);
1583
- const incident = loadIncidentById(opts.hippoRoot, ctx.tenantId, id);
1584
- if (!incident) {
1585
- throw new HttpError(404, `incident ${id} not found`);
1586
- }
1587
- sendJson(res, 200, { incident });
1588
- return;
1589
- }
1590
- // ── processes (E2 first-class object) ──
1591
- //
1592
- // 5 routes: POST /v1/processes (new; body processName + steps[] + description),
1593
- // GET /v1/processes (list, status filter), GET /v1/processes/:id (show),
1594
- // POST /v1/processes/:id/supersede (active -> superseded by a new version; body
1595
- // steps[] + changeSummary + description; reuses the predecessor's name),
1596
- // POST /v1/processes/:id/close (active -> closed). Bearer-authed + tenant-scoped
1597
- // via buildContextWithAuth. status validated against VALID_PROCESS_STATES. DoS
1598
- // caps: processName/description/changeSummary 4096, steps 200x2000
1599
- // (validateProcessStepsBody). Mirrors /v1/decisions; the delta lifecycle is the
1600
- // decision supersede path.
1601
- async function handleCreateProcess({ req, res, opts }) {
1602
- const body = await parseJsonBody(req);
1603
- const processName = body['processName'];
1604
- if (!isJsonString(processName) || processName.trim().length === 0) {
1605
- throw new HttpError(400, 'processName is required (non-empty string)');
1606
- }
1607
- if (processName.length > 4096) {
1608
- throw new HttpError(400, 'processName exceeds 4096-character cap');
1609
- }
1610
- const steps = validateProcessStepsBody(body['steps']);
1611
- const descriptionRaw = body['description'];
1612
- let description;
1613
- if (descriptionRaw !== undefined && descriptionRaw !== null) {
1614
- if (!isJsonString(descriptionRaw)) {
1615
- throw new HttpError(400, 'description must be a string');
1616
- }
1617
- if (descriptionRaw.length > 4096) {
1618
- throw new HttpError(400, 'description exceeds 4096-character cap');
1619
- }
1620
- description = descriptionRaw;
1621
- }
1622
- const ctx = await buildContextWithAuth(req, opts);
1623
- const process = saveProcess(opts.hippoRoot, ctx.tenantId, {
1624
- processName,
1625
- steps,
1626
- description,
1627
- }, ctx.actor.subject);
1628
- sendJson(res, 201, { process });
1629
- return;
1630
- }
1631
- async function handleListProcesses({ req, res, opts, query }) {
1632
- const status = query.get('status') ?? 'all';
1633
- const limit = parseListLimit(query.get('limit'));
1634
- const ctx = await buildContextWithAuth(req, opts);
1635
- let processes;
1636
- if (status === 'all') {
1637
- processes = loadProcesses(opts.hippoRoot, ctx.tenantId, { limit });
1638
- }
1639
- else {
1640
- if (!isSetMember(VALID_PROCESS_STATES, status)) {
1641
- throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
1642
- }
1643
- processes = loadProcesses(opts.hippoRoot, ctx.tenantId, {
1644
- status,
1645
- limit,
1646
- });
1647
- }
1648
- sendJson(res, 200, { processes });
1649
- return;
1650
- }
1651
- async function handleSupersedeProcess({ req, res, opts }, processSupersedeMatch) {
1652
- const id = parseInt(processSupersedeMatch[1], 10);
1653
- const body = await parseJsonBody(req);
1654
- const steps = validateProcessStepsBody(body['steps']);
1655
- if (steps.length === 0) {
1656
- throw new HttpError(400, 'steps is required (at least one step) for a supersession');
1657
- }
1658
- const changeRaw = body['changeSummary'];
1659
- let changeSummary;
1660
- if (changeRaw !== undefined && changeRaw !== null) {
1661
- if (!isJsonString(changeRaw)) {
1662
- throw new HttpError(400, 'changeSummary must be a string');
1663
- }
1664
- if (changeRaw.length > 4096) {
1665
- throw new HttpError(400, 'changeSummary exceeds 4096-character cap');
1666
- }
1667
- changeSummary = changeRaw;
1668
- }
1669
- const descRaw = body['description'];
1670
- let description;
1671
- if (descRaw !== undefined && descRaw !== null) {
1672
- if (!isJsonString(descRaw)) {
1673
- throw new HttpError(400, 'description must be a string');
1674
- }
1675
- if (descRaw.length > 4096) {
1676
- throw new HttpError(400, 'description exceeds 4096-character cap');
1677
- }
1678
- description = descRaw;
1679
- }
1680
- const ctx = await buildContextWithAuth(req, opts);
1681
- // A supersession is a new version of the SAME process: reuse the
1682
- // predecessor's name. 404 if the target does not exist; saveProcess's
1683
- // in-SAVEPOINT preflight is the authoritative active-state check (409).
1684
- const existing = loadProcessById(opts.hippoRoot, ctx.tenantId, id);
1685
- if (!existing) {
1686
- throw new HttpError(404, `process ${id} not found`);
1687
- }
1688
- const process = saveProcess(opts.hippoRoot, ctx.tenantId, {
1689
- processName: existing.processName,
1690
- steps,
1691
- description,
1692
- changeSummary,
1693
- supersedesProcessId: id,
1694
- }, ctx.actor.subject);
1695
- sendJson(res, 200, { process });
1696
- return;
1697
- }
1698
- async function handleCloseProcess({ req, res, opts }, processCloseMatch) {
1699
- const id = parseInt(processCloseMatch[1], 10);
1700
- const ctx = await buildContextWithAuth(req, opts);
1701
- const process = closeProcess(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
1702
- sendJson(res, 200, { process });
1703
- return;
1704
- }
1705
- async function handleGetProcess({ req, res, opts }, processByIdMatch) {
1706
- const id = parseInt(processByIdMatch[1], 10);
1707
- const ctx = await buildContextWithAuth(req, opts);
1708
- const process = loadProcessById(opts.hippoRoot, ctx.tenantId, id);
1709
- if (!process) {
1710
- throw new HttpError(404, `process ${id} not found`);
1711
- }
1712
- sendJson(res, 200, { process });
1713
- return;
1714
- }
1715
- // ── policies (E2 first-class object, bi-temporal-first) ──
1716
- //
1717
- // 6 routes: POST /v1/policies (new; processName-style body policyName +
1718
- // policyText + validFrom? + validTo?), GET /v1/policies (list, status filter),
1719
- // GET /v1/policies/asof (date + optional name; the bi-temporal as-of query;
1720
- // placed BEFORE the /:id GET so the literal 'asof' is matched first), GET
1721
- // /v1/policies/:id, POST /v1/policies/:id/supersede, POST /v1/policies/:id/close.
1722
- // Date inputs are normalized + range-validated in the store; an invalid/inverted
1723
- // date throws -> 400. DoS caps: policyName/policyText/changeSummary 4096.
1724
- async function handleCreatePolicy({ req, res, opts }) {
1725
- const body = await parseJsonBody(req);
1726
- const policyName = body['policyName'];
1727
- if (!isJsonString(policyName) || policyName.trim().length === 0) {
1728
- throw new HttpError(400, 'policyName is required (non-empty string)');
1729
- }
1730
- if (policyName.length > 4096) {
1731
- throw new HttpError(400, 'policyName exceeds 4096-character cap');
1732
- }
1733
- const policyText = body['policyText'];
1734
- if (!isJsonString(policyText) || policyText.trim().length === 0) {
1735
- throw new HttpError(400, 'policyText is required (non-empty string)');
1736
- }
1737
- if (policyText.length > 4096) {
1738
- throw new HttpError(400, 'policyText exceeds 4096-character cap');
1739
- }
1740
- const validFrom = optionalDateField(body['validFrom'], 'validFrom');
1741
- const validTo = optionalDateField(body['validTo'], 'validTo');
1742
- const ctx = await buildContextWithAuth(req, opts);
1743
- const policy = savePolicy(opts.hippoRoot, ctx.tenantId, {
1744
- policyName,
1745
- policyText,
1746
- validFrom,
1747
- validTo,
1748
- }, ctx.actor.subject);
1749
- sendJson(res, 201, { policy });
1750
- return;
1751
- }
1752
- async function handleListPolicies({ req, res, opts, query }) {
1753
- const status = query.get('status') ?? 'all';
1754
- const limit = parseListLimit(query.get('limit'));
1755
- const ctx = await buildContextWithAuth(req, opts);
1756
- let policies;
1757
- if (status === 'all') {
1758
- policies = loadPolicies(opts.hippoRoot, ctx.tenantId, { limit });
1759
- }
1760
- else {
1761
- if (!isSetMember(VALID_POLICY_STATES, status)) {
1762
- throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
1763
- }
1764
- policies = loadPolicies(opts.hippoRoot, ctx.tenantId, {
1765
- status,
1766
- limit,
1767
- });
1768
- }
1769
- sendJson(res, 200, { policies });
1770
- return;
1771
- }
1772
- // The as-of query: must precede the /:id GET (literal 'asof' is non-numeric so
1773
- // the /(\d+)/ route would not match it, but order it first for clarity).
1774
- async function handlePoliciesAsOf({ req, res, opts, query }) {
1775
- const date = query.get('date');
1776
- if (date === null || date.length === 0) {
1777
- throw new HttpError(400, 'date is required (ISO-8601 valid-time)');
1778
- }
1779
- const name = query.get('name') ?? undefined;
1780
- const ctx = await buildContextWithAuth(req, opts);
1781
- const policies = loadPoliciesAsOf(opts.hippoRoot, ctx.tenantId, date, { name });
1782
- sendJson(res, 200, { policies });
1783
- return;
1784
- }
1785
- async function handleSupersedePolicy({ req, res, opts }, policySupersedeMatch) {
1786
- const id = parseInt(policySupersedeMatch[1], 10);
1787
- const body = await parseJsonBody(req);
1788
- const policyText = body['policyText'];
1789
- if (!isJsonString(policyText) || policyText.trim().length === 0) {
1790
- throw new HttpError(400, 'policyText is required (non-empty string)');
1791
- }
1792
- if (policyText.length > 4096) {
1793
- throw new HttpError(400, 'policyText exceeds 4096-character cap');
1794
- }
1795
- const validFrom = optionalDateField(body['validFrom'], 'validFrom');
1796
- const validTo = optionalDateField(body['validTo'], 'validTo');
1797
- const changeRaw = body['changeSummary'];
1798
- let changeSummary;
1799
- if (changeRaw !== undefined && changeRaw !== null) {
1800
- if (!isJsonString(changeRaw)) {
1801
- throw new HttpError(400, 'changeSummary must be a string');
1802
- }
1803
- if (changeRaw.length > 4096) {
1804
- throw new HttpError(400, 'changeSummary exceeds 4096-character cap');
1805
- }
1806
- changeSummary = changeRaw;
1807
- }
1808
- const ctx = await buildContextWithAuth(req, opts);
1809
- const existing = loadPolicyById(opts.hippoRoot, ctx.tenantId, id);
1810
- if (!existing) {
1811
- throw new HttpError(404, `policy ${id} not found`);
1812
- }
1813
- const policy = savePolicy(opts.hippoRoot, ctx.tenantId, {
1814
- policyName: existing.policyName,
1815
- policyText,
1816
- validFrom,
1817
- validTo,
1818
- changeSummary,
1819
- supersedesPolicyId: id,
1820
- }, ctx.actor.subject);
1821
- sendJson(res, 200, { policy });
1822
- return;
1823
- }
1824
- async function handleClosePolicy({ req, res, opts }, policyCloseMatch) {
1825
- const id = parseInt(policyCloseMatch[1], 10);
1826
- const ctx = await buildContextWithAuth(req, opts);
1827
- const policy = closePolicy(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
1828
- sendJson(res, 200, { policy });
1829
- return;
1830
- }
1831
- async function handleGetPolicy({ req, res, opts }, policyByIdMatch) {
1832
- const id = parseInt(policyByIdMatch[1], 10);
1833
- const ctx = await buildContextWithAuth(req, opts);
1834
- const policy = loadPolicyById(opts.hippoRoot, ctx.tenantId, id);
1835
- if (!policy) {
1836
- throw new HttpError(404, `policy ${id} not found`);
1837
- }
1838
- sendJson(res, 200, { policy });
1839
- return;
1840
- }
1841
- // ── skills (E2 first-class object, executable/exportable) ──
1842
- //
1843
- // 6 routes: POST /v1/skills (new; body skillName + instructions + trigger?),
1844
- // GET /v1/skills (list, status filter; shared parseListLimit), GET
1845
- // /v1/skills/export (renders ACTIVE skills as an AGENTS.md/CLAUDE.md markdown
1846
- // block -> {markdown}; literal 'export' is non-numeric so the /:id (\d+) route
1847
- // cannot capture it, but it is ordered first regardless), GET /v1/skills/:id,
1848
- // POST /v1/skills/:id/supersede, POST /v1/skills/:id/close. DoS caps:
1849
- // skillName 256, instructions 8192, trigger 1024, changeSummary 4096. The store
1850
- // validates + throws; the boundary maps validation -> 400, not-found -> 404,
1851
- // not-active -> 409. Mirrors /v1/processes; "executable" = exportable
1852
- // instruction (no code exec).
1853
- async function handleCreateSkill({ req, res, opts }) {
1854
- const body = await parseJsonBody(req);
1855
- const skillName = body['skillName'];
1856
- if (!isJsonString(skillName) || skillName.trim().length === 0) {
1857
- throw new HttpError(400, 'skillName is required (non-empty string)');
1858
- }
1859
- if (skillName.length > 256) {
1860
- throw new HttpError(400, 'skillName exceeds 256-character cap');
1861
- }
1862
- const instructions = body['instructions'];
1863
- if (!isJsonString(instructions) || instructions.trim().length === 0) {
1864
- throw new HttpError(400, 'instructions are required (non-empty string)');
1865
- }
1866
- if (instructions.length > 8192) {
1867
- throw new HttpError(400, 'instructions exceed 8192-character cap');
1868
- }
1869
- const triggerRaw = body['trigger'];
1870
- let trigger;
1871
- if (triggerRaw !== undefined && triggerRaw !== null) {
1872
- if (!isJsonString(triggerRaw)) {
1873
- throw new HttpError(400, 'trigger must be a string');
1874
- }
1875
- if (triggerRaw.length > 1024) {
1876
- throw new HttpError(400, 'trigger exceeds 1024-character cap');
1877
- }
1878
- trigger = triggerRaw;
1879
- }
1880
- const ctx = await buildContextWithAuth(req, opts);
1881
- const skill = saveSkill(opts.hippoRoot, ctx.tenantId, {
1882
- skillName,
1883
- instructions,
1884
- trigger,
1885
- }, ctx.actor.subject);
1886
- sendJson(res, 201, { skill });
1887
- return;
1888
- }
1889
- async function handleListSkills({ req, res, opts, query }) {
1890
- const status = query.get('status') ?? 'all';
1891
- const limit = parseListLimit(query.get('limit'));
1892
- const ctx = await buildContextWithAuth(req, opts);
1893
- let skills;
1894
- if (status === 'all') {
1895
- skills = loadSkills(opts.hippoRoot, ctx.tenantId, { limit });
1896
- }
1897
- else {
1898
- if (!isSetMember(VALID_SKILL_STATES, status)) {
1899
- throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
1900
- }
1901
- skills = loadSkills(opts.hippoRoot, ctx.tenantId, {
1902
- status,
1903
- limit,
1904
- });
1905
- }
1906
- sendJson(res, 200, { skills });
1907
- return;
1908
- }
1909
- // The export renderer: must precede the /:id GET (literal 'export' is
1910
- // non-numeric so the /(\d+)/ route would not match it, but order it first).
1911
- async function handleExportSkills({ req, res, opts }) {
1912
- const ctx = await buildContextWithAuth(req, opts);
1913
- const markdown = exportSkills(opts.hippoRoot, ctx.tenantId);
1914
- sendJson(res, 200, { markdown });
1915
- return;
1916
- }
1917
- async function handleSupersedeSkill({ req, res, opts }, skillSupersedeMatch) {
1918
- const id = parseInt(skillSupersedeMatch[1], 10);
1919
- const body = await parseJsonBody(req);
1920
- const instructions = body['instructions'];
1921
- if (!isJsonString(instructions) || instructions.trim().length === 0) {
1922
- throw new HttpError(400, 'instructions are required (non-empty string)');
1923
- }
1924
- if (instructions.length > 8192) {
1925
- throw new HttpError(400, 'instructions exceed 8192-character cap');
1926
- }
1927
- const triggerRaw = body['trigger'];
1928
- let trigger;
1929
- if (triggerRaw !== undefined && triggerRaw !== null) {
1930
- if (!isJsonString(triggerRaw)) {
1931
- throw new HttpError(400, 'trigger must be a string');
1932
- }
1933
- if (triggerRaw.length > 1024) {
1934
- throw new HttpError(400, 'trigger exceeds 1024-character cap');
1935
- }
1936
- trigger = triggerRaw;
1937
- }
1938
- const changeRaw = body['changeSummary'];
1939
- let changeSummary;
1940
- if (changeRaw !== undefined && changeRaw !== null) {
1941
- if (!isJsonString(changeRaw)) {
1942
- throw new HttpError(400, 'changeSummary must be a string');
1943
- }
1944
- if (changeRaw.length > 4096) {
1945
- throw new HttpError(400, 'changeSummary exceeds 4096-character cap');
1946
- }
1947
- changeSummary = changeRaw;
1948
- }
1949
- const ctx = await buildContextWithAuth(req, opts);
1950
- const existing = loadSkillById(opts.hippoRoot, ctx.tenantId, id);
1951
- if (!existing) {
1952
- throw new HttpError(404, `skill ${id} not found`);
1953
- }
1954
- const skill = saveSkill(opts.hippoRoot, ctx.tenantId, {
1955
- skillName: existing.skillName,
1956
- instructions,
1957
- trigger,
1958
- changeSummary,
1959
- supersedesSkillId: id,
1960
- }, ctx.actor.subject);
1961
- sendJson(res, 200, { skill });
1962
- return;
1963
- }
1964
- async function handleCloseSkill({ req, res, opts }, skillCloseMatch) {
1965
- const id = parseInt(skillCloseMatch[1], 10);
1966
- const ctx = await buildContextWithAuth(req, opts);
1967
- const skill = closeSkill(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
1968
- sendJson(res, 200, { skill });
1969
- return;
1970
- }
1971
- async function handleGetSkill({ req, res, opts }, skillByIdMatch) {
1972
- const id = parseInt(skillByIdMatch[1], 10);
1973
- const ctx = await buildContextWithAuth(req, opts);
1974
- const skill = loadSkillById(opts.hippoRoot, ctx.tenantId, id);
1975
- if (!skill) {
1976
- throw new HttpError(404, `skill ${id} not found`);
1977
- }
1978
- sendJson(res, 200, { skill });
1979
- return;
1980
- }
1981
- // ── E2 project_brief routes ──
1982
- //
1983
- // 6 routes: POST /v1/project-briefs (new; body repo + summary), GET
1984
- // /v1/project-briefs (list; status + repo filter; shared parseListLimit), POST
1985
- // /v1/project-briefs/refresh (body {repo, dryRun?} -> auto-assemble the brief
1986
- // from the repo's receipts; dryRun returns {markdown} without writing; ordered
1987
- // before /:id), GET /v1/project-briefs/:id, POST /v1/project-briefs/:id/supersede,
1988
- // POST /v1/project-briefs/:id/close. DoS caps: repo 256, summary 8192,
1989
- // changeSummary 4096. The store validates + throws; the boundary maps validation
1990
- // -> 400, not-found -> 404, not-active -> 409. Mirrors /v1/skills.
1991
- async function handleCreateProjectBrief({ req, res, opts }) {
1992
- const body = await parseJsonBody(req);
1993
- const repo = body['repo'];
1994
- if (!isJsonString(repo) || repo.trim().length === 0) {
1995
- throw new HttpError(400, 'repo is required (non-empty string)');
1996
- }
1997
- if (repo.length > 256) {
1998
- throw new HttpError(400, 'repo exceeds 256-character cap');
1999
- }
2000
- const summary = body['summary'];
2001
- if (!isJsonString(summary) || summary.trim().length === 0) {
2002
- throw new HttpError(400, 'summary is required (non-empty string)');
2003
- }
2004
- if (summary.length > 8192) {
2005
- throw new HttpError(400, 'summary exceeds 8192-character cap');
2006
- }
2007
- const ctx = await buildContextWithAuth(req, opts);
2008
- const brief = saveProjectBrief(opts.hippoRoot, ctx.tenantId, {
2009
- repo,
2010
- summary,
2011
- }, ctx.actor.subject);
2012
- sendJson(res, 201, { brief });
2013
- return;
2014
- }
2015
- async function handleListProjectBriefs({ req, res, opts, query }) {
2016
- const status = query.get('status') ?? 'all';
2017
- const repoFilter = query.get('repo');
2018
- const limit = parseListLimit(query.get('limit'));
2019
- const ctx = await buildContextWithAuth(req, opts);
2020
- const listOpts = { limit };
2021
- if (repoFilter !== null && repoFilter.trim().length > 0) {
2022
- listOpts.repo = repoFilter.trim();
2023
- }
2024
- if (status !== 'all') {
2025
- if (!isSetMember(VALID_BRIEF_STATES, status)) {
2026
- throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
2027
- }
2028
- listOpts.status = status;
2029
- }
2030
- const briefs = loadProjectBriefs(opts.hippoRoot, ctx.tenantId, listOpts);
2031
- sendJson(res, 200, { briefs });
2032
- return;
2033
- }
2034
- // The refresh op: must precede the /:id routes (literal 'refresh' is non-numeric
2035
- // so the /(\d+)/ routes would not match it, but order it first).
2036
- async function handleRefreshProjectBrief({ req, res, opts }) {
2037
- const body = await parseJsonBody(req);
2038
- const repo = body['repo'];
2039
- if (!isJsonString(repo) || repo.trim().length === 0) {
2040
- throw new HttpError(400, 'repo is required (non-empty string)');
2041
- }
2042
- if (repo.length > 256) {
2043
- throw new HttpError(400, 'repo exceeds 256-character cap');
2044
- }
2045
- const dryRun = body['dryRun'] === true;
2046
- const ctx = await buildContextWithAuth(req, opts);
2047
- if (dryRun) {
2048
- const { markdown, receiptCount } = assembleBriefFromReceipts(opts.hippoRoot, ctx.tenantId, repo);
2049
- sendJson(res, 200, { markdown, receiptCount });
2050
- return;
2051
- }
2052
- const brief = refreshBrief(opts.hippoRoot, ctx.tenantId, repo, ctx.actor.subject);
2053
- sendJson(res, 200, { brief });
2054
- return;
2055
- }
2056
- async function handleSupersedeProjectBrief({ req, res, opts }, briefSupersedeMatch) {
2057
- const id = parseInt(briefSupersedeMatch[1], 10);
2058
- const body = await parseJsonBody(req);
2059
- const summary = body['summary'];
2060
- if (!isJsonString(summary) || summary.trim().length === 0) {
2061
- throw new HttpError(400, 'summary is required (non-empty string)');
2062
- }
2063
- if (summary.length > 8192) {
2064
- throw new HttpError(400, 'summary exceeds 8192-character cap');
2065
- }
2066
- const changeRaw = body['changeSummary'];
2067
- let changeSummary;
2068
- if (changeRaw !== undefined && changeRaw !== null) {
2069
- if (!isJsonString(changeRaw)) {
2070
- throw new HttpError(400, 'changeSummary must be a string');
2071
- }
2072
- if (changeRaw.length > 4096) {
2073
- throw new HttpError(400, 'changeSummary exceeds 4096-character cap');
2074
- }
2075
- changeSummary = changeRaw;
2076
- }
2077
- const ctx = await buildContextWithAuth(req, opts);
2078
- const existing = loadProjectBriefById(opts.hippoRoot, ctx.tenantId, id);
2079
- if (!existing) {
2080
- throw new HttpError(404, `project brief ${id} not found`);
2081
- }
2082
- const brief = saveProjectBrief(opts.hippoRoot, ctx.tenantId, {
2083
- repo: existing.repo,
2084
- summary,
2085
- changeSummary,
2086
- supersedesBriefId: id,
2087
- }, ctx.actor.subject);
2088
- sendJson(res, 200, { brief });
2089
- return;
2090
- }
2091
- async function handleCloseProjectBrief({ req, res, opts }, briefCloseMatch) {
2092
- const id = parseInt(briefCloseMatch[1], 10);
2093
- const ctx = await buildContextWithAuth(req, opts);
2094
- const brief = closeProjectBrief(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
2095
- sendJson(res, 200, { brief });
2096
- return;
2097
- }
2098
- async function handleGetProjectBrief({ req, res, opts }, briefByIdMatch) {
2099
- const id = parseInt(briefByIdMatch[1], 10);
2100
- const ctx = await buildContextWithAuth(req, opts);
2101
- const brief = loadProjectBriefById(opts.hippoRoot, ctx.tenantId, id);
2102
- if (!brief) {
2103
- throw new HttpError(404, `project brief ${id} not found`);
2104
- }
2105
- sendJson(res, 200, { brief });
2106
- return;
2107
- }
2108
- // ── E2 customer_note routes ──
2109
- //
2110
- // 5 routes (no assembler/refresh): POST /v1/customer-notes (new; body customer +
2111
- // note), GET /v1/customer-notes (list; status + customer filter; shared
2112
- // parseListLimit), GET /v1/customer-notes/:id, POST /v1/customer-notes/:id/supersede,
2113
- // POST /v1/customer-notes/:id/close. DoS caps: customer 256, note 8192,
2114
- // changeSummary 4096. The store validates + throws; the boundary maps validation ->
2115
- // 400, not-found -> 404, not-active -> 409. Mirrors /v1/project-briefs.
2116
- async function handleCreateCustomerNote({ req, res, opts }) {
2117
- const body = await parseJsonBody(req);
2118
- const customer = body['customer'];
2119
- if (!isJsonString(customer) || customer.trim().length === 0) {
2120
- throw new HttpError(400, 'customer is required (non-empty string)');
2121
- }
2122
- if (customer.length > 256) {
2123
- throw new HttpError(400, 'customer exceeds 256-character cap');
2124
- }
2125
- const note = body['note'];
2126
- if (!isJsonString(note) || note.trim().length === 0) {
2127
- throw new HttpError(400, 'note is required (non-empty string)');
2128
- }
2129
- if (note.length > 8192) {
2130
- throw new HttpError(400, 'note exceeds 8192-character cap');
2131
- }
2132
- const ctx = await buildContextWithAuth(req, opts);
2133
- const customerNote = saveCustomerNote(opts.hippoRoot, ctx.tenantId, {
2134
- customer,
2135
- note,
2136
- }, ctx.actor.subject);
2137
- sendJson(res, 201, { note: customerNote });
2138
- return;
2139
- }
2140
- async function handleListCustomerNotes({ req, res, opts, query }) {
2141
- const status = query.get('status') ?? 'all';
2142
- const customerFilter = query.get('customer');
2143
- const limit = parseListLimit(query.get('limit'));
2144
- const ctx = await buildContextWithAuth(req, opts);
2145
- const listOpts = { limit };
2146
- if (customerFilter !== null && customerFilter.trim().length > 0) {
2147
- listOpts.customer = customerFilter.trim();
2148
- }
2149
- if (status !== 'all') {
2150
- if (!isSetMember(VALID_NOTE_STATES, status)) {
2151
- throw new HttpError(400, `status must be one of: active | superseded | closed | all (got "${status}")`);
2152
- }
2153
- listOpts.status = status;
2154
- }
2155
- const notes = loadCustomerNotes(opts.hippoRoot, ctx.tenantId, listOpts);
2156
- sendJson(res, 200, { notes });
2157
- return;
2158
- }
2159
- async function handleSupersedeCustomerNote({ req, res, opts }, noteSupersedeMatch) {
2160
- const id = parseInt(noteSupersedeMatch[1], 10);
2161
- const body = await parseJsonBody(req);
2162
- const note = body['note'];
2163
- if (!isJsonString(note) || note.trim().length === 0) {
2164
- throw new HttpError(400, 'note is required (non-empty string)');
2165
- }
2166
- if (note.length > 8192) {
2167
- throw new HttpError(400, 'note exceeds 8192-character cap');
2168
- }
2169
- const changeRaw = body['changeSummary'];
2170
- let changeSummary;
2171
- if (changeRaw !== undefined && changeRaw !== null) {
2172
- if (!isJsonString(changeRaw)) {
2173
- throw new HttpError(400, 'changeSummary must be a string');
2174
- }
2175
- if (changeRaw.length > 4096) {
2176
- throw new HttpError(400, 'changeSummary exceeds 4096-character cap');
2177
- }
2178
- changeSummary = changeRaw;
2179
- }
2180
- const ctx = await buildContextWithAuth(req, opts);
2181
- const existing = loadCustomerNoteById(opts.hippoRoot, ctx.tenantId, id);
2182
- if (!existing) {
2183
- throw new HttpError(404, `customer note ${id} not found`);
2184
- }
2185
- const customerNote = saveCustomerNote(opts.hippoRoot, ctx.tenantId, {
2186
- customer: existing.customer,
2187
- note,
2188
- changeSummary,
2189
- supersedesNoteId: id,
2190
- }, ctx.actor.subject);
2191
- sendJson(res, 200, { note: customerNote });
2192
- return;
2193
- }
2194
- async function handleCloseCustomerNote({ req, res, opts }, noteCloseMatch) {
2195
- const id = parseInt(noteCloseMatch[1], 10);
2196
- const ctx = await buildContextWithAuth(req, opts);
2197
- const customerNote = closeCustomerNote(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
2198
- sendJson(res, 200, { note: customerNote });
2199
- return;
2200
- }
2201
- async function handleGetCustomerNote({ req, res, opts }, noteByIdMatch) {
2202
- const id = parseInt(noteByIdMatch[1], 10);
2203
- const ctx = await buildContextWithAuth(req, opts);
2204
- const customerNote = loadCustomerNoteById(opts.hippoRoot, ctx.tenantId, id);
2205
- if (!customerNote) {
2206
- throw new HttpError(404, `customer note ${id} not found`);
2207
- }
2208
- sendJson(res, 200, { note: customerNote });
2209
- return;
2210
- }
2211
67
  /** The /v1 routes in dispatch order; the first entry whose method and path match handles the request. */
2212
68
  const V1_ROUTES = [
2213
69
  { method: 'POST', path: '/v1/memories', handler: handleCreateMemory },
@@ -2302,45 +158,16 @@ async function dispatchV1Route(r, method, path) {
2302
158
  }
2303
159
  return false;
2304
160
  }
2305
- async function handleRequest(req, res, opts, startedAt, limiter) {
161
+ async function handleRequest(req, res, opts, startedAt, streamSlots, limiter) {
2306
162
  // v1.6.4: pre-decode raw-URL slash check. Catches `%2F` / `%2f` before
2307
163
  // Node's URL parser collapses them and they slip past the route table.
2308
164
  rejectEncodedSlash(req.url ?? '/');
2309
165
  const { method, path, query } = parseRequest(req);
2310
166
  if (method === 'GET' && path === '/health') {
2311
- // Loopback callers (detectServer's stale-pidfile probe reads version and
2312
- // pid) get the full body. Non-loopback callers get liveness only: the
2313
- // version string would fingerprint the build for the public internet and
2314
- // the pid is noise. Platform health checks only need the 200.
2315
- if (isLoopback(req.socket.remoteAddress)) {
2316
- sendJson(res, 200, {
2317
- ok: true,
2318
- version: VERSION,
2319
- started_at: startedAt,
2320
- pid: process.pid,
2321
- audit_write_failures: auditWriteFailureCount(),
2322
- });
2323
- }
2324
- else {
2325
- sendJson(res, 200, { ok: true });
2326
- }
167
+ sendHealth(req, res, startedAt);
2327
168
  return;
2328
169
  }
2329
- // E3: per-IP rate limit on /v1/* and /mcp* to bound api-key-id enumeration. /health
2330
- // (a liveness probe) and other paths are never throttled. A 429 thrown
2331
- // here lands in the createServer catch like any other HttpError.
2332
- //
2333
- // Keyed on the socket's remote address by default. Behind a TLS-terminating
2334
- // proxy every socket carries the proxy's address, collapsing the per-IP
2335
- // buckets into one global bucket that pre-auth traffic can drain; set
2336
- // HIPPO_CLIENT_IP_HEADER there so each real client gets its own bucket
2337
- // (see clientIpForRateLimit).
2338
- if (limiter && (path.startsWith('/v1/') || path === '/mcp' || path === '/mcp/stream')) {
2339
- const ip = clientIpForRateLimit(req);
2340
- if (!limiter.check(ip)) {
2341
- throw new HttpError(429, 'rate limit exceeded');
2342
- }
2343
- }
170
+ enforceRateLimit(req, path, limiter);
2344
171
  if (await dispatchV1Route({ req, res, opts, query }, method, path))
2345
172
  return;
2346
173
  if (method === 'POST' && path === '/v1/connectors/slack/events') {
@@ -2359,158 +186,35 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2359
186
  await handleGitHubEventsWebhook({ req, res, opts });
2360
187
  return;
2361
188
  }
2362
- // ── MCP-over-HTTP/SSE transport (Task 11) ──
2363
- //
2364
- // Two routes implement an MCP HTTP transport alongside the stdio one. Both
2365
- // dispatch to the same `handleMcpRequest` as the stdio loop in src/mcp/server.ts.
2366
- //
2367
- // POST /mcp — Send a JSON-RPC request, get a JSON-RPC response synchronously
2368
- // in the body. Content-type: application/json both ways.
2369
- // GET /mcp/stream — Open an SSE stream for server-initiated messages.
2370
- // v1 simplification: this stream is keepalive-only. Clients
2371
- // that need server-pushed notifications/progress will see
2372
- // only `: ping` comments every 30s. All real responses come
2373
- // back synchronously on POST /mcp. This matches the
2374
- // "synchronous JSON in body" leg of the MCP HTTP spec and
2375
- // is enough for `tools/list` / `tools/call` round-trips.
2376
- // Server-initiated SSE messages will be wired in a later task.
2377
- //
2378
- // Auth: same as /v1/* — Bearer token validated via `requireAuth`, with the
2379
- // loopback no-auth fallback. SSE check runs once at stream-open.
2380
189
  if (method === 'POST' && path === '/mcp') {
2381
- // Build the same Context the /v1/* routes use so MCP tool calls inherit
2382
- // the server's bound hippoRoot and the auth-resolved tenantId / actor.
2383
- // Without this, executeTool would walk from cwd via findHippoRoot() and
2384
- // pull tenant from HIPPO_TENANT, dropping a valid Bearer for tenant B
2385
- // back to whatever the env says.
2386
- const ctx = await buildContextWithAuth(req, opts);
2387
- const raw = await readBody(req);
2388
- let mcpReq;
2389
- try {
2390
- mcpReq = JSON.parse(raw);
2391
- }
2392
- catch {
2393
- throw new HttpError(400, 'invalid JSON-RPC body');
2394
- }
2395
- if (!isJsonObjectRecord(mcpReq) || !isJsonString(mcpReq.method)) {
2396
- throw new HttpError(400, 'JSON-RPC body must include a method string');
2397
- }
2398
- // SAFETY: validated above as a plain JSON object carrying a string method;
2399
- // the remaining McpRequest wire fields (jsonrpc, id, params) are checked or
2400
- // safely defaulted inside handleMcpRequest's JSON-RPC dispatch.
2401
- const rpcReq = mcpReq;
2402
- let mcpRes;
2403
- try {
2404
- mcpRes = await handleMcpRequest(rpcReq, {
2405
- hippoRoot: ctx.hippoRoot,
2406
- tenantId: ctx.tenantId,
2407
- // v1.12.0: McpContext.actor stays string; extract subject at the boundary.
2408
- actor: ctx.actor.subject,
2409
- // The caller's real role: MCP tools must not run a member key as admin.
2410
- role: ctx.actor.role,
2411
- scopes: ctx.actor.scopes,
2412
- viaAuthResolver: ctx.actor.viaAuthResolver,
2413
- clientKey: buildMcpClientKey(req),
2414
- });
2415
- }
2416
- catch (err) {
2417
- mcpRes = {
2418
- jsonrpc: '2.0',
2419
- id: mcpReq.id,
2420
- error: { code: -32603, message: err instanceof Error ? err.message : 'internal error' },
2421
- };
2422
- }
2423
- if (mcpRes === null) {
2424
- // Notification — no body, 202 Accepted.
2425
- res.writeHead(202);
2426
- res.end();
2427
- return;
2428
- }
2429
- sendJson(res, 200, mcpRes);
190
+ await handleMcpPost(req, res, opts);
2430
191
  return;
2431
192
  }
2432
193
  if (method === 'GET' && path === '/mcp/stream') {
2433
- await requireAuth(req, opts);
2434
- // An async resolver can outlive the client; 'close' has already fired, so no timer may start.
2435
- if (req.destroyed || res.destroyed || req.socket.destroyed)
2436
- return;
2437
- res.writeHead(200, {
2438
- 'content-type': 'text/event-stream',
2439
- 'cache-control': 'no-cache',
2440
- connection: 'keep-alive',
2441
- });
2442
- // Initial ping so smoke tests can confirm the stream is live without
2443
- // waiting for the first keepalive interval.
2444
- res.write(': ping\n\n');
2445
- // v0.39 SSE hardening:
2446
- // - Heartbeat re-validates the bearer (default 60s). If the key was
2447
- // revoked or rotated, close the stream with reason='auth_revoked'.
2448
- // - MCP_SSE_MAX_AGE_SEC (default 3600) caps stream lifetime; close
2449
- // with reason='max_age_exceeded' when reached.
2450
- // - MCP_SSE_HEARTBEAT_MS (default 60000) lets tests run with a short
2451
- // interval without waiting a full minute.
2452
- const heartbeatMs = parseInt(process.env.MCP_SSE_HEARTBEAT_MS ?? '60000', 10) || 60000;
2453
- const maxAgeMs = (parseInt(process.env.MCP_SSE_MAX_AGE_SEC ?? '3600', 10) || 3600) * 1000;
2454
- const startedAt = Date.now();
2455
- let closed = false;
2456
- let checking = false;
2457
- const closeWith = (reason) => {
2458
- if (closed)
2459
- return;
2460
- closed = true;
2461
- try {
2462
- res.write(`event: closed\ndata: ${JSON.stringify({ reason })}\n\n`);
2463
- }
2464
- catch { /* socket already gone */ }
2465
- try {
2466
- res.end();
2467
- }
2468
- catch { /* socket already gone */ }
2469
- };
2470
- const ping = setInterval(() => {
2471
- if (closed) {
2472
- clearInterval(ping);
2473
- return;
2474
- }
2475
- if (Date.now() - startedAt >= maxAgeMs) {
2476
- closeWith('max_age_exceeded');
2477
- clearInterval(ping);
2478
- return;
2479
- }
2480
- if (checking)
2481
- return;
2482
- checking = true;
2483
- void heartbeatVerdict(req, opts).then((verdict) => {
2484
- checking = false;
2485
- if (closed || verdict === 'unavailable')
2486
- return;
2487
- if (verdict === 'revoked') {
2488
- closeWith('auth_revoked');
2489
- clearInterval(ping);
2490
- return;
2491
- }
2492
- try {
2493
- res.write(': ping\n\n');
2494
- }
2495
- catch {
2496
- clearInterval(ping);
2497
- }
2498
- });
2499
- }, heartbeatMs);
2500
- // Don't keep the event loop alive just for this timer — the server's
2501
- // listener already does that, and tests want the process to exit cleanly.
2502
- if (ping.unref instanceof Function)
2503
- ping.unref();
2504
- // res 'close' covers an early socket drop that req 'close' can miss.
2505
- res.on('close', () => {
2506
- closed = true;
2507
- clearInterval(ping);
2508
- });
194
+ await handleMcpStream(req, res, opts, streamSlots);
2509
195
  return;
2510
196
  }
2511
197
  res.writeHead(404, JSON_HEADERS);
2512
198
  res.end(JSON.stringify({ error: 'not found' }));
2513
199
  }
200
+ function sendHealth(req, res, startedAt) {
201
+ // Loopback callers (detectServer's stale-pidfile probe reads version and
202
+ // pid) get the full body. Non-loopback callers get liveness only: the
203
+ // version string would fingerprint the build for the public internet and
204
+ // the pid is noise. Platform health checks only need the 200.
205
+ if (isLoopback(req.socket.remoteAddress)) {
206
+ sendJson(res, 200, {
207
+ ok: true,
208
+ version: VERSION,
209
+ started_at: startedAt,
210
+ pid: process.pid,
211
+ audit_write_failures: auditWriteFailureCount(),
212
+ });
213
+ }
214
+ else {
215
+ sendJson(res, 200, { ok: true });
216
+ }
217
+ }
2514
218
  /**
2515
219
  * Boot the HTTP daemon on host:port and write the pidfile under hippoRoot.
2516
220
  *
@@ -2534,8 +238,8 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2534
238
  */
2535
239
  export async function serve(opts) {
2536
240
  const host = opts.host ?? '127.0.0.1';
2537
- const requestedPort = opts.port ?? Number(process.env.HIPPO_PORT ?? 6789);
2538
- if (!LOOPBACK_HOSTS.has(host) && process.env.HIPPO_REQUIRE_AUTH !== '1') {
241
+ const requestedPort = opts.port ?? Number(envPort() ?? 6789);
242
+ if (!LOOPBACK_HOSTS.has(host) && !envRequireAuth()) {
2539
243
  throw new Error(`Refusing to bind hippo serve to non-loopback host '${host}' without auth. ` +
2540
244
  `Set HIPPO_REQUIRE_AUTH=1 to bind non-loopback; every request then requires ` +
2541
245
  `a valid API key. Bind to 127.0.0.1 / ::1 / localhost otherwise.`);
@@ -2557,16 +261,37 @@ export async function serve(opts) {
2557
261
  // HIPPO_V1_RPS is read at boot, matching HIPPO_PORT above and letting a test
2558
262
  // set the rate before serve(). A non-positive or non-finite value disables
2559
263
  // limiting (the opt-out knob).
2560
- const v1Rps = Number(process.env.HIPPO_V1_RPS ?? 20);
264
+ const v1Rps = Number(envV1Rps() ?? 20);
2561
265
  const limiter = Number.isFinite(v1Rps) && v1Rps > 0
2562
266
  ? createRateLimiter({ ratePerSec: v1Rps, burst: v1Rps * 2, idleEvictMs: 60000, maxKeys: 10000 })
2563
267
  : undefined;
268
+ // Open /mcp/stream count per client key, so the cap is per server rather than per process.
269
+ const streamSlots = new Map();
270
+ // Handlers open and close their own connections; while this one is held, none of those closes is SQLite's last,
271
+ // which checkpoints and deletes the WAL. It opens only once the store exists, so serving never creates one.
272
+ let heldDb;
273
+ let stopHolding = false;
274
+ const holdStore = () => {
275
+ if (heldDb || stopHolding || !existsSync(getHippoDbPath(opts.hippoRoot)))
276
+ return;
277
+ try {
278
+ heldDb = openHippoDb(opts.hippoRoot);
279
+ }
280
+ catch (err) {
281
+ stopHolding = true;
282
+ log.warn(`serve: could not hold a store connection; requests still work, only slower: ${err instanceof Error ? err.message : String(err)}`);
283
+ }
284
+ };
285
+ const inflight = new Set();
2564
286
  const server = createServer((req, res) => {
287
+ res.once('finish', holdStore);
288
+ inflight.add(res);
289
+ res.once('close', () => inflight.delete(res));
2565
290
  const requestId = resolveRequestId(req.headers['x-request-id']);
2566
291
  requestIds.set(req, requestId);
2567
292
  res.setHeader('X-Request-Id', requestId);
2568
- handleRequest(req, res, opts, startedAt, limiter).catch((err) => {
2569
- const mapped = mapApiError(err);
293
+ withBusyWait(SERVER_DB_WAIT_MS, () => handleRequest(req, res, opts, startedAt, streamSlots, limiter)).catch((err) => {
294
+ const mapped = replyFor(err);
2570
295
  logRequestFailure(req, err, requestId, mapped.status);
2571
296
  if (res.headersSent) {
2572
297
  try {
@@ -2575,6 +300,8 @@ export async function serve(opts) {
2575
300
  catch { /* socket already gone */ }
2576
301
  return;
2577
302
  }
303
+ if (isSqliteBusy(err))
304
+ res.setHeader('Retry-After', '1');
2578
305
  if (mapped.status === 500) {
2579
306
  // The id lets an operator find the logged cause without the client seeing internal text.
2580
307
  sendJson(res, 500, { error: mapped.message, requestId });
@@ -2628,6 +355,7 @@ export async function serve(opts) {
2628
355
  const actualPort = addressInfo.port;
2629
356
  const url = `http://${host}:${actualPort}`;
2630
357
  writePidfile(opts.hippoRoot, { port: actualPort, url, startedAt });
358
+ holdStore();
2631
359
  let stopping = false;
2632
360
  const stop = async () => {
2633
361
  if (stopping)
@@ -2637,14 +365,11 @@ export async function serve(opts) {
2637
365
  // may have started on this hippoRoot and rewritten the pidfile; an
2638
366
  // unconditional unlink here would orphan it. (v0.37.0 server-hardening.)
2639
367
  removePidfileIfOwned(opts.hippoRoot, { pid: process.pid, startedAt });
2640
- // Force-close any long-lived idle connections (e.g. SSE keepalive streams
2641
- // on /mcp/stream) so server.close() can resolve. Without this, SIGTERM
2642
- // would hang the process until the SSE client cancels. Available on
2643
- // Node 18.2+; gate via optional chaining to avoid crashing on older runtimes.
2644
- server.closeAllConnections?.();
2645
- await new Promise((resolve) => {
2646
- server.close(() => resolve());
2647
- });
368
+ await drainAndClose(server, inflight, opts.shutdownDrainMs ?? 5000);
369
+ stopHolding = true;
370
+ if (heldDb)
371
+ closeHippoDb(heldDb);
372
+ heldDb = undefined;
2648
373
  };
2649
374
  if (opts.handleSignals) {
2650
375
  let shuttingDown = false;
@@ -2652,15 +377,14 @@ export async function serve(opts) {
2652
377
  if (shuttingDown)
2653
378
  return;
2654
379
  shuttingDown = true;
2655
- console.error(`Received ${signal}, shutting down...`);
380
+ log.warn(`received ${signal}, shutting down`);
2656
381
  try {
2657
382
  await stop();
383
+ process.exit(0);
2658
384
  }
2659
385
  catch (err) {
2660
- console.error('Error during stop:', err);
2661
- }
2662
- finally {
2663
- process.exit(0);
386
+ log.error(`error during stop: ${err instanceof Error ? err.message : String(err)}`, errorFields(err));
387
+ process.exit(1);
2664
388
  }
2665
389
  };
2666
390
  process.once('SIGTERM', () => { void gracefulShutdown('SIGTERM'); });