hippo-memory 1.61.0 → 1.63.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 (377) hide show
  1. package/README.md +37 -53
  2. package/dist/agent-memories/apply.d.ts +3 -1
  3. package/dist/agent-memories/apply.js +23 -1
  4. package/dist/agent-memories/claude-code.d.ts +4 -1
  5. package/dist/agent-memories/claude-code.js +53 -9
  6. package/dist/agent-memories/report.d.ts +1 -0
  7. package/dist/agent-memories/report.js +2 -2
  8. package/dist/agent-memories/sync.d.ts +3 -3
  9. package/dist/agent-memories/sync.js +46 -24
  10. package/dist/agent-memories/types.d.ts +0 -2
  11. package/dist/ambient-store.d.ts +4 -4
  12. package/dist/ambient-store.js +4 -3
  13. package/dist/api/assemble.d.ts +7 -10
  14. package/dist/api/assemble.js +62 -66
  15. package/dist/api/audit.d.ts +2 -2
  16. package/dist/api/audit.js +2 -2
  17. package/dist/api/auth.d.ts +6 -6
  18. package/dist/api/auth.js +5 -7
  19. package/dist/api/context-select.d.ts +50 -0
  20. package/dist/api/context-select.js +342 -0
  21. package/dist/api/context-types.d.ts +12 -17
  22. package/dist/api/context.d.ts +5 -4
  23. package/dist/api/context.js +208 -535
  24. package/dist/api/dormant.js +1 -1
  25. package/dist/api/drill-down.d.ts +4 -4
  26. package/dist/api/drill-down.js +56 -41
  27. package/dist/api/outcome.d.ts +8 -13
  28. package/dist/api/outcome.js +13 -17
  29. package/dist/api/promote.d.ts +5 -8
  30. package/dist/api/promote.js +55 -66
  31. package/dist/api/quarantine.js +3 -2
  32. package/dist/api/recall-types.d.ts +43 -55
  33. package/dist/api/recall.d.ts +3 -3
  34. package/dist/api/recall.js +310 -456
  35. package/dist/api/remember.d.ts +2 -2
  36. package/dist/api/sleep.d.ts +7 -35
  37. package/dist/api/sleep.js +206 -220
  38. package/dist/api/tokens.d.ts +2 -2
  39. package/dist/api/tokens.js +2 -2
  40. package/dist/api/types.d.ts +6 -10
  41. package/dist/api/types.js +2 -4
  42. package/dist/audit-prune.d.ts +4 -6
  43. package/dist/audit-prune.js +3 -5
  44. package/dist/audit.js +11 -33
  45. package/dist/auth.d.ts +8 -9
  46. package/dist/auth.js +5 -7
  47. package/dist/autolearn.d.ts +1 -1
  48. package/dist/autolearn.js +1 -1
  49. package/dist/availability.js +3 -5
  50. package/dist/capture/command.d.ts +5 -4
  51. package/dist/capture/command.js +8 -22
  52. package/dist/capture/compact.d.ts +2 -2
  53. package/dist/capture/compact.js +11 -15
  54. package/dist/capture/extract.js +37 -115
  55. package/dist/capture-error.d.ts +1 -1
  56. package/dist/capture-error.js +1 -1
  57. package/dist/churn-git.d.ts +1 -1
  58. package/dist/churn-git.js +1 -1
  59. package/dist/cli/audit.js +3 -4
  60. package/dist/cli/auth.js +3 -6
  61. package/dist/cli/briefs.js +324 -306
  62. package/dist/cli/context.js +44 -34
  63. package/dist/cli/continuity.js +283 -271
  64. package/dist/cli/curate.d.ts +1 -1
  65. package/dist/cli/curate.js +43 -58
  66. package/dist/cli/dag.js +5 -9
  67. package/dist/cli/decisions.js +334 -345
  68. package/dist/cli/explain.js +68 -61
  69. package/dist/cli/goals.js +1 -1
  70. package/dist/cli/init.js +1 -1
  71. package/dist/cli/maintenance.js +62 -51
  72. package/dist/cli/playbooks.js +391 -379
  73. package/dist/cli/projects.js +11 -6
  74. package/dist/cli/recall.js +30 -44
  75. package/dist/cli/remember.js +118 -87
  76. package/dist/cli/session-hooks.js +106 -115
  77. package/dist/cli/setup.d.ts +1 -1
  78. package/dist/cli/setup.js +267 -250
  79. package/dist/cli/shared.js +3 -3
  80. package/dist/cli/slack.js +1 -1
  81. package/dist/cli/sleep.js +27 -1
  82. package/dist/cli/status.d.ts +4 -4
  83. package/dist/cli/status.js +80 -76
  84. package/dist/cli/transfer.js +88 -107
  85. package/dist/cli/usage.js +9 -6
  86. package/dist/cli.d.ts +1 -1
  87. package/dist/cli.js +4 -9
  88. package/dist/compaction-record.d.ts +2 -2
  89. package/dist/compaction-record.js +89 -68
  90. package/dist/compare.d.ts +11 -16
  91. package/dist/compare.js +11 -16
  92. package/dist/config.d.ts +19 -18
  93. package/dist/config.js +90 -67
  94. package/dist/connectors/github/backfill.d.ts +2 -2
  95. package/dist/connectors/github/backfill.js +8 -15
  96. package/dist/connectors/github/cli-impl.js +3 -8
  97. package/dist/connectors/github/deletion.d.ts +5 -12
  98. package/dist/connectors/github/deletion.js +5 -12
  99. package/dist/connectors/github/dlq.d.ts +6 -9
  100. package/dist/connectors/github/dlq.js +2 -3
  101. package/dist/connectors/github/ingest.d.ts +5 -7
  102. package/dist/connectors/github/ingest.js +8 -12
  103. package/dist/connectors/github/octokit-client.d.ts +3 -5
  104. package/dist/connectors/github/octokit-client.js +5 -6
  105. package/dist/connectors/github/signature.d.ts +9 -39
  106. package/dist/connectors/github/signature.js +9 -39
  107. package/dist/connectors/github/tenant-routing.d.ts +1 -1
  108. package/dist/connectors/github/tenant-routing.js +1 -1
  109. package/dist/connectors/github/transform.js +2 -2
  110. package/dist/connectors/github/types.d.ts +2 -10
  111. package/dist/connectors/github/types.js +1 -3
  112. package/dist/connectors/slack/deletion.d.ts +3 -8
  113. package/dist/connectors/slack/deletion.js +3 -8
  114. package/dist/connectors/slack/dlq.d.ts +1 -1
  115. package/dist/connectors/slack/ingest.d.ts +1 -1
  116. package/dist/connectors/slack/ingest.js +7 -16
  117. package/dist/connectors/slack/signature.d.ts +1 -1
  118. package/dist/connectors/slack/tenant-routing.d.ts +3 -5
  119. package/dist/connectors/slack/tenant-routing.js +3 -5
  120. package/dist/connectors/slack/transform.d.ts +5 -6
  121. package/dist/connectors/slack/transform.js +5 -6
  122. package/dist/connectors/slack/types.d.ts +2 -6
  123. package/dist/connectors/slack/types.js +1 -3
  124. package/dist/connectors/slack/web-client.js +10 -3
  125. package/dist/connectors/slack/workspaces.d.ts +3 -5
  126. package/dist/connectors/slack/workspaces.js +3 -5
  127. package/dist/consolidate/conflicts.js +3 -14
  128. package/dist/consolidate/decay.js +9 -29
  129. package/dist/consolidate/llm-passes.js +4 -5
  130. package/dist/consolidate/merge.js +8 -23
  131. package/dist/consolidate/run.d.ts +1 -8
  132. package/dist/consolidate/run.js +3 -25
  133. package/dist/consolidate/sleep.js +5 -17
  134. package/dist/consolidate/traces.js +9 -21
  135. package/dist/customer-notes.d.ts +5 -7
  136. package/dist/customer-notes.js +82 -76
  137. package/dist/dag.d.ts +10 -21
  138. package/dist/dag.js +189 -203
  139. package/dist/db/continuity.js +2 -2
  140. package/dist/db/migrations/v14.js +1 -1
  141. package/dist/db/migrations/v15.js +1 -2
  142. package/dist/db/migrations/v16.js +3 -4
  143. package/dist/db/migrations/v17.js +2 -3
  144. package/dist/db/migrations/v19.js +1 -1
  145. package/dist/db/migrations/v20.js +1 -1
  146. package/dist/db/migrations/v21.js +2 -6
  147. package/dist/db/migrations/v22.js +2 -4
  148. package/dist/db/migrations/v23.js +1 -1
  149. package/dist/db/migrations/v24.js +4 -6
  150. package/dist/db/migrations/v25.js +2 -3
  151. package/dist/db/migrations/v26.js +3 -3
  152. package/dist/db/migrations/v27.js +2 -10
  153. package/dist/db/migrations/v28.js +5 -8
  154. package/dist/db/migrations/v29.js +3 -4
  155. package/dist/db/migrations/v30.js +2 -2
  156. package/dist/db/migrations/v31.js +1 -1
  157. package/dist/db/migrations/v32.js +1 -1
  158. package/dist/db/migrations/v33.js +3 -3
  159. package/dist/db/migrations/v34.js +1 -1
  160. package/dist/db/migrations/v35.js +3 -4
  161. package/dist/db/migrations/v36.js +3 -4
  162. package/dist/db/migrations/v37.js +5 -5
  163. package/dist/db/migrations/v38.js +7 -8
  164. package/dist/db/migrations/v39.js +1 -1
  165. package/dist/db/migrations/v40.js +4 -16
  166. package/dist/db/migrations/v41.js +3 -4
  167. package/dist/db/migrations/v42.js +3 -4
  168. package/dist/db/migrations/v45.js +1 -1
  169. package/dist/db/migrations/v46.js +1 -1
  170. package/dist/db/migrations/v47.js +1 -1
  171. package/dist/db/migrations/v48.js +1 -1
  172. package/dist/decisions.d.ts +2 -2
  173. package/dist/decisions.js +97 -80
  174. package/dist/dedupe.js +86 -61
  175. package/dist/delivery-recorder.js +154 -135
  176. package/dist/doctor.js +129 -110
  177. package/dist/dormant.js +1 -4
  178. package/dist/embedding-provider.d.ts +4 -8
  179. package/dist/embedding-provider.js +4 -8
  180. package/dist/embeddings.js +55 -47
  181. package/dist/env.d.ts +1 -1
  182. package/dist/env.js +12 -12
  183. package/dist/escape.d.ts +5 -0
  184. package/dist/escape.js +10 -0
  185. package/dist/eval-stats.d.ts +1 -2
  186. package/dist/eval-stats.js +1 -2
  187. package/dist/eval-suite.js +27 -21
  188. package/dist/extract.js +4 -9
  189. package/dist/failure-log.d.ts +3 -3
  190. package/dist/failure-log.js +1 -1
  191. package/dist/forward-claim-detector.d.ts +2 -4
  192. package/dist/forward-claim-detector.js +6 -11
  193. package/dist/goals.d.ts +3 -3
  194. package/dist/goals.js +103 -91
  195. package/dist/graph/read.d.ts +2 -2
  196. package/dist/graph/read.js +5 -6
  197. package/dist/graph/types.d.ts +8 -8
  198. package/dist/graph/write.d.ts +7 -14
  199. package/dist/graph/write.js +16 -23
  200. package/dist/graph-extract.d.ts +7 -8
  201. package/dist/graph-extract.js +62 -72
  202. package/dist/graph-recall.d.ts +2 -2
  203. package/dist/graph-recall.js +55 -49
  204. package/dist/graph-stream.d.ts +5 -6
  205. package/dist/graph-stream.js +66 -57
  206. package/dist/graph-view.d.ts +2 -2
  207. package/dist/graph-view.js +7 -7
  208. package/dist/half-life-migration.d.ts +1 -2
  209. package/dist/half-life-migration.js +2 -3
  210. package/dist/hooks/codex-session.js +1 -1
  211. package/dist/hooks/codex-wrapper.d.ts +1 -1
  212. package/dist/hooks/codex-wrapper.js +3 -2
  213. package/dist/hooks/json-hooks.d.ts +2 -2
  214. package/dist/hooks/json-hooks.js +5 -4
  215. package/dist/hooks/opencode.d.ts +1 -1
  216. package/dist/hooks/opencode.js +5 -4
  217. package/dist/hooks/shared.d.ts +3 -7
  218. package/dist/hooks/shared.js +1 -8
  219. package/dist/http-util.d.ts +2 -3
  220. package/dist/http-util.js +3 -0
  221. package/dist/importers/core.d.ts +2 -9
  222. package/dist/importers/core.js +15 -30
  223. package/dist/importers/sources.js +2 -1
  224. package/dist/importers/vault.js +2 -20
  225. package/dist/incidents.d.ts +1 -1
  226. package/dist/incidents.js +46 -39
  227. package/dist/instruction-detect.d.ts +1 -1
  228. package/dist/instruction-detect.js +1 -1
  229. package/dist/invalidation.d.ts +3 -0
  230. package/dist/invalidation.js +160 -114
  231. package/dist/json.d.ts +5 -0
  232. package/dist/json.js +4 -0
  233. package/dist/judgment.js +1 -2
  234. package/dist/local-embedding.js +1 -1
  235. package/dist/mcp/admin-tools.js +7 -17
  236. package/dist/mcp/format.js +1 -1
  237. package/dist/mcp/framing.js +3 -6
  238. package/dist/mcp/protocol.d.ts +2 -5
  239. package/dist/mcp/protocol.js +1 -3
  240. package/dist/mcp/recall-tools.js +12 -15
  241. package/dist/mcp/request.js +4 -3
  242. package/dist/mcp/session-state.js +2 -3
  243. package/dist/mcp/stdio.js +2 -1
  244. package/dist/mcp/tools.js +9 -6
  245. package/dist/memory-value-weights.d.ts +5 -8
  246. package/dist/memory-value-weights.js +5 -8
  247. package/dist/memory-value.d.ts +13 -13
  248. package/dist/memory-value.js +26 -37
  249. package/dist/memory.d.ts +20 -22
  250. package/dist/memory.js +24 -48
  251. package/dist/multihop.d.ts +1 -1
  252. package/dist/multihop.js +3 -2
  253. package/dist/owner-validation.d.ts +4 -5
  254. package/dist/owner-validation.js +4 -5
  255. package/dist/physics.d.ts +4 -4
  256. package/dist/physics.js +7 -9
  257. package/dist/policies.d.ts +9 -10
  258. package/dist/policies.js +96 -81
  259. package/dist/postinstall.js +3 -6
  260. package/dist/predictions/planning-fallacy.d.ts +9 -14
  261. package/dist/predictions/planning-fallacy.js +10 -16
  262. package/dist/predictions/store.d.ts +15 -23
  263. package/dist/predictions/store.js +36 -33
  264. package/dist/processes.d.ts +2 -7
  265. package/dist/processes.js +88 -72
  266. package/dist/project-briefs.d.ts +2 -3
  267. package/dist/project-briefs.js +141 -118
  268. package/dist/project-identity.d.ts +22 -9
  269. package/dist/project-identity.js +47 -12
  270. package/dist/project-merge.d.ts +28 -5
  271. package/dist/project-merge.js +213 -46
  272. package/dist/project-remote.d.ts +12 -0
  273. package/dist/project-remote.js +138 -0
  274. package/dist/prompt-recall.js +1 -2
  275. package/dist/rate-limit.d.ts +1 -1
  276. package/dist/rate-limit.js +1 -1
  277. package/dist/raw-archive.d.ts +9 -0
  278. package/dist/raw-archive.js +70 -53
  279. package/dist/recall-history.d.ts +19 -20
  280. package/dist/recall-history.js +24 -42
  281. package/dist/recall-pipeline.js +4 -28
  282. package/dist/recall-scope.d.ts +7 -8
  283. package/dist/recall-scope.js +7 -8
  284. package/dist/recall-trace.d.ts +5 -9
  285. package/dist/recall-trace.js +6 -10
  286. package/dist/refine-llm.d.ts +1 -1
  287. package/dist/refine-llm.js +2 -2
  288. package/dist/reject-flow.d.ts +3 -4
  289. package/dist/reject-flow.js +122 -117
  290. package/dist/rejection.d.ts +5 -6
  291. package/dist/rejection.js +7 -15
  292. package/dist/rerankers/clef.d.ts +1 -1
  293. package/dist/rerankers/jev.d.ts +1 -2
  294. package/dist/rerankers/jev.js +4 -5
  295. package/dist/rerankers/llm.d.ts +1 -2
  296. package/dist/rerankers/llm.js +1 -2
  297. package/dist/rerankers/types.d.ts +1 -2
  298. package/dist/rrf.d.ts +2 -2
  299. package/dist/rrf.js +2 -2
  300. package/dist/search/bm25-search.d.ts +1 -1
  301. package/dist/search/bm25-search.js +2 -1
  302. package/dist/search/boosts.js +2 -1
  303. package/dist/search/hybrid.d.ts +1 -1
  304. package/dist/search/hybrid.js +2 -1
  305. package/dist/search/physics-search.d.ts +1 -1
  306. package/dist/search/physics-search.js +2 -1
  307. package/dist/search/types.d.ts +2 -0
  308. package/dist/search/types.js +3 -1
  309. package/dist/secret-detect.d.ts +4 -5
  310. package/dist/secret-detect.js +6 -10
  311. package/dist/server/auth.js +5 -5
  312. package/dist/server/client-ip.js +1 -1
  313. package/dist/server/cursor.js +2 -1
  314. package/dist/server/mcp-http.js +4 -4
  315. package/dist/server/request.d.ts +3 -6
  316. package/dist/server/request.js +6 -7
  317. package/dist/server/routes/admin.js +5 -4
  318. package/dist/server/routes/customer-notes.js +6 -5
  319. package/dist/server/routes/decisions.js +4 -3
  320. package/dist/server/routes/incidents.js +7 -5
  321. package/dist/server/routes/memories.js +7 -7
  322. package/dist/server/routes/policies.js +3 -2
  323. package/dist/server/routes/predictions.js +12 -15
  324. package/dist/server/routes/processes.js +3 -2
  325. package/dist/server/routes/project-briefs.js +8 -7
  326. package/dist/server/routes/recall.js +95 -93
  327. package/dist/server/routes/skills.js +6 -5
  328. package/dist/server/types.d.ts +1 -1
  329. package/dist/server/validation.d.ts +1 -2
  330. package/dist/server/validation.js +7 -14
  331. package/dist/server-detect.js +72 -58
  332. package/dist/server.d.ts +2 -2
  333. package/dist/server.js +131 -117
  334. package/dist/shared.d.ts +26 -17
  335. package/dist/shared.js +102 -104
  336. package/dist/skills.d.ts +3 -3
  337. package/dist/skills.js +88 -72
  338. package/dist/store/audit-event.d.ts +2 -2
  339. package/dist/store/audit-event.js +1 -1
  340. package/dist/store/candidates.d.ts +2 -2
  341. package/dist/store/candidates.js +4 -3
  342. package/dist/store/conflicts.js +30 -22
  343. package/dist/store/delete-and-batch.d.ts +11 -14
  344. package/dist/store/delete-and-batch.js +40 -91
  345. package/dist/store/entry-reads.d.ts +17 -25
  346. package/dist/store/entry-reads.js +59 -40
  347. package/dist/store/entry-row.d.ts +6 -24
  348. package/dist/store/entry-row.js +6 -24
  349. package/dist/store/entry-writes.d.ts +6 -7
  350. package/dist/store/entry-writes.js +13 -11
  351. package/dist/store/handoffs.d.ts +1 -1
  352. package/dist/store/handoffs.js +7 -10
  353. package/dist/store/index-and-stats.d.ts +2 -6
  354. package/dist/store/index-and-stats.js +4 -10
  355. package/dist/store/mirrors.d.ts +6 -19
  356. package/dist/store/mirrors.js +14 -39
  357. package/dist/store/open.js +9 -31
  358. package/dist/store/rows.d.ts +5 -11
  359. package/dist/store/rows.js +6 -11
  360. package/dist/store/search-rows.d.ts +17 -34
  361. package/dist/store/search-rows.js +34 -56
  362. package/dist/store/sessions.d.ts +4 -5
  363. package/dist/store/sessions.js +5 -6
  364. package/dist/store/summaries.d.ts +13 -17
  365. package/dist/store/summaries.js +26 -70
  366. package/dist/support-bundle.js +4 -8
  367. package/dist/tenant.d.ts +1 -5
  368. package/dist/token-ledger.d.ts +1 -1
  369. package/dist/token-ledger.js +3 -5
  370. package/dist/trace.js +1 -3
  371. package/dist/version.d.ts +1 -1
  372. package/dist/version.js +1 -1
  373. package/dist/working-memory.d.ts +1 -1
  374. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  375. package/extensions/openclaw-plugin/package.json +1 -1
  376. package/openclaw.plugin.json +1 -1
  377. package/package.json +1 -1
