hippo-memory 1.63.1 → 1.64.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 (275) hide show
  1. package/README.md +13 -5
  2. package/dist/agent-memories/claude-code.js +3 -3
  3. package/dist/ambient-store.d.ts +2 -0
  4. package/dist/ambient-store.js +4 -3
  5. package/dist/api/assemble.d.ts +2 -0
  6. package/dist/api/assemble.js +11 -7
  7. package/dist/api/auth.d.ts +15 -11
  8. package/dist/api/auth.js +116 -22
  9. package/dist/api/context-select.d.ts +5 -3
  10. package/dist/api/context-select.js +6 -11
  11. package/dist/api/context-types.d.ts +2 -1
  12. package/dist/api/context.d.ts +1 -1
  13. package/dist/api/context.js +79 -55
  14. package/dist/api/dormant.d.ts +5 -5
  15. package/dist/api/dormant.js +13 -9
  16. package/dist/api/drill-down.d.ts +2 -0
  17. package/dist/api/drill-down.js +12 -7
  18. package/dist/api/forget.d.ts +1 -1
  19. package/dist/api/forget.js +8 -4
  20. package/dist/api/learn.js +5 -3
  21. package/dist/api/outcome.d.ts +1 -1
  22. package/dist/api/outcome.js +2 -1
  23. package/dist/api/promote.js +8 -5
  24. package/dist/api/recall-types.d.ts +4 -2
  25. package/dist/api/recall.js +51 -36
  26. package/dist/api/remember.d.ts +7 -0
  27. package/dist/api/remember.js +26 -2
  28. package/dist/api/sleep-run.d.ts +28 -0
  29. package/dist/api/sleep-run.js +267 -0
  30. package/dist/api/sleep.d.ts +5 -45
  31. package/dist/api/sleep.js +5 -252
  32. package/dist/api/types.d.ts +5 -0
  33. package/dist/api/types.js +4 -0
  34. package/dist/audit-prune.js +1 -1
  35. package/dist/audit.d.ts +1 -3
  36. package/dist/audit.js +7 -103
  37. package/dist/auth.d.ts +48 -4
  38. package/dist/auth.js +111 -42
  39. package/dist/autolearn.js +2 -2
  40. package/dist/capture/caller-session.d.ts +16 -0
  41. package/dist/capture/caller-session.js +58 -0
  42. package/dist/capture/command.d.ts +19 -1
  43. package/dist/capture/command.js +28 -21
  44. package/dist/capture/compact-resume-caller.d.ts +11 -0
  45. package/dist/capture/compact-resume-caller.js +35 -0
  46. package/dist/capture/compact.d.ts +0 -14
  47. package/dist/capture/compact.js +7 -160
  48. package/dist/capture/compaction-items-caller.d.ts +16 -0
  49. package/dist/capture/compaction-items-caller.js +32 -0
  50. package/dist/capture/extract.d.ts +2 -14
  51. package/dist/capture/extract.js +135 -346
  52. package/dist/capture/failure-caller.d.ts +21 -0
  53. package/dist/capture/failure-caller.js +74 -0
  54. package/dist/capture/failure-reading.d.ts +44 -0
  55. package/dist/capture/failure-reading.js +78 -0
  56. package/dist/capture/pre-compact-caller.d.ts +16 -0
  57. package/dist/capture/pre-compact-caller.js +39 -0
  58. package/dist/capture/session-end-caller.d.ts +21 -0
  59. package/dist/capture/session-end-caller.js +30 -0
  60. package/dist/capture/session-texts.d.ts +14 -0
  61. package/dist/capture/session-texts.js +42 -0
  62. package/dist/capture/transcript.d.ts +6 -1
  63. package/dist/capture/transcript.js +20 -16
  64. package/dist/capture/working-state.d.ts +21 -0
  65. package/dist/capture/working-state.js +154 -0
  66. package/dist/capture-error.d.ts +8 -22
  67. package/dist/capture-error.js +9 -61
  68. package/dist/cli/audit.js +25 -0
  69. package/dist/cli/auth.js +4 -3
  70. package/dist/cli/context.js +13 -158
  71. package/dist/cli/continuity.js +2 -1
  72. package/dist/cli/init.js +5 -3
  73. package/dist/cli/last-sleep.d.ts +6 -0
  74. package/dist/cli/last-sleep.js +68 -0
  75. package/dist/cli/quality-repair-once.d.ts +3 -0
  76. package/dist/cli/quality-repair-once.js +36 -0
  77. package/dist/cli/recall.js +2 -1
  78. package/dist/cli/remember.d.ts +2 -0
  79. package/dist/cli/remember.js +4 -1
  80. package/dist/cli/session-hooks.d.ts +0 -3
  81. package/dist/cli/session-hooks.js +12 -49
  82. package/dist/cli/setup.js +10 -2
  83. package/dist/cli/shared.d.ts +7 -14
  84. package/dist/cli/shared.js +19 -78
  85. package/dist/cli/sleep.js +6 -3
  86. package/dist/cli/usage.js +18 -9
  87. package/dist/cli.d.ts +1 -1
  88. package/dist/cli.js +3 -2
  89. package/dist/client.js +1 -1
  90. package/dist/compaction-record.d.ts +19 -1
  91. package/dist/compaction-record.js +61 -132
  92. package/dist/compaction-spool.d.ts +45 -0
  93. package/dist/compaction-spool.js +454 -0
  94. package/dist/config.d.ts +8 -0
  95. package/dist/config.js +49 -0
  96. package/dist/connectors/github/deletion.d.ts +1 -1
  97. package/dist/connectors/github/deletion.js +5 -17
  98. package/dist/consolidate/conflicts.js +4 -0
  99. package/dist/consolidate/flush-units.d.ts +9 -0
  100. package/dist/consolidate/flush-units.js +63 -0
  101. package/dist/consolidate/llm-passes.js +5 -4
  102. package/dist/consolidate/merge.js +5 -1
  103. package/dist/consolidate/run.d.ts +2 -1
  104. package/dist/consolidate/sleep.d.ts +2 -0
  105. package/dist/consolidate/sleep.js +82 -15
  106. package/dist/context-render.d.ts +14 -0
  107. package/dist/context-render.js +28 -0
  108. package/dist/dag.js +6 -0
  109. package/dist/dashboard-actions.js +3 -2
  110. package/dist/dashboard.js +4 -3
  111. package/dist/db/backup.d.ts +6 -0
  112. package/dist/db/backup.js +29 -0
  113. package/dist/db/busy.d.ts +9 -0
  114. package/dist/db/busy.js +35 -0
  115. package/dist/db/continuity.d.ts +1 -0
  116. package/dist/db/continuity.js +31 -4
  117. package/dist/db/meta.d.ts +2 -0
  118. package/dist/db/meta.js +7 -0
  119. package/dist/db/migrate.js +2 -3
  120. package/dist/db/migrations/index.d.ts +1 -1
  121. package/dist/db/migrations/index.js +5 -1
  122. package/dist/db/migrations/v39.js +2 -7
  123. package/dist/db/migrations/v53.d.ts +3 -0
  124. package/dist/db/migrations/v53.js +16 -0
  125. package/dist/db/migrations/v54.d.ts +3 -0
  126. package/dist/db/migrations/v54.js +25 -0
  127. package/dist/db/open.d.ts +11 -0
  128. package/dist/db/open.js +27 -0
  129. package/dist/db/tables.d.ts +2 -0
  130. package/dist/db/tables.js +6 -2
  131. package/dist/db.d.ts +2 -2
  132. package/dist/db.js +2 -2
  133. package/dist/doctor.d.ts +1 -1
  134. package/dist/doctor.js +31 -15
  135. package/dist/dormant.d.ts +16 -20
  136. package/dist/dormant.js +24 -30
  137. package/dist/entry/json-hooks.d.ts +3 -0
  138. package/dist/entry/json-hooks.js +4 -0
  139. package/dist/entry/project-identity.d.ts +2 -0
  140. package/dist/entry/project-identity.js +3 -0
  141. package/dist/entry/session-text.d.ts +7 -0
  142. package/dist/entry/session-text.js +8 -0
  143. package/dist/extract.js +6 -0
  144. package/dist/failure-log.d.ts +10 -2
  145. package/dist/failure-log.js +19 -2
  146. package/dist/gated-write.js +1 -1
  147. package/dist/graph/delta.d.ts +44 -0
  148. package/dist/graph/delta.js +109 -0
  149. package/dist/graph/types.d.ts +5 -0
  150. package/dist/graph/write.d.ts +20 -20
  151. package/dist/graph/write.js +103 -36
  152. package/dist/graph-extract.d.ts +43 -9
  153. package/dist/graph-extract.js +140 -157
  154. package/dist/graph-recall.d.ts +1 -0
  155. package/dist/graph-recall.js +2 -2
  156. package/dist/graph-view.d.ts +1 -0
  157. package/dist/graph-view.js +25 -2
  158. package/dist/half-life-migration.js +5 -1
  159. package/dist/handoff-evidence.d.ts +5 -0
  160. package/dist/handoff-evidence.js +30 -0
  161. package/dist/home-path.d.ts +5 -0
  162. package/dist/home-path.js +24 -0
  163. package/dist/hooks/json-hooks.d.ts +13 -2
  164. package/dist/hooks/json-hooks.js +246 -117
  165. package/dist/hooks/opencode.js +1 -1
  166. package/dist/hooks/shared.d.ts +2 -0
  167. package/dist/hooks/shared.js +6 -1
  168. package/dist/http-util.d.ts +16 -7
  169. package/dist/http-util.js +65 -20
  170. package/dist/importers/vault.js +2 -0
  171. package/dist/index.d.ts +2 -1
  172. package/dist/index.js +1 -0
  173. package/dist/json.d.ts +2 -0
  174. package/dist/json.js +7 -0
  175. package/dist/ledger-db.d.ts +10 -0
  176. package/dist/ledger-db.js +42 -0
  177. package/dist/mcp/admin-tools.d.ts +2 -2
  178. package/dist/mcp/admin-tools.js +47 -5
  179. package/dist/mcp/memory-tools.js +7 -2
  180. package/dist/mcp/protocol.d.ts +6 -0
  181. package/dist/mcp/protocol.js +4 -2
  182. package/dist/mcp/recall-tools.js +19 -6
  183. package/dist/mcp/request.js +4 -0
  184. package/dist/mcp/session-state.d.ts +4 -0
  185. package/dist/mcp/session-state.js +15 -6
  186. package/dist/mcp/shared-gate.d.ts +4 -0
  187. package/dist/mcp/shared-gate.js +22 -0
  188. package/dist/mcp/tools.js +1 -0
  189. package/dist/memory-quality.d.ts +35 -0
  190. package/dist/memory-quality.js +191 -0
  191. package/dist/memory.d.ts +4 -4
  192. package/dist/physics-state.js +1 -1
  193. package/dist/pilot-arm.d.ts +14 -3
  194. package/dist/pilot-arm.js +32 -10
  195. package/dist/postinstall.js +2 -2
  196. package/dist/project-briefs.js +4 -4
  197. package/dist/project-identity.d.ts +14 -10
  198. package/dist/project-identity.js +29 -11
  199. package/dist/project-merge.d.ts +0 -2
  200. package/dist/project-merge.js +9 -13
  201. package/dist/prompt-hook.d.ts +65 -0
  202. package/dist/prompt-hook.js +213 -0
  203. package/dist/prompt-recall.js +1 -1
  204. package/dist/quality-repair.d.ts +27 -0
  205. package/dist/quality-repair.js +220 -0
  206. package/dist/rate-limit.d.ts +1 -0
  207. package/dist/rate-limit.js +5 -3
  208. package/dist/raw-archive.d.ts +1 -1
  209. package/dist/raw-archive.js +6 -16
  210. package/dist/recall-scope.d.ts +28 -5
  211. package/dist/recall-scope.js +52 -7
  212. package/dist/recall-trace.js +1 -1
  213. package/dist/reject-flow.d.ts +2 -0
  214. package/dist/reject-flow.js +20 -6
  215. package/dist/secret-detect.js +7 -8
  216. package/dist/server/auth.d.ts +8 -6
  217. package/dist/server/auth.js +85 -38
  218. package/dist/server/client-ip.d.ts +2 -0
  219. package/dist/server/client-ip.js +16 -1
  220. package/dist/server/mcp-http.d.ts +9 -3
  221. package/dist/server/mcp-http.js +56 -13
  222. package/dist/server/request.d.ts +1 -1
  223. package/dist/server/request.js +5 -5
  224. package/dist/server/routes/admin.js +12 -6
  225. package/dist/server/routes/memories.js +9 -1
  226. package/dist/server/routes/recall.js +14 -2
  227. package/dist/server/types.d.ts +42 -5
  228. package/dist/server/validation.d.ts +5 -2
  229. package/dist/server/validation.js +19 -2
  230. package/dist/server.d.ts +21 -4
  231. package/dist/server.js +207 -49
  232. package/dist/session-digest.d.ts +0 -1
  233. package/dist/session-digest.js +3 -12
  234. package/dist/session-owners.d.ts +7 -0
  235. package/dist/session-owners.js +54 -0
  236. package/dist/share-scrub.d.ts +3 -0
  237. package/dist/share-scrub.js +8 -0
  238. package/dist/shared.d.ts +1 -0
  239. package/dist/shared.js +26 -18
  240. package/dist/store/candidates.d.ts +14 -3
  241. package/dist/store/candidates.js +48 -26
  242. package/dist/store/conflicts.d.ts +15 -0
  243. package/dist/store/conflicts.js +68 -44
  244. package/dist/store/delete-and-batch.d.ts +25 -1
  245. package/dist/store/delete-and-batch.js +121 -69
  246. package/dist/store/entry-reads.d.ts +5 -6
  247. package/dist/store/entry-reads.js +24 -12
  248. package/dist/store/entry-row.d.ts +7 -10
  249. package/dist/store/entry-row.js +40 -18
  250. package/dist/store/entry-writes.d.ts +3 -3
  251. package/dist/store/entry-writes.js +9 -23
  252. package/dist/store/handoffs.d.ts +9 -6
  253. package/dist/store/handoffs.js +72 -59
  254. package/dist/store/index-and-stats.js +2 -2
  255. package/dist/store/open.d.ts +3 -1
  256. package/dist/store/open.js +3 -3
  257. package/dist/store/search-rows.d.ts +8 -5
  258. package/dist/store/search-rows.js +15 -18
  259. package/dist/store/sessions.d.ts +25 -6
  260. package/dist/store/sessions.js +61 -19
  261. package/dist/store/summaries.d.ts +1 -1
  262. package/dist/store/summaries.js +3 -18
  263. package/dist/store/tenant-lookup.d.ts +7 -3
  264. package/dist/store/tenant-lookup.js +9 -9
  265. package/dist/store-port.d.ts +13 -0
  266. package/dist/store-port.js +20 -0
  267. package/dist/version.d.ts +5 -1
  268. package/dist/version.js +5 -1
  269. package/dist/working-memory.js +1 -1
  270. package/dist/write-budget.d.ts +15 -0
  271. package/dist/write-budget.js +15 -0
  272. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  273. package/extensions/openclaw-plugin/package.json +1 -1
  274. package/openclaw.plugin.json +1 -1
  275. package/package.json +8 -4
