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
@@ -1,8 +1,10 @@
1
1
  // MCP over HTTP: POST /mcp and the GET /mcp/stream SSE keepalive.
2
2
  import { envMcpSseHeartbeatMs, envMcpSseMaxAgeSec, envMcpSseMaxStreams } from '../env.js';
3
3
  import { createHash } from 'node:crypto';
4
+ import { isSharedStore } from '../config.js';
4
5
  import { handleMcpRequest, mcpErrorResponse } from '../mcp/server.js';
5
6
  import { HttpError, isJsonObjectRecord, readBody, sendJson } from '../http-util.js';
7
+ import { assertCallerProject } from '../project-identity.js';
6
8
  import { buildContextWithAuth, heartbeatVerdict, readAuthHeader, requireAuth } from './auth.js';
7
9
  import { clientIpForRateLimit } from './client-ip.js';
8
10
  import { requestIds } from './request.js';
@@ -44,6 +46,57 @@ function buildMcpClientKey(req) {
44
46
  //
45
47
  // Auth: same as /v1/* — Bearer token validated via `requireAuth`, with the
46
48
  // loopback no-auth fallback. SSE check runs once at stream-open.
49
+ // Percent-encoded by the client, so any lowercase Unicode name fits a Latin-1 header.
50
+ const HEADER_PIECE = /^[A-Za-z0-9\-_.!~*'()%]+$/;
51
+ function decodeHeaderPiece(raw, header) {
52
+ if (!HEADER_PIECE.test(raw))
53
+ throw new HttpError(400, `${header} must be percent-encoded, with no empty names`);
54
+ try {
55
+ return decodeURIComponent(raw);
56
+ }
57
+ catch {
58
+ throw new HttpError(400, `${header} holds a bad percent escape`);
59
+ }
60
+ }
61
+ /** On a shared store, the caller's project from X-Hippo-Project and the comma-separated X-Hippo-Project-Aliases; any other store ignores both. */
62
+ export function callerProjectFromHeaders(req, hippoRoot) {
63
+ if (!isSharedStore(hippoRoot))
64
+ return undefined;
65
+ const names = req.headersDistinct['x-hippo-project'];
66
+ const aliasHeaders = req.headersDistinct['x-hippo-project-aliases'];
67
+ if (names === undefined) {
68
+ if (aliasHeaders !== undefined)
69
+ throw new HttpError(400, 'X-Hippo-Project-Aliases needs X-Hippo-Project');
70
+ return undefined;
71
+ }
72
+ if (names.length > 1 || (aliasHeaders?.length ?? 0) > 1)
73
+ throw new HttpError(400, 'send X-Hippo-Project and X-Hippo-Project-Aliases once each');
74
+ const name = decodeHeaderPiece(names[0], 'X-Hippo-Project');
75
+ const rawAliases = aliasHeaders?.[0] ?? '';
76
+ const aliases = rawAliases === '' ? [] : rawAliases.split(',').map((a) => decodeHeaderPiece(a, 'X-Hippo-Project-Aliases'));
77
+ assertCallerProject({ name, aliases });
78
+ return { name, legacyName: name, aliases };
79
+ }
80
+ export function mcpContextFor(ctx, clientKey, autoSleep, project) {
81
+ const mcpCtx = {
82
+ hippoRoot: ctx.hippoRoot,
83
+ tenantId: ctx.tenantId,
84
+ // McpContext.actor stays string; extract subject at the boundary.
85
+ actor: ctx.actor.subject,
86
+ // The caller's real role: MCP tools must not run a member key as admin.
87
+ role: ctx.actor.role,
88
+ scopes: ctx.actor.scopes,
89
+ viaAuthResolver: ctx.actor.viaAuthResolver,
90
+ hostAdmin: ctx.actor.hostAdmin,
91
+ owner: ctx.actor.owner,
92
+ clientKey,
93
+ store: ctx.store,
94
+ autoSleep,
95
+ };
96
+ if (project !== undefined)
97
+ mcpCtx.project = project;
98
+ return mcpCtx;
99
+ }
47
100
  export async function handleMcpPost(req, res, opts) {
48
101
  // Build the same Context the /v1/* routes use so MCP tool calls inherit
49
102
  // the server's bound hippoRoot and the auth-resolved tenantId / actor.
@@ -51,6 +104,7 @@ export async function handleMcpPost(req, res, opts) {
51
104
  // pull tenant from HIPPO_TENANT, dropping a valid Bearer for tenant B
52
105
  // back to whatever the env says.
53
106
  const ctx = await buildContextWithAuth(req, opts);
107
+ const project = callerProjectFromHeaders(req, ctx.hippoRoot);
54
108
  const raw = await readBody(req);
55
109
  let mcpReq;
56
110
  try {
@@ -68,18 +122,7 @@ export async function handleMcpPost(req, res, opts) {
68
122
  const rpcReq = mcpReq;
69
123
  let mcpRes;
70
124
  try {
71
- mcpRes = await handleMcpRequest(rpcReq, {
72
- hippoRoot: ctx.hippoRoot,
73
- tenantId: ctx.tenantId,
74
- // McpContext.actor stays string; extract subject at the boundary.
75
- actor: ctx.actor.subject,
76
- // The caller's real role: MCP tools must not run a member key as admin.
77
- role: ctx.actor.role,
78
- scopes: ctx.actor.scopes,
79
- viaAuthResolver: ctx.actor.viaAuthResolver,
80
- hostAdmin: ctx.actor.hostAdmin,
81
- clientKey: buildMcpClientKey(req),
82
- });
125
+ mcpRes = await handleMcpRequest(rpcReq, mcpContextFor(ctx, buildMcpClientKey(req), opts.autoSleep, project));
83
126
  }
84
127
  catch (err) {
85
128
  mcpRes = mcpErrorResponse(rpcReq.id, err, requestIds.get(req));
@@ -107,7 +150,7 @@ function acquireStreamSlot(req, res, slots) {
107
150
  const key = streamSlotKey(req);
108
151
  const open = slots.get(key) ?? 0;
109
152
  if (open >= max)
110
- throw new HttpError(429, `too many open streams for this client (limit ${max}); close one first`);
153
+ throw new HttpError(429, `too many open streams for this client (limit ${max}); close one first`, 60);
111
154
  slots.set(key, open + 1);
112
155
  res.once('close', () => {
113
156
  const left = (slots.get(key) ?? 1) - 1;
@@ -1,7 +1,7 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  /** The caller's `X-Request-Id` when it is a short plain token, else a fresh UUID. */
3
3
  export declare function resolveRequestId(header: string | string[] | undefined): string;
4
- /** One line per failed request; 4xx is the caller's mistake and a busy 503 is back-pressure the client retries, so neither logs as an error. */
4
+ /** One line per failed request; 4xx is the caller's mistake, a busy 503 is back-pressure and a 501 is a route not on this store, so none logs as an error. */
5
5
  export declare function logRequestFailure<E>(req: IncomingMessage, err: E, requestId: string, status: number): void;
6
6
  /** The status and client message for a failed request; a held write lock is a retryable 503, never a 500. */
7
7
  export declare function replyFor<E>(err: E): {
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { isSqliteBusy, STORE_BUSY_MESSAGE } from '../db.js';
2
+ import { isStoreBusy, STORE_BUSY_MESSAGE } from '../db.js';
3
3
  import { errorFields, log } from '../log.js';
4
4
  import { HttpError, mapApiError, sendJson } from '../http-util.js';
5
5
  // The caller's id lands in a response header and in logs, so only a short plain token is echoed back.
@@ -9,20 +9,20 @@ export function resolveRequestId(header) {
9
9
  const value = Array.isArray(header) ? undefined : header;
10
10
  return value && REQUEST_ID_RE.test(value) ? value : randomUUID();
11
11
  }
12
- /** One line per failed request; 4xx is the caller's mistake and a busy 503 is back-pressure the client retries, so neither logs as an error. */
12
+ /** One line per failed request; 4xx is the caller's mistake, a busy 503 is back-pressure and a 501 is a route not on this store, so none logs as an error. */
13
13
  export function logRequestFailure(req, err, requestId, status) {
14
14
  const message = err instanceof Error ? err.message : String(err);
15
15
  const line = `${req.method ?? 'GET'} ${(req.url ?? '/').split('?')[0]} failed: ${message}`;
16
- if (isSqliteBusy(err))
16
+ if (isStoreBusy(err))
17
17
  log.warn(line, { requestId, status });
18
- else if (status >= 500)
18
+ else if (status >= 500 && status !== 501)
19
19
  log.error(line, { requestId, status, ...errorFields(err) });
20
20
  else
21
21
  log.info(line, { requestId, status });
22
22
  }
23
23
  /** The status and client message for a failed request; a held write lock is a retryable 503, never a 500. */
24
24
  export function replyFor(err) {
25
- return isSqliteBusy(err) ? { status: 503, message: STORE_BUSY_MESSAGE } : mapApiError(err);
25
+ return isStoreBusy(err) ? { status: 503, message: STORE_BUSY_MESSAGE } : mapApiError(err);
26
26
  }
27
27
  export function sendError(res, status, message) {
28
28
  sendJson(res, status, { error: message });
@@ -1,12 +1,16 @@
1
1
  // Admin routes: API keys, quarantine and the audit log.
2
2
  import { AUDIT_OPS } from '../../audit.js';
3
3
  import { auditList, authCreate, authListRows, authRevoke, quarantineApprove, quarantineList, quarantineReject } from '../../api.js';
4
- import { HttpError, sendJson } from '../../http-util.js';
4
+ import { HttpError, readBody, sendJson } from '../../http-util.js';
5
5
  import { assertCrossTenantAdmin, buildContextWithAuth } from '../auth.js';
6
6
  import { pageOf, parseCursor, setNextCursorHeader } from '../cursor.js';
7
- import { isSetMember, parseJsonBody, parseListLimit, validateIdSegment } from '../validation.js';
7
+ import { isSetMember, parseJsonObjectText, parseListLimit, validateIdSegment } from '../validation.js';
8
8
  import { isJsonString } from '../../json.js';
9
9
  const VALID_AUDIT_OPS = new Set(AUDIT_OPS);
10
+ // POST /v1/auth/keys reads its body before auth, so an unauthenticated caller can make the server buffer at most this many bytes and wait at most this many ms.
11
+ // ServeOpts.mintBodyDeadlineMs overrides the wait for tests only, so a 408 test need not sit through it.
12
+ const MINT_BODY_MAX_BYTES = 4 * 1024;
13
+ const MINT_BODY_DEADLINE_MS = 10_000;
10
14
  // Cap on GET /v1/audit?limit=. Matches docs/api.md (when written) and is large
11
15
  // enough to dump a small deployment's full audit log without paginating, but
12
16
  // small enough that a malicious client can't ask for the world.
@@ -17,8 +21,10 @@ const MAX_AUTH_KEYS_PAGE = 1000;
17
21
  // body: the HTTP layer hands it to the client; the user-facing
18
22
  // "store this somewhere safe" warning belongs in the CLI client, not here.
19
23
  export async function handleCreateAuthKey({ req, res, opts }) {
24
+ // Body first, so the resolver's check (and any gate in it) runs right before the mint with no wait between.
25
+ const raw = await readBody(req, { maxBytes: MINT_BODY_MAX_BYTES, deadlineMs: opts.mintBodyDeadlineMs ?? MINT_BODY_DEADLINE_MS });
20
26
  const ctx = await buildContextWithAuth(req, opts);
21
- const body = await parseJsonBody(req, ctx);
27
+ const body = parseJsonObjectText(raw);
22
28
  const labelRaw = body['label'];
23
29
  if (labelRaw !== undefined && !isJsonString(labelRaw)) {
24
30
  throw new HttpError(400, 'label must be a string');
@@ -47,7 +53,7 @@ export async function handleCreateAuthKey({ req, res, opts }) {
47
53
  }
48
54
  // GET /v1/auth/keys?active=true&limit=&cursor=: list keys visible to ctx.tenantId.
49
55
  // `active` defaults to true so the common case (show me usable keys) is
50
- // a single GET; ?active=false includes revoked rows.
56
+ // a single GET; ?active=false includes revoked and expired rows.
51
57
  export async function handleListAuthKeys({ req, res, opts, query }) {
52
58
  const activeRaw = query.get('active');
53
59
  let active = true;
@@ -67,8 +73,8 @@ export async function handleListAuthKeys({ req, res, opts, query }) {
67
73
  sendJson(res, 200, page.items.map((r) => r.key));
68
74
  return;
69
75
  }
70
- // DELETE /v1/auth/keys/:keyId — revoke. Missing or cross-tenant keys are 404
71
- // (no info leak); a member key targeting any key but its own is 403.
76
+ // DELETE /v1/auth/keys/:keyId — revoke. Missing or cross-tenant keys are 404 (no info leak); a member
77
+ // is 403 on any key but its own key or, signed in through the resolver, the keys it minted.
72
78
  // 200 with the body rather than 204 so the caller sees revokedAt.
73
79
  export async function handleRevokeAuthKey({ req, res, opts }, keyMatch) {
74
80
  validateIdSegment(keyMatch.keyId, 'key id');
@@ -3,8 +3,9 @@ import { archiveRaw, forget, outcome, outcomeForLastRecall, promote, remember, s
3
3
  import { buildGraphModel } from '../../graph-view.js';
4
4
  import { MAX_ENTITY_NAME_LEN } from '../../graph/types.js';
5
5
  import { HttpError, sendJson } from '../../http-util.js';
6
+ import { canReadScope } from '../../recall-scope.js';
6
7
  import { assertCrossTenantAdmin, buildContextWithAuth, isLoopback } from '../auth.js';
7
- import { getString, getStringArray, isJsonBoolean, isSetMember, parseJsonBody, parseListLimit, validateIdSegment } from '../validation.js';
8
+ import { getCallerProject, getString, getStringArray, isJsonBoolean, isSetMember, parseJsonBody, parseListLimit, validateIdSegment } from '../validation.js';
8
9
  import { isJsonString } from '../../json.js';
9
10
  const VALID_KINDS = new Set([
10
11
  'raw',
@@ -24,6 +25,10 @@ export async function handleCreateMemory({ req, res, opts }) {
24
25
  if (kindRaw !== undefined && !isSetMember(VALID_KINDS, kindRaw)) {
25
26
  throw new HttpError(400, `invalid kind: ${kindRaw}`);
26
27
  }
28
+ const personalRaw = body['personal'];
29
+ if (personalRaw !== undefined && !isJsonBoolean(personalRaw)) {
30
+ throw new HttpError(400, 'personal must be a boolean');
31
+ }
27
32
  const result = remember(ctx, {
28
33
  content,
29
34
  kind: kindRaw,
@@ -31,6 +36,8 @@ export async function handleCreateMemory({ req, res, opts }) {
31
36
  owner: getString(body, 'owner'),
32
37
  artifactRef: getString(body, 'artifactRef'),
33
38
  tags: getStringArray(body, 'tags'),
39
+ project: getCallerProject(body),
40
+ personal: personalRaw === true,
34
41
  });
35
42
  sendJson(res, 200, result);
36
43
  return;
@@ -48,6 +55,7 @@ export async function handleGetGraph({ req, res, opts, query }) {
48
55
  const model = buildGraphModel(ctx.hippoRoot, ctx.tenantId, {
49
56
  entity: entityRaw ?? undefined,
50
57
  limit,
58
+ canRead: (s) => s === null || canReadScope(ctx.actor, s),
51
59
  });
52
60
  sendJson(res, 200, model);
53
61
  return;
@@ -1,6 +1,7 @@
1
1
  // Recall routes: /v1/memories search, assemble, drill and /v1/context.
2
2
  import { dirname, resolve } from 'node:path';
3
- import { resolveProjectIdentity } from '../../project-identity.js';
3
+ import { assertCallerProject, resolveProjectIdentity } from '../../project-identity.js';
4
+ import { isSharedStore } from '../../config.js';
4
5
  import { assembleCost, contextCost, drillCost } from '../../context-render.js';
5
6
  import { closeHippoDb, openHippoDb } from '../../db.js';
6
7
  import { updateStats } from '../../store/index-and-stats.js';
@@ -272,6 +273,17 @@ export async function handleDrillRecall({ req, res, opts, query }, drillMatch) {
272
273
  sendJson(res, 200, result);
273
274
  return;
274
275
  }
276
+ /** A shared store's folder is no caller's project, so the caller names it (`project`, repeated `alias`); with none, getContext refuses. */
277
+ function contextReader(hippoRoot, query) {
278
+ if (!isSharedStore(hippoRoot))
279
+ return resolveProjectIdentity(dirname(resolve(hippoRoot)));
280
+ const name = query.get('project');
281
+ if (name === null)
282
+ return '';
283
+ const aliases = query.getAll('alias');
284
+ assertCallerProject({ name, aliases });
285
+ return { name, legacyName: name, aliases };
286
+ }
275
287
  // GET /v1/context — assemble a budget-bounded context bundle. Returns
276
288
  // ContextResult JSON (entries + tokens + activeSnapshot + sessionHandoff
277
289
  // + recentEvents). No server-side rendering; clients render. Tenant-scoped
@@ -332,7 +344,7 @@ export async function handleGetContext({ req, res, opts, query }) {
332
344
  scope,
333
345
  includeRecent,
334
346
  crossProject,
335
- currentProject: resolveProjectIdentity(dirname(resolve(opts.hippoRoot))),
347
+ currentProject: contextReader(opts.hippoRoot, query),
336
348
  cost: contextCost('markdown', 'observe'), // clients render; the budget prices the block `hippo context` would print
337
349
  });
338
350
  recordTokens(ctx, 'http_context', { items: result.entries.length, tokens: result.tokens });
@@ -1,4 +1,8 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import type { Context } from '../api.js';
3
+ import type { JsonValue } from '../json.js';
4
+ import type { RateLimiter } from '../rate-limit.js';
5
+ import type { HippoStore } from '../store-port.js';
2
6
  export interface ServerHandle {
3
7
  port: number;
4
8
  url: string;
@@ -19,6 +23,20 @@ export interface ResolvedBearer {
19
23
  }
20
24
  /** Sole judge of non-`hk_` bearer tokens: null is a 401; a throw or missed deadline is a 503, so throw only when upstream is down. */
21
25
  export type AuthResolver = (token: string) => ResolvedBearer | null | Promise<ResolvedBearer | null>;
26
+ /** What an add-on route's handler gets: the caller core authenticated and the parsed JSON body. */
27
+ export interface AddonCall {
28
+ readonly ctx: Context;
29
+ readonly body: Readonly<Record<string, JsonValue>>;
30
+ }
31
+ /** A POST /v1 route an add-on mounts through serve(): core authenticates and parses first, then sends the returned value as 200 JSON. */
32
+ export interface AddonRoute {
33
+ readonly path: string;
34
+ readonly handler: (call: AddonCall) => Promise<JsonValue>;
35
+ }
36
+ export interface RateLimitSpec {
37
+ ratePerSec: number;
38
+ burst: number;
39
+ }
22
40
  export interface ServeOpts {
23
41
  hippoRoot: string;
24
42
  /** Runs on every request and SSE heartbeat, so keep it cache-backed; API keys never reach it. */
@@ -31,26 +49,45 @@ export interface ServeOpts {
31
49
  handleSignals?: boolean;
32
50
  /** How long stop() lets in-flight requests finish before closing their sockets; defaults to 5000 ms. */
33
51
  shutdownDrainMs?: number;
52
+ /** Defaults to hippo.db under `hippoRoot`. A store of another kind runs only the routes ported to it; its caller closes it. */
53
+ store?: HippoStore;
54
+ autoSleep?: false;
55
+ routes?: readonly AddonRoute[];
56
+ mintBodyDeadlineMs?: number;
57
+ /** Static JSON served to anyone at GET <path>; built once at boot and never authenticated, so it must hold nothing secret. */
58
+ publicJson?: Readonly<Record<string, JsonValue>>;
59
+ /** Request limits: perCaller after auth; perAddress replaces HIPPO_V1_RPS when set, 'off' disables it; failedAuthPerAddress guards scrypt. */
60
+ rateLimits?: {
61
+ perCaller?: RateLimitSpec;
62
+ perAddress?: RateLimitSpec | 'off';
63
+ failedAuthPerAddress?: RateLimitSpec;
64
+ };
34
65
  }
66
+ export type ResolvedServeOpts = ServeOpts & {
67
+ store: HippoStore;
68
+ publicJsonBodies: ReadonlyMap<string, string>;
69
+ callerLimiter?: RateLimiter;
70
+ failedAuthLimiter: RateLimiter;
71
+ };
35
72
  /** Per-request values the /v1 route handlers read. */
36
73
  export interface RouteRequest {
37
74
  req: IncomingMessage;
38
75
  res: ServerResponse;
39
- opts: ServeOpts;
76
+ opts: ResolvedServeOpts;
40
77
  query: URLSearchParams;
41
78
  }
42
- /** One /v1 route: an exact path, a matchPath pattern, or a regex, each paired with the handler for one method. */
79
+ /** One /v1 route: an exact path, a matchPath pattern, or a regex, each paired with the handler for one method. `storeReady` routes run under any store. */
43
80
  export type Route = {
44
81
  method: string;
82
+ storeReady?: true;
83
+ } & ({
45
84
  path: string;
46
85
  handler: (r: RouteRequest) => Promise<void>;
47
86
  } | {
48
- method: string;
49
87
  pattern: string;
50
88
  handler: (r: RouteRequest, params: Record<string, string>) => Promise<void>;
51
89
  } | {
52
- method: string;
53
90
  regex: RegExp;
54
91
  handler: (r: RouteRequest, match: RegExpMatchArray) => Promise<void>;
55
- };
92
+ });
56
93
  //# sourceMappingURL=types.d.ts.map
@@ -1,13 +1,16 @@
1
1
  import type { IncomingMessage } from 'node:http';
2
- import type { Context } from '../api.js';
2
+ import type { Context, RememberOpts } from '../api.js';
3
3
  import { type JsonValue } from '../json.js';
4
4
  export declare function isJsonNumber(value: JsonValue | undefined): value is number;
5
5
  export declare function isJsonBoolean(value: JsonValue | undefined): value is boolean;
6
6
  export declare function isSetMember<T extends string>(set: ReadonlySet<T>, value: string): value is T;
7
7
  export declare function parseListLimit(limitRaw: string | null, defaultLimit?: number, maxLimit?: number): number;
8
- /** `_authed` is proof the caller passed auth: an unauthenticated request must never make the server read its body. */
8
+ /** `_authed` is proof the caller passed auth, so a full-size body is read only for an authenticated caller; the key mint reads first under its own small cap. */
9
9
  export declare function parseJsonBody(req: IncomingMessage, _authed: Context): Promise<Record<string, JsonValue>>;
10
+ export declare function parseJsonObjectText(raw: string): Record<string, JsonValue>;
10
11
  export declare function getString(obj: Record<string, JsonValue>, key: string): string | undefined;
11
12
  export declare function getStringArray(obj: Record<string, JsonValue>, key: string): string[] | undefined;
13
+ /** Keeps only `name` and `aliases`: a client's `legacy_name` is a folder name, which must never become a row's origin. */
14
+ export declare function getCallerProject(body: Record<string, JsonValue>): RememberOpts['project'];
12
15
  export declare function validateIdSegment(id: string, fieldName: string): void;
13
16
  //# sourceMappingURL=validation.d.ts.map
@@ -30,9 +30,11 @@ export function parseListLimit(limitRaw, defaultLimit = 100, maxLimit = 1000) {
30
30
  }
31
31
  return limit;
32
32
  }
33
- /** `_authed` is proof the caller passed auth: an unauthenticated request must never make the server read its body. */
33
+ /** `_authed` is proof the caller passed auth, so a full-size body is read only for an authenticated caller; the key mint reads first under its own small cap. */
34
34
  export async function parseJsonBody(req, _authed) {
35
- const raw = await readBody(req);
35
+ return parseJsonObjectText(await readBody(req));
36
+ }
37
+ export function parseJsonObjectText(raw) {
36
38
  if (raw.length === 0)
37
39
  return {};
38
40
  try {
@@ -60,6 +62,21 @@ export function getStringArray(obj, key) {
60
62
  return undefined;
61
63
  return v;
62
64
  }
65
+ /** Keeps only `name` and `aliases`: a client's `legacy_name` is a folder name, which must never become a row's origin. */
66
+ export function getCallerProject(body) {
67
+ const v = body.project;
68
+ if (v === undefined || v === null)
69
+ return undefined;
70
+ if (!isJsonObjectRecord(v) || !isJsonString(v.name)) {
71
+ throw new HttpError(400, 'project must be an object with a "name" string');
72
+ }
73
+ if (v.aliases === undefined)
74
+ return { name: v.name };
75
+ if (!Array.isArray(v.aliases) || !v.aliases.every(isJsonString)) {
76
+ throw new HttpError(400, 'project.aliases must be an array of strings');
77
+ }
78
+ return { name: v.name, aliases: v.aliases };
79
+ }
63
80
  /**
64
81
  * Charset + length validation for `:id` route captures. Routes call
65
82
  * this immediately after `matchPath` to reject empty / overlong / illegal
package/dist/server.d.ts CHANGED
@@ -1,12 +1,29 @@
1
- import { type Actor, authRevoke, type Context } from './api.js';
1
+ import { type Actor, authCreateSelf, type AuthCreateSelfOpts, type AuthCreateSelfResult, authRevoke, type Context } from './api.js';
2
2
  import { ForbiddenError } from './api-errors.js';
3
3
  import type { ServeOpts, ServerHandle } from './server/types.js';
4
- export { authRevoke, ForbiddenError, type Context, type Actor };
4
+ export { authCreateSelf, authRevoke, ForbiddenError, type AuthCreateSelfOpts, type AuthCreateSelfResult, type Context, type Actor };
5
5
  export { isCrossSite, LOOPBACK_HOST_HEADER } from './http-util.js';
6
6
  export { __resetSessionRecallHistoryHttp } from './server/routes/recall.js';
7
7
  export { clientIpForRateLimit } from './server/client-ip.js';
8
8
  export { isLoopback, isReservedActor } from './server/auth.js';
9
- export type { AuthResolver, ResolvedBearer, ServeOpts, ServerHandle } from './server/types.js';
9
+ export type { AddonCall, AddonRoute, AuthResolver, RateLimitSpec, ResolvedBearer, ServeOpts, ServerHandle } from './server/types.js';
10
+ export { HttpError } from './http-util.js';
11
+ export { promptHookContext, type CallerProject } from './prompt-hook.js';
12
+ export type { JsonValue } from './json.js';
13
+ export { captureSessionTexts, type SessionCaptureRequest, type SessionCaptureResult } from './capture/session-texts.js';
14
+ export { sqliteStore, type HippoStore } from './store-port.js';
15
+ export type { ApiKeyRecord } from './auth.js';
16
+ export { StoreBusyError } from './db.js';
17
+ export { authCreate, type AuthCreateOpts, type AuthCreateResult } from './api.js';
18
+ export { isSharedStore } from './config.js';
19
+ export { ownerOrSubject } from './api.js';
20
+ export { bindSessionOwner } from './session-owners.js';
21
+ export { preCompactForCaller, type CallerHookOutput, type CallerPreCompactRequest } from './capture/pre-compact-caller.js';
22
+ export { compactResumeForCaller, type CallerCompactResumeRequest } from './capture/compact-resume-caller.js';
23
+ export { saveCompactionItemsForCaller, type CallerItemsRequest, type CallerItemsResult } from './capture/compaction-items-caller.js';
24
+ export { captureFailureForCaller, type CallerFailureRequest, type CallerFailureResult } from './capture/failure-caller.js';
25
+ export { sessionEndHandoffForCaller, type CallerEvidence, type CallerSessionEndRequest, type CallerSessionEndResult } from './capture/session-end-caller.js';
26
+ export type { WorkingState } from './capture/working-state.js';
10
27
  /**
11
28
  * Boot the HTTP daemon on host:port and write the pidfile under hippoRoot.
12
29
  *
@@ -15,7 +32,7 @@ export type { AuthResolver, ResolvedBearer, ServeOpts, ServerHandle } from './se
15
32
  * requireAuth) has shipped and every route checks it except GET /health
16
33
  * (public by design for platform health checks) and the two connector
17
34
  * webhooks in PUBLIC_ROUTES, which are HMAC-gated by their own signing
18
- * secrets and 404 when those secrets are unset. But the loopback
35
+ * secrets and 404 when those secrets are unset, and any publicJson GET path. But the loopback
19
36
  * no-auth fallback inside buildContextWithAuth still admits unauthenticated
20
37
  * requests from a loopback remote address (unless they carry Forwarded,
21
38
  * X-Forwarded-For/-Host/-Proto, X-Real-IP, Cf-Connecting-Ip or True-Client-Ip, which mark a same-host proxy and get