@@ -7,11 +7,11 @@ import { updateStats } from '../../store/index-and-stats.js';
7
7
  import { appendRecall, biasHintEnabled, buildSessionKey, getOrCreateRing, hashQueryText, snapshotRing } from '../../recall-history.js';
8
8
  import { appendAuditEvent, auditQueryFields } from '../../audit.js';
9
9
  import { assemble, drillDown, getContext, recordTokens, retrieve } from '../../api.js';
10
- import { HttpError, sendJson } from '../../http-util.js';
10
+ import { HttpError, MAX_ID_LEN, sendJson } from '../../http-util.js';
11
11
  import { buildContextWithAuth } from '../auth.js';
12
12
  import { parseListLimit, validateIdSegment } from '../validation.js';
13
- // v0.33 / J1 — Module-level per-(tenant, session) recall-history ring map
14
- // for the HTTP pipeline. Separate from CLI/MCP rings per plan v3 (per-
13
+ // Module-level per-(tenant, session) recall-history ring map
14
+ // for the HTTP pipeline. Separate from CLI/MCP rings (per-
15
15
  // pipeline rings; no IPC). HTTP is the only caller that threads its
16
16
  // snapshot through opts.recallHistory to api.recall — api.recall's
17
17
  // anchoringHint on the returned RecallResult IS the user-visible hint