@@ -10,8 +10,9 @@
10
10
  * sequence, writing both outputs to the log file. The parent returns in
11
11
  * <100ms so the TUI teardown can't kill the child before it finishes.
12
12
  * - SessionStart: `hippo last-sleep --path <path>` - prints the log
13
- * written by the previous session's detached worker to stderr, which
14
- * keeps it out of the model's context, and then clears it.
13
+ * written by the previous session's detached worker on stderr and
14
+ * clears it. Stdout carries only a `systemMessage` problems line,
15
+ * because Claude Code shows that to the user, not the model.
15
16
  * Earlier Claude Code forms are detected and migrated automatically:
16
17
  * - < 0.20.2: `Stop` hook firing `hippo sleep` on every assistant turn.
17
18
  * - < 0.21.0: bare `hippo sleep` in SessionEnd, no `--log-file`.
@@ -34,15 +35,18 @@
34
35
  */
35
36
  import * as fs from 'fs';
36
37
  import * as path from 'path';
37
- import { isJsonObject, HIPPO_SLEEP_MARKER, HIPPO_LAST_SLEEP_MARKER, HIPPO_CAPTURE_MARKER, HIPPO_SESSION_END_MARKER, HIPPO_PINNED_INJECT_MARKER, HIPPO_PINNED_INJECT_COMMAND, HIPPO_PRE_COMPACT_MARKER, HIPPO_COMPACT_RESUME_MARKER, HIPPO_CAPTURE_ERROR_MARKER, HIPPO_POST_COMPACT_MARKER, homeDir, codexHomeDir, defaultPreCompactLogPath } from './shared.js';
38
- import { isJsonString } from '../json.js';
38
+ import { isJsonObject, HIPPO_SLEEP_MARKER, HIPPO_LAST_SLEEP_MARKER, HIPPO_CAPTURE_MARKER, HIPPO_SESSION_END_MARKER, HIPPO_PINNED_INJECT_MARKER, HIPPO_PINNED_INJECT_COMMAND, HIPPO_PRE_COMPACT_MARKER, HIPPO_COMPACT_RESUME_MARKER, HIPPO_CAPTURE_ERROR_MARKER, HIPPO_POST_COMPACT_MARKER, homeDir, claudeConfigDir, codexHomeDir, defaultPreCompactLogPath } from './shared.js';
39
+ import { isJsonString, readJsonFile } from '../json.js';
40
+ import { escapeRegex } from '../escape.js';
41
+ import { errorMessage, log } from '../log.js';
42
+ /** The paths for `target` under the current home directory; reads and writes nothing. */
39
43
  export function resolveJsonHookPaths(target) {
40
44
  const home = homeDir();
41
45
  const logsDir = path.join(home, '.hippo', 'logs');
42
46
  switch (target) {
43
47
  case 'claude-code':
44
48
  return {
45
- settings: path.join(home, '.claude', 'settings.json'),
49
+ settings: path.join(claudeConfigDir(), 'settings.json'),
46
50
  logFile: path.join(logsDir, 'claude-code-sleep.log'),
47
51
  display: 'Claude Code',
48
52
  };
@@ -54,13 +58,58 @@ export function resolveJsonHookPaths(target) {
54
58
  };
55
59
  }
56
60
  }
57
- function hookArrayContains(hookArray, marker) {
58
- if (!Array.isArray(hookArray))
61
+ // An unterminated quote runs to the end, as in a shell, which also keeps the scan linear.
62
+ const QUOTED_SPAN = /'[^']*(?:'|$)|"(?:[^"\\]|\\[\s\S]?)*(?:"|$)/g;
63
+ /** Hippo's command only at string start or after `(`, past env assignments and an install path, with quoted text ignored: `say hippo sleep`, `echo "a; hippo sleep"` and `./backup.sh && hippo sleep` stay a user's. */
64
+ function isHippoCommand(command, marker) {
65
+ const unquoted = command.replace(QUOTED_SPAN, '');
66
+ return new RegExp(`(?:^|\\()\\s*(?:[A-Za-z_]\\w*=\\S*\\s+)*(?:[^\\s(]*[/\\\\])?${escapeRegex(marker)}(?![\\w.-])`).test(unquoted);
67
+ }
68
+ const runsHippo = (...markers) => (command) => markers.some((m) => isHippoCommand(command, m));
69
+ function handlerCommand(handler) {
70
+ return isJsonObject(handler) && isJsonString(handler.command) ? handler.command : null;
71
+ }
72
+ /** `groups` minus the handlers `isOurs` picks; a group goes only once it has no handler left. Null when no handler matched. */
73
+ function withoutHandlers(groups, isOurs) {
74
+ let removed = false;
75
+ const kept = groups.flatMap((group) => {
76
+ if (!isJsonObject(group) || !Array.isArray(group.hooks))
77
+ return [group];
78
+ const handlers = group.hooks.filter((h) => {
79
+ const command = handlerCommand(h);
80
+ return command === null || !isOurs(command);
81
+ });
82
+ if (handlers.length === group.hooks.length)
83
+ return [group];
84
+ removed = true;
85
+ return handlers.length > 0 ? [{ ...group, hooks: handlers }] : [];
86
+ });
87
+ return removed ? kept : null;
88
+ }
89
+ /** Removes the matching handlers from one event's groups; the event key goes once no group is left. */
90
+ function stripHandlers(hooks, event, isOurs) {
91
+ const groups = hooks[event];
92
+ if (!Array.isArray(groups))
93
+ return false;
94
+ const kept = withoutHandlers(groups, isOurs);
95
+ if (kept === null)
59
96
  return false;
60
- return JSON.stringify(hookArray).includes(marker);
97
+ if (kept.length > 0)
98
+ hooks[event] = kept;
99
+ else
100
+ delete hooks[event];
101
+ return true;
102
+ }
103
+ /** Every command string in `groups`; a group or handler of an unexpected shape adds none. */
104
+ function handlerCommands(groups) {
105
+ return groups.flatMap((group) => (isJsonObject(group) && Array.isArray(group.hooks) ? group.hooks.flatMap((h) => handlerCommand(h) ?? []) : []));
106
+ }
107
+ /** The loose "already installed?" test, on purpose weaker than isHippoCommand: a launcher or env prefix still counts, and a false hit only skips an append. */
108
+ function hookArrayContains(hookArray, marker) {
109
+ return Array.isArray(hookArray) && handlerCommands(hookArray).some((command) => command.includes(marker));
61
110
  }
62
111
  function addIncludeRecentToPinnedCommand(command) {
63
- if (!command.includes(HIPPO_PINNED_INJECT_MARKER) || command.includes('--include-recent'))
112
+ if (!isHippoCommand(command, HIPPO_PINNED_INJECT_MARKER) || command.includes('--include-recent'))
64
113
  return command;
65
114
  return command.includes(' --format ')
66
115
  ? command.replace(' --format ', ' --include-recent 5 --format ')
@@ -90,19 +139,6 @@ function migratePinnedInjectRecentCommands(hookArray) {
90
139
  }
91
140
  return changed;
92
141
  }
93
- /**
94
- * Returns true when `hooks.SessionEnd` still contains either of the legacy
95
- * split entries (bare `hippo sleep` / `hippo capture --last-session`)
96
- * without the current consolidated `hippo session-end` entry.
97
- */
98
- function hasLegacySplitSessionEnd(hookArray) {
99
- if (!Array.isArray(hookArray))
100
- return false;
101
- const serialized = JSON.stringify(hookArray);
102
- const hasSleep = serialized.includes(HIPPO_SLEEP_MARKER);
103
- const hasCapture = serialized.includes(HIPPO_CAPTURE_MARKER);
104
- return (hasSleep || hasCapture) && !serialized.includes(HIPPO_SESSION_END_MARKER);
105
- }
106
142
  function nothingInstalled(target, settingsPath) {
107
143
  return {
108
144
  target,
@@ -125,18 +161,23 @@ function nothingInstalled(target, settingsPath) {
125
161
  function codexCommandHook(command, timeout) {
126
162
  return { type: 'command', command, commandWindows: command.replace(/^hippo /, 'hippo.cmd '), timeout };
127
163
  }
164
+ /** Assigns `settings.hooks = {}` when it is absent, then returns it if it is an object whose `events`, where present, are arrays; null when appending would overwrite anything else. */
165
+ function ensureHooksObject(settings, events) {
166
+ if (settings.hooks === undefined)
167
+ settings.hooks = {};
168
+ const hooks = settings.hooks;
169
+ if (!isJsonObject(hooks) || events.some((e) => hooks[e] !== undefined && !Array.isArray(hooks[e])))
170
+ return null;
171
+ return hooks;
172
+ }
128
173
  /** Codex keys trust to each hook's position and hash and re-asks for a changed one, so hippo only appends and never edits an entry. */
129
174
  function installCodexHooks(settingsPath, settings) {
130
175
  const result = nothingInstalled('codex', settingsPath);
131
176
  if (!isJsonObject(settings))
132
177
  return { ...result, invalidJson: true };
133
- if (settings.hooks === undefined)
134
- settings.hooks = {};
135
- const hooks = settings.hooks;
136
- const events = ['UserPromptSubmit', 'SessionStart'];
137
- if (!isJsonObject(hooks) || events.some((e) => hooks[e] !== undefined && !Array.isArray(hooks[e]))) {
178
+ const hooks = ensureHooksObject(settings, ['UserPromptSubmit', 'SessionStart']);
179
+ if (hooks === null)
138
180
  return { ...result, invalidJson: true };
139
- }
140
181
  const append = (event, marker, group) => {
141
182
  const groups = hooks[event];
142
183
  if (hookArrayContains(groups, marker))
@@ -151,11 +192,11 @@ function installCodexHooks(settingsPath, settings) {
151
192
  matcher: 'compact',
152
193
  hooks: [codexCommandHook(HIPPO_COMPACT_RESUME_MARKER, 10)],
153
194
  });
154
- if (installedUserPromptSubmit || installedCompactResume) {
155
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
156
- }
195
+ if (installedUserPromptSubmit || installedCompactResume)
196
+ writeSettingsFile(settingsPath, settings);
157
197
  return { ...result, installedUserPromptSubmit, installedCompactResume };
158
198
  }
199
+ /** Adds hippo's hooks to `target`'s settings file and migrates older hippo entries; a file that is not JSON stays untouched. */
159
200
  export function installJsonHooks(target) {
160
201
  const { settings: settingsPath, logFile } = resolveJsonHookPaths(target);
161
202
  const dir = path.dirname(settingsPath);
@@ -164,7 +205,7 @@ export function installJsonHooks(target) {
164
205
  let settings = {};
165
206
  if (fs.existsSync(settingsPath)) {
166
207
  try {
167
- settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
208
+ settings = readJsonFile(settingsPath);
168
209
  }
169
210
  catch {
170
211
  return { ...nothingInstalled(target, settingsPath), invalidJson: true };
@@ -172,35 +213,129 @@ export function installJsonHooks(target) {
172
213
  }
173
214
  if (target === 'codex')
174
215
  return installCodexHooks(settingsPath, settings);
216
+ if (!isJsonObject(settings))
217
+ return { ...nothingInstalled(target, settingsPath), invalidJson: true };
175
218
  return installClaudeCodeHooks(settingsPath, settings, logFile);
176
219
  }
177
- function migrateLegacyClaudeHooks(hooks) {
178
- let migratedFromStop = false;
179
- if (Array.isArray(hooks.Stop) && hookArrayContains(hooks.Stop, HIPPO_SLEEP_MARKER)) {
180
- hooks.Stop = hooks.Stop.filter((entry) => !JSON.stringify(entry).includes(HIPPO_SLEEP_MARKER));
181
- if (hooks.Stop.length === 0)
182
- delete hooks.Stop;
183
- migratedFromStop = true;
220
+ const RENAME_RETRY_MS = 1000;
221
+ const WINDOWS_RENAME_REFUSALS = ['EPERM', 'EACCES', 'EBUSY'];
222
+ // A rename can be refused where an in-place write still works (a bind-mounted file, a read-only folder), so these fall back to one.
223
+ const REPLACE_REFUSALS = ['EBUSY', 'EXDEV', 'EACCES', 'EPERM'];
224
+ // Matches the usual SYMLOOP_MAX, so a link cycle ends in an error instead of a hang.
225
+ const MAX_LINK_HOPS = 40;
226
+ function errnoCode(err) {
227
+ return 'code' in err ? String(err.code) : '';
228
+ }
229
+ /** Windows refuses a rename onto a file another program has open, usually for a moment, so a refusal is retried before it is reported. */
230
+ function renameOnto(tmp, target) {
231
+ const deadline = Date.now() + RENAME_RETRY_MS;
232
+ const idle = new Int32Array(new SharedArrayBuffer(4));
233
+ for (let pause = 10;; pause = Math.min(pause * 2, 200)) {
234
+ try {
235
+ fs.renameSync(tmp, target);
236
+ return;
237
+ }
238
+ catch (err) {
239
+ const transient = process.platform === 'win32' && err instanceof Error && WINDOWS_RENAME_REFUSALS.includes(errnoCode(err));
240
+ const left = deadline - Date.now();
241
+ if (!transient || left <= 0)
242
+ throw err;
243
+ Atomics.wait(idle, 0, 0, Math.min(pause, left));
244
+ }
245
+ }
246
+ }
247
+ /** Where a write to `file` lands: through any symlink, a dangling one included, so a dotfile manager's link survives. */
248
+ function writeTarget(file) {
249
+ let target = file;
250
+ for (let hop = 0; hop < MAX_LINK_HOPS && fs.lstatSync(target, { throwIfNoEntry: false })?.isSymbolicLink(); hop++) {
251
+ target = path.resolve(path.dirname(target), fs.readlinkSync(target));
252
+ }
253
+ return fs.existsSync(target) ? fs.realpathSync(target) : target;
254
+ }
255
+ /** Gives the replacement the old file's exact mode (umask trims the create mode) and, for root, its owner. */
256
+ function keepAccess(tmp, old) {
257
+ fs.chmodSync(tmp, old.mode & 0o777);
258
+ if (process.getuid?.() === 0)
259
+ fs.chownSync(tmp, old.uid, old.gid);
260
+ }
261
+ /** Cleanup must not hide the failure that made it necessary, so a cleanup error is logged and the first error stays the one thrown. */
262
+ function removeTemp(tmp) {
263
+ try {
264
+ fs.rmSync(tmp, { force: true });
265
+ }
266
+ catch (err) {
267
+ log.warn(`could not remove the temporary file ${tmp}: ${errorMessage(err)}`);
268
+ }
269
+ }
270
+ function replaceViaTemp(target, text, old) {
271
+ const tmp = `${target}.${process.pid}.tmp`;
272
+ try {
273
+ fs.writeFileSync(tmp, text, { encoding: 'utf8', mode: (old?.mode ?? 0o666) & 0o777 });
274
+ if (old)
275
+ keepAccess(tmp, old);
276
+ renameOnto(tmp, target);
277
+ }
278
+ catch (err) {
279
+ removeTemp(tmp);
280
+ throw err;
281
+ }
282
+ }
283
+ /** The pre-rename write: it truncates first, so it is only the fallback, but it needs just a writable file and keeps hard links together. */
284
+ function writeInPlace(target, text) {
285
+ try {
286
+ fs.writeFileSync(target, text, 'utf8');
287
+ }
288
+ catch (err) {
289
+ if (!(err instanceof Error))
290
+ throw err;
291
+ const reason = errnoCode(err) === 'EBUSY'
292
+ ? 'is in use by another program, so hippo could not replace it; close that program and run the command again'
293
+ : `could not be replaced (${errnoCode(err) || err.message})`;
294
+ throw new Error(`${target} ${reason}`, { cause: err });
184
295
  }
185
- // Migrate legacy SessionEnd forms:
186
- // - pre-0.21 bare `hippo sleep`
187
- // - 0.21.x+ `hippo sleep --log-file` split across two entries
188
- // - 0.22.x `hippo capture --last-session --log-file` second entry
189
- // All of these get collapsed into the single `hippo session-end` entry.
296
+ }
297
+ /** Swaps the file in with one rename, so a crash mid-write cannot leave a truncated settings.json that Claude Code cannot parse. */
298
+ export function writeSettingsFile(file, settings) {
299
+ const target = writeTarget(file);
300
+ const text = JSON.stringify(settings, null, 2) + '\n';
301
+ const old = fs.statSync(target, { throwIfNoEntry: false });
302
+ if (old === undefined) {
303
+ fs.mkdirSync(path.dirname(target), { recursive: true });
304
+ }
305
+ else {
306
+ // A rename replaces a read-only file that the in-place write refused, and Windows would retry that refusal for a second.
307
+ fs.accessSync(target, fs.constants.W_OK);
308
+ }
309
+ if (old !== undefined && old.nlink > 1) {
310
+ // A rename gives the file a new inode, which would leave the other hard links on the old content.
311
+ writeInPlace(target, text);
312
+ return;
313
+ }
314
+ try {
315
+ replaceViaTemp(target, text, old);
316
+ }
317
+ catch (err) {
318
+ if (!(err instanceof Error) || !REPLACE_REFUSALS.includes(errnoCode(err)))
319
+ throw err;
320
+ writeInPlace(target, text);
321
+ }
322
+ }
323
+ function migrateLegacyClaudeHooks(hooks) {
324
+ const migratedFromStop = stripHandlers(hooks, 'Stop', runsHippo(HIPPO_SLEEP_MARKER));
325
+ // The legacy SessionEnd forms in the file header collapse into `hippo session-end`, but only while that entry is absent.
190
326
  let migratedLegacySessionEnd = false;
191
327
  let migratedSplitSessionEnd = false;
192
- if (Array.isArray(hooks.SessionEnd) && hasLegacySplitSessionEnd(hooks.SessionEnd)) {
193
- const before = hooks.SessionEnd.length;
194
- hooks.SessionEnd = hooks.SessionEnd.filter((entry) => {
195
- const s = JSON.stringify(entry);
196
- return !s.includes(HIPPO_SLEEP_MARKER) && !s.includes(HIPPO_CAPTURE_MARKER);
197
- });
198
- if (hooks.SessionEnd.length === 0)
199
- delete hooks.SessionEnd;
200
- // If the removed entries used the log-file pattern (0.21.x-0.22.x) we
201
- // call it a "split" migration; otherwise it was the older bare form.
202
- migratedSplitSessionEnd = true;
203
- migratedLegacySessionEnd = before > 1;
328
+ if (Array.isArray(hooks.SessionEnd) && !hookArrayContains(hooks.SessionEnd, HIPPO_SESSION_END_MARKER)) {
329
+ const kept = withoutHandlers(hooks.SessionEnd, runsHippo(HIPPO_SLEEP_MARKER, HIPPO_CAPTURE_MARKER));
330
+ if (kept !== null) {
331
+ // Whatever form was removed reports as the split migration; the legacy flag also marks a SessionEnd that held several groups.
332
+ migratedSplitSessionEnd = true;
333
+ migratedLegacySessionEnd = hooks.SessionEnd.length > 1;
334
+ if (kept.length === 0)
335
+ delete hooks.SessionEnd;
336
+ else
337
+ hooks.SessionEnd = kept;
338
+ }
204
339
  }
205
340
  return { migratedFromStop, migratedLegacySessionEnd, migratedSplitSessionEnd };
206
341
  }
@@ -216,13 +351,24 @@ function appendHookIfMissing(hooks, event, marker, group) {
216
351
  hooks[event].push(group);
217
352
  return true;
218
353
  }
354
+ /** Every command hippo has written to Claude Code's settings per event, the legacy forms included; uninstall removes only handlers that run one. */
355
+ const CLAUDE_HOOK_MARKERS = [
356
+ ['SessionEnd', [HIPPO_SESSION_END_MARKER, HIPPO_SLEEP_MARKER, HIPPO_CAPTURE_MARKER]],
357
+ ['SessionStart', [HIPPO_LAST_SLEEP_MARKER, HIPPO_COMPACT_RESUME_MARKER]],
358
+ ['UserPromptSubmit', [HIPPO_PINNED_INJECT_MARKER]],
359
+ ['PreCompact', [HIPPO_PRE_COMPACT_MARKER]],
360
+ ['PostCompact', [HIPPO_POST_COMPACT_MARKER]],
361
+ ['PostToolUseFailure', [HIPPO_CAPTURE_ERROR_MARKER]],
362
+ ['Stop', [HIPPO_SLEEP_MARKER]],
363
+ ];
364
+ // Stop is only ever migrated away, so install never appends to it and a Stop that is not a list does not block the install.
365
+ const CLAUDE_APPENDED_EVENTS = CLAUDE_HOOK_MARKERS.map(([event]) => event).filter((event) => event !== 'Stop');
219
366
  function installClaudeCodeHooks(settingsPath, settings, logFile) {
220
- if (!settings.hooks)
221
- settings.hooks = {};
222
- // SAFETY: settings.hooks is either freshly initialised to {} on the line above, or an
223
- // existing value from settings.json — Claude Code's own schema always writes an object
224
- // there; each event key below is still re-validated with Array.isArray before use.
225
- const hooks = settings.hooks;
367
+ const merge = ensureHooksObject(settings, CLAUDE_APPENDED_EVENTS);
368
+ if (merge === null)
369
+ return { ...nothingInstalled('claude-code', settingsPath), invalidJson: true };
370
+ // SAFETY: ensureHooksObject left every event hippo appends to absent or an array.
371
+ const hooks = merge;
226
372
  const migration = migrateLegacyClaudeHooks(hooks);
227
373
  const installedSessionEnd = appendHookIfMissing(hooks, 'SessionEnd', HIPPO_SESSION_END_MARKER, claudeCommandGroup(`hippo session-end --log-file "${logFile}"`, 5));
228
374
  const installedSessionStart = appendHookIfMissing(hooks, 'SessionStart', HIPPO_LAST_SLEEP_MARKER, claudeCommandGroup(`hippo last-sleep --path "${logFile}"`, 5));
@@ -266,9 +412,8 @@ function installClaudeCodeHooks(settingsPath, settings, logFile) {
266
412
  ...migration,
267
413
  invalidJson: false,
268
414
  };
269
- if (Object.values(result).some((v) => v === true)) {
270
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
271
- }
415
+ if (Object.values(result).some((v) => v === true))
416
+ writeSettingsFile(settingsPath, settings);
272
417
  return result;
273
418
  }
274
419
  /** The exact command hippo writes for each Codex event; uninstall removes only these handlers. */
@@ -279,37 +424,28 @@ const CODEX_HOOK_COMMANDS = [
279
424
  /** A group loses only hippo's handlers and goes only once empty, so a user's hook beside or like hippo's stays. */
280
425
  function uninstallCodexHooks(hooks) {
281
426
  let changed = false;
282
- for (const [event, command] of CODEX_HOOK_COMMANDS) {
283
- const groups = hooks[event];
284
- if (!Array.isArray(groups))
285
- continue;
286
- let removed = false;
287
- const kept = groups.flatMap((group) => {
288
- if (!isJsonObject(group) || !Array.isArray(group.hooks))
289
- return [group];
290
- const handlers = group.hooks.filter((h) => !(isJsonObject(h) && h.command === command));
291
- if (handlers.length === group.hooks.length)
292
- return [group];
293
- removed = true;
294
- return handlers.length > 0 ? [{ ...group, hooks: handlers }] : [];
295
- });
296
- if (!removed)
297
- continue;
298
- changed = true;
299
- if (kept.length > 0)
300
- hooks[event] = kept;
301
- else
302
- delete hooks[event];
303
- }
427
+ for (const [event, command] of CODEX_HOOK_COMMANDS)
428
+ changed = stripHandlers(hooks, event, (c) => c === command) || changed;
304
429
  return changed;
305
430
  }
431
+ /** One stderr line naming the handlers uninstall kept that still mention a hippo command (`nice hippo sleep`, `pnpm exec hippo ...`), so the user can remove them by hand. */
432
+ function warnKeptHippoHandlers(settingsPath, hooks) {
433
+ const kept = CLAUDE_HOOK_MARKERS.flatMap(([event, markers]) => {
434
+ const groups = hooks[event];
435
+ return Array.isArray(groups) ? handlerCommands(groups).filter((command) => markers.some((m) => command.includes(m))) : [];
436
+ });
437
+ if (kept.length === 0)
438
+ return;
439
+ process.stderr.write(`hippo kept these hook handlers in ${settingsPath} because they do not start with a hippo command; remove any that are hippo's by hand: ${kept.map((c) => JSON.stringify(c)).join(', ')}\n`);
440
+ }
441
+ /** Removes hippo's own hook handlers from `target`'s settings file and keeps every other handler; true when the file changed. */
306
442
  export function uninstallJsonHooks(target) {
307
443
  const { settings: settingsPath } = resolveJsonHookPaths(target);
308
444
  if (!fs.existsSync(settingsPath))
309
445
  return false;
310
446
  let settings;
311
447
  try {
312
- settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
448
+ settings = readJsonFile(settingsPath);
313
449
  }
314
450
  catch {
315
451
  // Never rewrite a settings file we cannot parse; report nothing uninstalled.
@@ -317,41 +453,34 @@ export function uninstallJsonHooks(target) {
317
453
  }
318
454
  if (!isJsonObject(settings) || !isJsonObject(settings.hooks))
319
455
  return false;
320
- const changed = target === 'codex' ? uninstallCodexHooks(settings.hooks) : uninstallClaudeCodeHooks(settings.hooks);
456
+ const hooks = settings.hooks;
457
+ const changed = target === 'codex' ? uninstallCodexHooks(hooks) : uninstallClaudeCodeHooks(hooks);
458
+ if (target === 'claude-code')
459
+ warnKeptHippoHandlers(settingsPath, hooks);
321
460
  if (!changed)
322
461
  return false;
323
- if (Object.keys(settings.hooks).length === 0)
462
+ if (Object.keys(hooks).length === 0)
324
463
  delete settings.hooks;
325
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
464
+ writeSettingsFile(settingsPath, settings);
326
465
  return true;
327
466
  }
328
- function uninstallClaudeCodeHooks(settingsHooks) {
329
- // SAFETY: each event key below is re-validated with Array.isArray before use.
330
- const hooks = settingsHooks;
331
- let changed = false;
332
- const markersByKey = {
333
- SessionEnd: [HIPPO_SESSION_END_MARKER, HIPPO_SLEEP_MARKER, HIPPO_CAPTURE_MARKER],
334
- // Both the un-matched last-sleep entry and the matcher:'compact'
335
- // compact-resume entry live under the SessionStart key; the matcher
336
- // field doesn't affect this substring match, so removal covers both.
337
- SessionStart: [HIPPO_LAST_SLEEP_MARKER, HIPPO_COMPACT_RESUME_MARKER],
338
- UserPromptSubmit: [HIPPO_PINNED_INJECT_MARKER],
339
- PreCompact: [HIPPO_PRE_COMPACT_MARKER],
340
- PostCompact: [HIPPO_POST_COMPACT_MARKER],
341
- PostToolUseFailure: [HIPPO_CAPTURE_ERROR_MARKER],
342
- Stop: [HIPPO_SLEEP_MARKER],
343
- };
344
- for (const [key, markers] of Object.entries(markersByKey)) {
345
- if (!Array.isArray(hooks[key]))
346
- continue;
347
- const before = hooks[key].length;
348
- hooks[key] = hooks[key].filter((entry) => !markers.some((m) => JSON.stringify(entry).includes(m)));
349
- if (hooks[key].length !== before) {
350
- changed = true;
351
- if (hooks[key].length === 0)
352
- delete hooks[key];
353
- }
467
+ /** Whether uninstall can edit settings.json; `invalidJson` marks a file that exists but is not JSON hippo can edit, which `uninstallJsonHooks` reports only as false. */
468
+ export function checkUninstallable(target) {
469
+ const { settings: settingsPath } = resolveJsonHookPaths(target);
470
+ if (!fs.existsSync(settingsPath))
471
+ return { settingsPath, invalidJson: false };
472
+ try {
473
+ const settings = readJsonFile(settingsPath);
474
+ return { settingsPath, invalidJson: !isJsonObject(settings) || (settings.hooks !== undefined && !isJsonObject(settings.hooks)) };
475
+ }
476
+ catch {
477
+ return { settingsPath, invalidJson: true };
354
478
  }
479
+ }
480
+ function uninstallClaudeCodeHooks(hooks) {
481
+ let changed = false;
482
+ for (const [event, markers] of CLAUDE_HOOK_MARKERS)
483
+ changed = stripHandlers(hooks, event, runsHippo(...markers)) || changed;
355
484
  return changed;
356
485
  }
357
486
  //# sourceMappingURL=json-hooks.js.map
@@ -89,7 +89,7 @@ function resolveOpencodeConfigPath() {
89
89
  * must NOT lose the user-authored commands. The migration filters the inner
90
90
  * array per-hook, then drops the entry only when its inner array is empty.
91
91
  */
92
- const HIPPO_OWNED_COMMAND_RE = /^\s*hippo\s+(session-end|last-sleep|sleep|capture|context)\b/;
92
+ const HIPPO_OWNED_COMMAND_RE = /^\s*hippo\s+(session-end|last-sleep|sleep|capture|context)(?=\s|$)/;
93
93
  function hookIsHippoOwned(hook) {
94
94
  if (!isJsonObject(hook))
95
95
  return false;
@@ -24,6 +24,8 @@ export declare const HIPPO_POST_COMPACT_MARKER = "hippo post-compact";
24
24
  export declare function homeDir(): string;
25
25
  /** Codex's config folder: $CODEX_HOME, else ~/.codex, as the Codex hooks docs describe. */
26
26
  export declare function codexHomeDir(home?: string, env?: Readonly<Record<string, string | undefined>>): string;
27
+ /** Claude Code's config folder, where it reads settings.json: $CLAUDE_CONFIG_DIR when set and non-empty, else ~/.claude under os.homedir(), as Claude Code does (a HOME that differs from the profile must not move it). */
28
+ export declare function claudeConfigDir(home?: string, env?: Readonly<Record<string, string | undefined>>): string;
27
29
  /** Codex counts as installed only when its config folder exists: Codex itself refuses a CODEX_HOME that is not a folder. */
28
30
  export declare function isCodexPresent(home?: string): boolean;
29
31
  /** Codex hashes each hook and skips new or changed ones until the user reviews them in `/hooks`, so the reminder says what they would trust. */
@@ -24,6 +24,10 @@ export function homeDir() {
24
24
  export function codexHomeDir(home = homeDir(), env = processEnv()) {
25
25
  return env.CODEX_HOME || path.join(home, '.codex');
26
26
  }
27
+ /** Claude Code's config folder, where it reads settings.json: $CLAUDE_CONFIG_DIR when set and non-empty, else ~/.claude under os.homedir(), as Claude Code does (a HOME that differs from the profile must not move it). */
28
+ export function claudeConfigDir(home = os.homedir(), env = processEnv()) {
29
+ return env.CLAUDE_CONFIG_DIR || path.join(home, '.claude');
30
+ }
27
31
  /** Codex counts as installed only when its config folder exists: Codex itself refuses a CODEX_HOME that is not a folder. */
28
32
  export function isCodexPresent(home = homeDir()) {
29
33
  return fs.statSync(codexHomeDir(home), { throwIfNoEntry: false })?.isDirectory() === true;
@@ -58,8 +62,9 @@ export function ensureDir(dir) {
58
62
  export function detectInstalledTools() {
59
63
  const home = homeDir();
60
64
  const exists = (...parts) => fs.existsSync(path.join(home, ...parts));
65
+ const claudeDir = claudeConfigDir();
61
66
  return [
62
- { name: 'claude-code', configDir: '~/.claude', detected: exists('.claude'), kind: 'json-hook' },
67
+ { name: 'claude-code', configDir: claudeDir, detected: fs.existsSync(claudeDir), kind: 'json-hook' },
63
68
  { name: 'opencode', configDir: '~/.config/opencode', detected: exists('.config', 'opencode'), kind: 'plugin', notes: 'installs a TS plugin at ~/.config/opencode/plugins/hippo.ts' },
64
69
  { name: 'openclaw', configDir: '~/.openclaw', detected: exists('.openclaw'), kind: 'plugin', notes: 'install via `openclaw plugins install hippo-memory`' },
65
70
  { name: 'codex', configDir: '~/.codex', detected: isCodexPresent(home), kind: 'wrapper', notes: 'memory hooks in hooks.json, and wraps the detected codex launcher for session-end consolidation' },
@@ -8,10 +8,15 @@ export declare const JSON_HEADERS: {
8
8
  };
9
9
  export declare class HttpError extends Error {
10
10
  status: number;
11
- constructor(status: number, message: string);
11
+ /** Sent as the Retry-After header, so a refused caller knows when to come back. */
12
+ readonly retryAfterSec: number | undefined;
13
+ constructor(status: number, message: string, retryAfterSec?: number);
12
14
  }
13
15
  export declare class BodyTooLargeError extends Error {
14
16
  }
17
+ /** The body did not arrive in time. Its own class so mapApiError answers 408 and the server drops the socket. */
18
+ export declare class BodyTimeoutError extends Error {
19
+ }
15
20
  /** The status and client-facing message for one failed request. */
16
21
  export interface ApiErrorReply {
17
22
  status: number;
@@ -20,13 +25,17 @@ export interface ApiErrorReply {
20
25
  export declare const INTERNAL_ERROR_MESSAGE = "internal server error";
21
26
  /** Maps by class so rewording a message never moves a status; an untyped error is a 500 whose text stays in the server log. */
22
27
  export declare function mapApiError<E>(err: E): ApiErrorReply;
28
+ /** Serialises before the head goes out, so a body that is not JSON still reaches the caller's error reply. */
23
29
  export declare function sendJson<T>(res: ServerResponse, status: number, body: T): void;
24
- /**
25
- * Read the entire request body into a Buffer. Caps at MAX_BODY_BYTES to keep
26
- * a malicious or buggy client from exhausting memory. The cap is enforced
27
- * mid-stream so we don't wait for an attacker to finish before erroring out.
28
- */
29
- export declare function readBody(req: IncomingMessage): Promise<string>;
30
+ export interface ReadBodyOpts {
31
+ /** Defaults to 1 MB. */
32
+ maxBytes?: number;
33
+ /** Fails with BodyTimeoutError when the whole body has not arrived by then; unset waits for as long as the socket stays open. */
34
+ deadlineMs?: number;
35
+ }
36
+ /** The body as text, refused mid-stream past maxBytes and past deadlineMs, so an oversized or slow sender cannot tie up the server. */
37
+ export declare function readBody(req: IncomingMessage, { maxBytes, deadlineMs }?: ReadBodyOpts): Promise<string>;
38
+ export declare function closeAfterReply(req: IncomingMessage): void;
30
39
  export declare const LOOPBACK_HOST_HEADER: RegExp;
31
40
  /** A browser request sent by another site. Non-browser clients send neither header and pass. */
32
41
  export declare function isCrossSite(req: IncomingMessage): boolean;