@@ -21,8 +21,40 @@ const sessionRecallHistoryHttp = new Map();
21
21
  export function __resetSessionRecallHistoryHttp() {
22
22
  sessionRecallHistoryHttp.clear();
23
23
  }
24
- // GET /v1/memories?q=...&limit=...&mode=...&scope=...&include_continuity=1
25
- export async function handleRecallMemories({ req, res, opts, query }) {
24
+ function parseFreshTail(query) {
25
+ // Surface the fresh-tail RecallOpts to HTTP callers so session-scoped
26
+ // fresh-tail and summary substitution are not JS-only.
27
+ const freshTailCountRaw = query.get('fresh_tail_count');
28
+ const freshTailCount = freshTailCountRaw === null ? undefined : Number(freshTailCountRaw);
29
+ if (freshTailCount !== undefined && (!Number.isFinite(freshTailCount) || freshTailCount < 0)) {
30
+ throw new HttpError(400, 'fresh_tail_count must be a non-negative number');
31
+ }
32
+ // Cap session_id length consistent with the
33
+ // rest of the API. Untrimmed strings round-trip through the SQL layer
34
+ // and through any downstream metric/log; 256 is generous for a session
35
+ // id and matches the rest of this file's id-shaped param parsers.
36
+ const freshTailSessionIdRaw = query.get('fresh_tail_session_id');
37
+ if (freshTailSessionIdRaw !== null && freshTailSessionIdRaw.length > MAX_ID_LEN) {
38
+ throw new HttpError(400, `fresh_tail_session_id exceeds ${MAX_ID_LEN}-character cap`);
39
+ }
40
+ const freshTailSessionId = freshTailSessionIdRaw && freshTailSessionIdRaw.length > 0
41
+ ? freshTailSessionIdRaw
42
+ : undefined;
43
+ return { freshTailCount, freshTailSessionId };
44
+ }
45
+ function parseSessionId(query) {
46
+ // session_id for the dlPFC goal-stack boost. 256-char cap mirrors
47
+ // fresh_tail_session_id (above). Trim then drop if empty so api.recall
48
+ // sees undefined when the param is omitted or whitespace-only.
49
+ const sessionIdRaw = query.get('session_id');
50
+ if (sessionIdRaw !== null && sessionIdRaw.length > MAX_ID_LEN) {
51
+ throw new HttpError(400, `session_id exceeds ${MAX_ID_LEN}-character cap`);
52
+ }
53
+ return sessionIdRaw && sessionIdRaw.trim().length > 0
54
+ ? sessionIdRaw.trim()
55
+ : undefined;
56
+ }
57
+ function parseRecallQuery(query) {
26
58
  const q = query.get('q');
27
59
  if (!q) {
28
60
  throw new HttpError(400, 'q is required');
@@ -37,29 +69,9 @@ export async function handleRecallMemories({ req, res, opts, query }) {
37
69
  const includeContinuityRaw = query.get('include_continuity');
38
70
  const includeContinuity = includeContinuityRaw === '1'
39
71
  || includeContinuityRaw === 'true';
40
- // v1.6.2: surface the v1.5.0/v1.5.2 RecallOpts additions to HTTP
41
- // callers. Pre-v1.6.2 the route silently ignored these so the
42
- // session-scoped fresh-tail and summary substitution were JS-only.
43
- const freshTailCountRaw = query.get('fresh_tail_count');
44
- const freshTailCount = freshTailCountRaw === null ? undefined : Number(freshTailCountRaw);
45
- if (freshTailCount !== undefined && (!Number.isFinite(freshTailCount) || freshTailCount < 0)) {
46
- throw new HttpError(400, 'fresh_tail_count must be a non-negative number');
47
- }
48
- // v1.6.3 senior-review P1-3: cap session_id length consistent with the
49
- // rest of the API. Untrimmed strings round-trip through the SQL layer
50
- // and through any downstream metric/log; 256 is generous for a session
51
- // id and matches the rest of this file's id-shaped param parsers.
52
- const freshTailSessionIdRaw = query.get('fresh_tail_session_id');
53
- if (freshTailSessionIdRaw !== null && freshTailSessionIdRaw.length > 256) {
54
- throw new HttpError(400, 'fresh_tail_session_id exceeds 256-character cap');
55
- }
56
- const freshTailSessionId = freshTailSessionIdRaw && freshTailSessionIdRaw.length > 0
57
- ? freshTailSessionIdRaw
58
- : undefined;
59
- // v1.6.3 senior-review P1-4: tighten parser to match the includeContinuity
60
- // convention. Pre-v1.6.3 accepted any non-'0'/'false' value as `true`,
61
- // so `?summarize_overflow=banana` and `?summarize_overflow=` both
62
- // turned it on. Surface convention drift fixed.
72
+ const { freshTailCount, freshTailSessionId } = parseFreshTail(query);
73
+ // Strict parse matching the includeContinuity convention, so values
74
+ // like `?summarize_overflow=banana` or an empty value do not turn it on.
63
75
  const summarizeOverflowRaw = query.get('summarize_overflow');
64
76
  const summarizeOverflow = summarizeOverflowRaw === null
65
77
  ? undefined
@@ -70,40 +82,29 @@ export async function handleRecallMemories({ req, res, opts, query }) {
70
82
  if (scorerWindow !== undefined && scorerWindow > 1000) {
71
83
  throw new HttpError(400, 'scorer_window must be <= 1000');
72
84
  }
73
- // v1.7.4: session_id for the dlPFC goal-stack boost. 256-char cap mirrors
74
- // fresh_tail_session_id (above). Trim then drop if empty so api.recall
75
- // sees undefined when the param is omitted or whitespace-only.
76
- const sessionIdRaw = query.get('session_id');
77
- if (sessionIdRaw !== null && sessionIdRaw.length > 256) {
78
- throw new HttpError(400, 'session_id exceeds 256-character cap');
79
- }
80
- const sessionId = sessionIdRaw && sessionIdRaw.trim().length > 0
81
- ? sessionIdRaw.trim()
82
- : undefined;
83
- // A7 recall-trace: opt-in explain flag. When set, api.recall attaches the
85
+ const sessionId = parseSessionId(query);
86
+ // Recall-trace: opt-in explain flag. When set, api.recall attaches the
84
87
  // lifecycle re-ranking trace (goal-boost step on the api pipeline) +
85
88
  // rerankPipeline:'api' to each result item; the field then rides on the
86
89
  // serialized RecallResult. Mirrors the include_continuity convention.
87
90
  const explainRaw = query.get('explain');
88
91
  const explain = explainRaw === '1' || explainRaw === 'true';
89
- const ctx = await buildContextWithAuth(req, opts);
90
- // v0.33 / J1 — HTTP per-pipeline anchoring detector. HTTP threads its
91
- // ring snapshot via opts.recallHistory so api.recall's own
92
- // anchoringHint compute path activates. Unlike CLI (which computes
93
- // its own hint separately because cmdRecall runs its own physics/
94
- // hybrid pipeline outside api.recall), HTTP's /v1/memories response
95
- // body IS api.recall's result directly. So the api.recall-computed
96
- // hint flows through. HIPPO_ANCHORING=off short-circuits.
92
+ return { q, limit, mode, scope, includeContinuity, freshTailCount, freshTailSessionId, summarizeOverflow, scorerWindow, sessionId, explain };
93
+ }
94
+ // HTTP per-pipeline anchoring detector. HTTP threads its
95
+ // ring snapshot via opts.recallHistory so api.recall's own
96
+ // anchoringHint compute path activates. Unlike CLI (which computes
97
+ // its own hint separately because cmdRecall runs its own physics/
98
+ // hybrid pipeline outside api.recall), HTTP's /v1/memories response
99
+ // body IS api.recall's result directly. So the api.recall-computed
100
+ // hint flows through. HIPPO_ANCHORING=off short-circuits.
101
+ function snapshotSessionRing(ctx, hippoRoot, q, sessionId) {
97
102
  let httpRecallHistory;
98
103
  let httpRingKey;
99
104
  if (biasHintEnabled('anchoring')) {
100
105
  if (sessionId) {
101
- // Codex round-5 P2 catch: do NOT mutate sessionRecallHistoryHttp
102
- // before recall() preflight runs. A request with an invalid
103
- // scorer_window / fresh_tail_count would create-or-touch the
104
- // session ring (LRU-evicting valid sessions) even though recall
105
- // throws 400. Snapshot the EXISTING ring if present; only
106
- // create-or-touch after the recall returns successfully.
106
+ // Do NOT mutate sessionRecallHistoryHttp before recall() preflight: a request
107
+ // that 400s would otherwise create-or-touch a ring and LRU-evict valid sessions.
107
108
  httpRingKey = buildSessionKey(ctx.tenantId, sessionId);
108
109
  const existingRing = sessionRecallHistoryHttp.get(httpRingKey);
109
110
  httpRecallHistory = existingRing ? snapshotRing(existingRing) : [];
@@ -113,12 +114,9 @@ export async function handleRecallMemories({ req, res, opts, query }) {
113
114
  // Per the normal recall-audit convention (api.ts:854 stores
114
115
  // SHA-256/16 hash of the query, NOT raw text), avoid retaining
115
116
  // prompts in audit_log here too — query content can contain
116
- // secrets, PII, or RTBF-restricted material. Codex round-2 P2
117
- // catch: hashQueryText is a 32-bit FNV-1a designed for recall
118
- // matching, NOT a privacy hash; brute-force trivial for low-
119
- // entropy queries. Use the same SHA-256/16 truncation as the
120
- // canonical recall audit.
121
- const dbForAudit = openHippoDb(opts.hippoRoot);
117
+ // secrets, PII, or RTBF-restricted material. hashQueryText is a 32-bit
118
+ // FNV-1a, NOT a privacy hash, so use the recall audit's SHA-256/16 truncation.
119
+ const dbForAudit = openHippoDb(hippoRoot);
122
120
  try {
123
121
  appendAuditEvent(dbForAudit, {
124
122
  tenantId: ctx.tenantId,
@@ -133,36 +131,42 @@ export async function handleRecallMemories({ req, res, opts, query }) {
133
131
  }
134
132
  }
135
133
  }
134
+ return { httpRecallHistory, httpRingKey };
135
+ }
136
+ function recallExtraOpts(parsed, httpRecallHistory) {
136
137
  const recallExtra = {};
137
- if (freshTailCount !== undefined)
138
- recallExtra.freshTailCount = freshTailCount;
139
- if (freshTailSessionId !== undefined)
140
- recallExtra.freshTailSessionId = freshTailSessionId;
141
- if (summarizeOverflow !== undefined)
142
- recallExtra.summarizeOverflow = summarizeOverflow;
143
- if (scorerWindow !== undefined)
144
- recallExtra.scorerWindow = scorerWindow;
145
- if (sessionId !== undefined)
146
- recallExtra.sessionId = sessionId;
138
+ if (parsed.freshTailCount !== undefined)
139
+ recallExtra.freshTailCount = parsed.freshTailCount;
140
+ if (parsed.freshTailSessionId !== undefined)
141
+ recallExtra.freshTailSessionId = parsed.freshTailSessionId;
142
+ if (parsed.summarizeOverflow !== undefined)
143
+ recallExtra.summarizeOverflow = parsed.summarizeOverflow;
144
+ if (parsed.scorerWindow !== undefined)
145
+ recallExtra.scorerWindow = parsed.scorerWindow;
146
+ if (parsed.sessionId !== undefined)
147
+ recallExtra.sessionId = parsed.sessionId;
147
148
  if (httpRecallHistory !== undefined)
148
149
  recallExtra.recallHistory = httpRecallHistory;
149
- if (explain)
150
- recallExtra.explain = explain;
150
+ if (parsed.explain)
151
+ recallExtra.explain = parsed.explain;
152
+ return recallExtra;
153
+ }
154
+ // GET /v1/memories?q=...&limit=...&mode=...&scope=...&include_continuity=1
155
+ export async function handleRecallMemories({ req, res, opts, query }) {
156
+ const parsed = parseRecallQuery(query);
157
+ const { q, includeContinuity, sessionId } = parsed;
158
+ const ctx = await buildContextWithAuth(req, opts);
159
+ const { httpRecallHistory, httpRingKey } = snapshotSessionRing(ctx, opts.hippoRoot, q, sessionId);
151
160
  const result = await retrieve(ctx, {
152
161
  query: q,
153
- limit,
154
- mode: mode ?? undefined,
155
- scope: scope ?? undefined,
162
+ limit: parsed.limit,
163
+ mode: parsed.mode ?? undefined,
164
+ scope: parsed.scope ?? undefined,
156
165
  includeContinuity,
157
- ...recallExtra,
166
+ ...recallExtraOpts(parsed, httpRecallHistory),
158
167
  });
159
- // v0.33 / J1 — append AFTER recall completes (snapshot was taken before
160
- // recall() ran). anchoredOn carries the memoryId of any hint that fired
161
- // (api.recall computed it from the same snapshot we passed in), feeding
162
- // the cooldown logic for the NEXT recall on this session.
163
- // Codex round-5 P2 fix: create-or-touch the ring ONLY HERE, after recall
164
- // returns successfully. Invalid requests that throw 400 in recall()
165
- // never reach this point, so they cannot LRU-evict valid sessions.
168
+ // Append only after recall succeeds, so a 400 cannot LRU-evict valid sessions;
169
+ // anchoredOn feeds the cooldown logic for the NEXT recall on this session.
166
170
  if (httpRingKey) {
167
171
  const httpRing = getOrCreateRing(sessionRecallHistoryHttp, httpRingKey);
168
172
  const topId = result.results[0]?.id ?? null;
@@ -196,10 +200,8 @@ export async function handleAssembleSession({ req, res, opts, query }, assembleM
196
200
  if (freshTailCount !== undefined && (!Number.isFinite(freshTailCount) || freshTailCount < 0)) {
197
201
  throw new HttpError(400, 'freshTail must be a non-negative number');
198
202
  }
199
- // v1.6.3 senior review P1: same strict-parse convention as the v1.6.3
200
- // summarize_overflow tighten on /v1/memories. Pre-v1.6.3 accepted any
201
- // non-'0'/'false' as true; ?summarizeOlder=banana now correctly returns
202
- // false (matches includeContinuity convention).
203
+ // Same strict-parse convention as summarize_overflow on /v1/memories:
204
+ // ?summarizeOlder=banana is false (matches includeContinuity convention).
203
205
  const sumOlderRaw = query.get('summarizeOlder');
204
206
  const summarizeOlder = sumOlderRaw === null
205
207
  ? undefined
@@ -239,12 +241,12 @@ export async function handleDrillRecall({ req, res, opts, query }, drillMatch) {
239
241
  if (budget !== undefined && (!Number.isFinite(budget) || budget <= 0)) {
240
242
  throw new HttpError(400, 'budget must be a positive number');
241
243
  }
242
- // v0.30 / E5: depth query param walks N levels (default 1, hard cap 10).
244
+ // depth query param walks N levels (default 1, hard cap 10).
243
245
  const depthRaw = query.get('depth');
244
246
  let depth;
245
247
  if (depthRaw !== null) {
246
248
  const parsed = Number(depthRaw);
247
- // L4 fold: reject out-of-range explicitly (no silent clamp).
249
+ // Reject out-of-range explicitly (no silent clamp).
248
250
  if (!Number.isInteger(parsed) || parsed < 1 || parsed > 10) {
249
251
  throw new HttpError(400, 'depth must be a positive integer between 1 and 10');
250
252
  }
@@ -260,7 +262,7 @@ export async function handleDrillRecall({ req, res, opts, query }, drillMatch) {
260
262
  drillExtra.depth = depth;
261
263
  const result = drillDown(ctx, drillMatch.id, { ...drillExtra, cost: drillCost });
262
264
  if ('failure' in result) {
263
- // v1.6.4: leaf id maps to 422 (caller-actionable). Other cases stay
265
+ // Leaf id maps to 422 (caller-actionable). Other cases stay
264
266
  // as 404 to avoid leaking cross-tenant existence or scope grants.
265
267
  if (result.failure === 'not_drillable') {
266
268
  throw new HttpError(422, 'Id is a leaf row, not a level-2+ summary; nothing to drill into');
@@ -277,7 +279,7 @@ export async function handleDrillRecall({ req, res, opts, query }, drillMatch) {
277
279
  // (matches cmdContext); real-query hybrid search emits one 'recall' row.
278
280
  export async function handleGetContext({ req, res, opts, query }) {
279
281
  const q = query.get('q') ?? undefined;
280
- // v1.11.5: DoS cap on q-param length. 1024 covers real multi-clause queries
282
+ // DoS cap on q-param length. 1024 covers real multi-clause queries
281
283
  // (pasted error messages, multi-stem searches) while bounding BM25
282
284
  // tokenisation cost (~150 tokens worst case at 1024 chars).
283
285
  if (q !== undefined && q.length > 1024) {
@@ -302,8 +304,8 @@ export async function handleGetContext({ req, res, opts, query }) {
302
304
  const pinnedOnlyRaw = query.get('pinned_only');
303
305
  const pinnedOnly = pinnedOnlyRaw === '1' || pinnedOnlyRaw === 'true';
304
306
  const scopeRaw = query.get('scope');
305
- if (scopeRaw !== null && scopeRaw.length > 256) {
306
- throw new HttpError(400, 'scope exceeds 256-character cap');
307
+ if (scopeRaw !== null && scopeRaw.length > MAX_ID_LEN) {
308
+ throw new HttpError(400, `scope exceeds ${MAX_ID_LEN}-character cap`);
307
309
  }
308
310
  const scope = scopeRaw === null ? undefined : scopeRaw;
309
311
  const includeRecentRaw = query.get('include_recent');
@@ -330,7 +332,7 @@ export async function handleGetContext({ req, res, opts, query }) {
330
332
  scope,
331
333
  includeRecent,
332
334
  crossProject,
333
- currentProject: resolveProjectIdentity(dirname(resolve(opts.hippoRoot))).name,
335
+ currentProject: resolveProjectIdentity(dirname(resolve(opts.hippoRoot))),
334
336
  cost: contextCost('markdown', 'observe'), // clients render; the budget prices the block `hippo context` would print
335
337
  });
336
338
  recordTokens(ctx, 'http_context', { items: result.entries.length, tokens: result.tokens });
@@ -1,10 +1,11 @@
1
1
  // /v1/skills routes.
2
- import { closeSkill, exportSkills, loadSkillById, loadSkills, saveSkill, VALID_SKILL_STATES } from '../../skills.js';
2
+ import { closeSkill, exportSkills, loadSkillById, loadSkills, MAX_SKILL_NAME_LEN, saveSkill, VALID_SKILL_STATES } from '../../skills.js';
3
3
  import { HttpError, sendJson } from '../../http-util.js';
4
4
  import { buildContextWithAuth } from '../auth.js';
5
5
  import { byCreatedAt, pageOf, parseCursor } from '../cursor.js';
6
- import { isJsonString, isSetMember, parseJsonBody, parseListLimit } from '../validation.js';
7
- // ── skills (E2 first-class object, executable/exportable) ──
6
+ import { isSetMember, parseJsonBody, parseListLimit } from '../validation.js';
7
+ import { isJsonString } from '../../json.js';
8
+ // ── skills (first-class object, executable/exportable) ──
8
9
  //
9
10
  // 6 routes: POST /v1/skills (new; body skillName + instructions + trigger?),
10
11
  // GET /v1/skills (list, status filter; shared parseListLimit), GET
@@ -23,8 +24,8 @@ export async function handleCreateSkill({ req, res, opts }) {
23
24
  if (!isJsonString(skillName) || skillName.trim().length === 0) {
24
25
  throw new HttpError(400, 'skillName is required (non-empty string)');
25
26
  }
26
- if (skillName.length > 256) {
27
- throw new HttpError(400, 'skillName exceeds 256-character cap');
27
+ if (skillName.length > MAX_SKILL_NAME_LEN) {
28
+ throw new HttpError(400, `skillName exceeds ${MAX_SKILL_NAME_LEN}-character cap`);
28
29
  }
29
30
  const instructions = body['instructions'];
30
31
  if (!isJsonString(instructions) || instructions.trim().length === 0) {
@@ -3,7 +3,7 @@ export interface ServerHandle {
3
3
  port: number;
4
4
  url: string;
5
5
  stop: () => Promise<void>;
6
- /** Introspection-only (v1.26.2): the underlying node:http Server, exposed so
6
+ /** Introspection-only: the underlying node:http Server, exposed so
7
7
  * tests can assert keep-alive/headers timeout hardening without reaching
8
8
  * into serve()'s closure. Additive field — do not depend on it for control
9
9
  * flow outside tests. */
@@ -1,7 +1,6 @@
1
1
  import type { IncomingMessage } from 'node:http';
2
2
  import type { Context } from '../api.js';
3
- import { type JsonValue } from '../http-util.js';
4
- export declare function isJsonString(value: JsonValue | undefined): value is string;
3
+ import { type JsonValue } from '../json.js';
5
4
  export declare function isJsonNumber(value: JsonValue | undefined): value is number;
6
5
  export declare function isJsonBoolean(value: JsonValue | undefined): value is boolean;
7
6
  export declare function isSetMember<T extends string>(set: ReadonlySet<T>, value: string): value is T;
@@ -1,7 +1,5 @@
1
- import { HttpError, isJsonObjectRecord, readBody } from '../http-util.js';
2
- export function isJsonString(value) {
3
- return typeof value === 'string';
4
- }
1
+ import { HttpError, isJsonObjectRecord, MAX_ID_LEN, readBody } from '../http-util.js';
2
+ import { isJsonString } from '../json.js';
5
3
  export function isJsonNumber(value) {
6
4
  return typeof value === 'number';
7
5
  }
@@ -21,13 +19,8 @@ export function isSetMember(set, value) {
21
19
  // performs the actual narrowing for callers.
22
20
  return set.has(value);
23
21
  }
24
- // Parse a `?limit=` query param for the E2 list routes. Defaults to 100; requires
25
- // a positive INTEGER <= 1000. Number.isInteger rejects fractional values like
26
- // "1.5" that Number.isFinite would pass but SQLite `LIMIT ?` rejects with a
27
- // datatype mismatch (a 500). Shared across the decision/incident/process/policy
28
- // list routes so the guard cannot drift (codex review 2026-05-30 P2: fractional
29
- // limit reached SQLite on the policy route; the same latent hole existed in the
30
- // sibling routes this was copied from).
22
+ // Number.isInteger, not isFinite: SQLite `LIMIT ?` rejects "1.5" with a 500.
23
+ // Shared by every first-class-object list route so the guard cannot drift.
31
24
  export function parseListLimit(limitRaw, defaultLimit = 100, maxLimit = 1000) {
32
25
  if (limitRaw === null)
33
26
  return defaultLimit;
@@ -68,7 +61,7 @@ export function getStringArray(obj, key) {
68
61
  return v;
69
62
  }
70
63
  /**
71
- * v1.6.4: charset + length validation for `:id` route captures. Routes call
64
+ * Charset + length validation for `:id` route captures. Routes call
72
65
  * this immediately after `matchPath` to reject empty / overlong / illegal
73
66
  * ids with a useful 400 instead of silently falling through to "not found".
74
67
  *
@@ -82,8 +75,8 @@ const ID_SEGMENT_RE = /^[A-Za-z0-9_:.\-]+$/;
82
75
  export function validateIdSegment(id, fieldName) {
83
76
  if (id.length === 0)
84
77
  throw new HttpError(400, `${fieldName} is required`);
85
- if (id.length > 256)
86
- throw new HttpError(400, `${fieldName} exceeds 256-character cap`);
78
+ if (id.length > MAX_ID_LEN)
79
+ throw new HttpError(400, `${fieldName} exceeds ${MAX_ID_LEN}-character cap`);
87
80
  if (!ID_SEGMENT_RE.test(id)) {
88
81
  throw new HttpError(400, `${fieldName} contains invalid characters; allowed: A-Z a-z 0-9 _ : . -`);
89
82
  }
@@ -23,33 +23,8 @@ const HEALTH_BODY_MAX_BYTES = 64 * 1024;
23
23
  * with any other host is malformed or forged and must not be probed.
24
24
  */
25
25
  const PIDFILE_LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']);
26
- /**
27
- * Read .hippo/server.pid and return the embedded ServerInfo if a live hippo
28
- * server is genuinely answering on the recorded url. Returns null on missing,
29
- * malformed, or stale pidfiles, and best-effort unlinks the file in the
30
- * stale/malformed cases.
31
- *
32
- * Liveness is proven in two steps. `process.kill(pid, 0)` rules out dead
33
- * pids. But a pid can be reused by an unrelated process, so a GET /health
34
- * then confirms the process that answers is *this* hippo server: its
35
- * `started_at` must equal the pidfile's. A mismatch, non-200, malformed
36
- * body, or timeout all mean the pidfile is stale.
37
- *
38
- * The /health probe runs only when a pidfile exists and the pid is live, so
39
- * the common no-server path stays a single fast file existence check.
40
- */
41
- export async function detectServer(hippoRoot) {
42
- const path = join(hippoRoot, PIDFILE);
43
- if (!existsSync(path))
44
- return null;
45
- let info;
46
- try {
47
- info = JSON.parse(readFileSync(path, 'utf8'));
48
- }
49
- catch {
50
- removePidfile(hippoRoot);
51
- return null;
52
- }
26
+ /** False when the pidfile's pid is dead or its url could not be one serve() wrote. */
27
+ function isLiveLoopbackTarget(info) {
53
28
  // Probe the process. Sending signal 0 throws if the pid is dead or owned
54
29
  // by another user we cannot signal. Either way, treat as stale.
55
30
  // Node's process.kill(pid, 0) is implemented on Windows via OpenProcess +
@@ -58,8 +33,7 @@ export async function detectServer(hippoRoot) {
58
33
  process.kill(info.pid, 0);
59
34
  }
60
35
  catch {
61
- removePidfile(hippoRoot);
62
- return null;
36
+ return false;
63
37
  }
64
38
  // The pid is live, but it may have been reused by an unrelated process, and
65
39
  // the recorded url is read from a file anyone could forge. serve() only ever
@@ -71,15 +45,37 @@ export async function detectServer(hippoRoot) {
71
45
  probeUrl = new URL(info.url);
72
46
  }
73
47
  catch {
74
- removePidfile(hippoRoot);
75
- return null;
48
+ return false;
76
49
  }
77
- if (probeUrl.protocol !== 'http:' ||
78
- !PIDFILE_LOOPBACK_HOSTS.has(probeUrl.hostname) ||
79
- probeUrl.port !== String(info.port)) {
80
- removePidfile(hippoRoot);
81
- return null;
50
+ return (probeUrl.protocol === 'http:' &&
51
+ PIDFILE_LOOPBACK_HOSTS.has(probeUrl.hostname.replace(/^\[(.*)\]$/, '$1')) &&
52
+ probeUrl.port === String(info.port));
53
+ }
54
+ /** The whole body as text, or null once it passes HEALTH_BODY_MAX_BYTES (the stream is then cancelled). */
55
+ async function readCappedBody(body) {
56
+ // Read the body under a hard byte cap. The process answering on info.url
57
+ // may not be hippo (pid reuse is the case this probe guards against), so
58
+ // its response is untrusted: never hand an unbounded stream to a parser.
59
+ const reader = body.getReader();
60
+ const decoder = new TextDecoder();
61
+ let raw = '';
62
+ let received = 0;
63
+ for (;;) {
64
+ const { done, value } = await reader.read();
65
+ if (done)
66
+ break;
67
+ received += value.byteLength;
68
+ if (received > HEALTH_BODY_MAX_BYTES) {
69
+ await reader.cancel();
70
+ return null;
71
+ }
72
+ raw += decoder.decode(value, { stream: true });
82
73
  }
74
+ raw += decoder.decode();
75
+ return raw;
76
+ }
77
+ /** True when /health on info.url reports the pidfile's started_at; unlinks the pidfile on any definitive mismatch. */
78
+ async function healthMatchesPidfile(hippoRoot, info) {
83
79
  // Confirm the process answering on info.url is this hippo server by matching
84
80
  // the /health `started_at` against the pidfile. A connection refusal, a
85
81
  // non-200, or a malformed body unlink the pidfile as stale. A probe timeout
@@ -92,33 +88,19 @@ export async function detectServer(hippoRoot) {
92
88
  });
93
89
  if (!res.ok || !res.body) {
94
90
  removePidfile(hippoRoot);
95
- return null;
91
+ return false;
96
92
  }
97
- // Read the body under a hard byte cap. The process answering on info.url
98
- // may not be hippo (pid reuse is the case this probe guards against), so
99
- // its response is untrusted: never hand an unbounded stream to a parser.
100
- const reader = res.body.getReader();
101
- const decoder = new TextDecoder();
102
- let raw = '';
103
- let received = 0;
104
- for (;;) {
105
- const { done, value } = await reader.read();
106
- if (done)
107
- break;
108
- received += value.byteLength;
109
- if (received > HEALTH_BODY_MAX_BYTES) {
110
- await reader.cancel();
111
- removePidfile(hippoRoot);
112
- return null;
113
- }
114
- raw += decoder.decode(value, { stream: true });
93
+ const raw = await readCappedBody(res.body);
94
+ if (raw === null) {
95
+ removePidfile(hippoRoot);
96
+ return false;
115
97
  }
116
- raw += decoder.decode();
117
98
  const body = JSON.parse(raw);
118
99
  if (body.started_at !== info.started_at) {
119
100
  removePidfile(hippoRoot);
120
- return null;
101
+ return false;
121
102
  }
103
+ return true;
122
104
  }
123
105
  catch (err) {
124
106
  // A timeout is ambiguous (the server may be alive but busy), so keep the
@@ -129,9 +111,41 @@ export async function detectServer(hippoRoot) {
129
111
  if (err?.name !== 'TimeoutError') {
130
112
  removePidfile(hippoRoot);
131
113
  }
114
+ return false;
115
+ }
116
+ }
117
+ /**
118
+ * Read .hippo/server.pid and return the embedded ServerInfo if a live hippo
119
+ * server is genuinely answering on the recorded url. Returns null on missing,
120
+ * malformed, or stale pidfiles, and best-effort unlinks the file in the
121
+ * stale/malformed cases.
122
+ *
123
+ * Liveness is proven in two steps. `process.kill(pid, 0)` rules out dead
124
+ * pids. But a pid can be reused by an unrelated process, so a GET /health
125
+ * then confirms the process that answers is *this* hippo server: its
126
+ * `started_at` must equal the pidfile's. A mismatch, non-200, malformed
127
+ * body, or timeout all mean the pidfile is stale.
128
+ *
129
+ * The /health probe runs only when a pidfile exists and the pid is live, so
130
+ * the common no-server path stays a single fast file existence check.
131
+ */
132
+ export async function detectServer(hippoRoot) {
133
+ const path = join(hippoRoot, PIDFILE);
134
+ if (!existsSync(path))
135
+ return null;
136
+ let info;
137
+ try {
138
+ info = JSON.parse(readFileSync(path, 'utf8'));
139
+ }
140
+ catch {
141
+ removePidfile(hippoRoot);
142
+ return null;
143
+ }
144
+ if (!isLiveLoopbackTarget(info)) {
145
+ removePidfile(hippoRoot);
132
146
  return null;
133
147
  }
134
- return info;
148
+ return (await healthMatchesPidfile(hippoRoot, info)) ? info : null;
135
149
  }
136
150
  /**
137
151
  * Atomically write the pidfile. Writes to a process-scoped temp file then
package/dist/server.d.ts CHANGED
@@ -10,8 +10,8 @@ export type { AuthResolver, ResolvedBearer, ServeOpts, ServerHandle } from './se
10
10
  /**
11
11
  * Boot the HTTP daemon on host:port and write the pidfile under hippoRoot.
12
12
  *
13
- * Refuses non-loopback hosts at boot (Footgun #3 from the A1 plan) unless
14
- * HIPPO_REQUIRE_AUTH=1 is set. The A5 v2 auth middleware (buildContextWithAuth /
13
+ * Refuses non-loopback hosts at boot unless
14
+ * HIPPO_REQUIRE_AUTH=1 is set. The auth middleware (buildContextWithAuth /
15
15
  * requireAuth) has shipped and every route checks it except GET /health
16
16
  * (public by design for platform health checks) and the two connector
17
17
  * webhooks in PUBLIC_ROUTES, which are HMAC-gated by their own signing