hippo-memory 1.57.0 → 1.58.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 (91) hide show
  1. package/README.md +11 -0
  2. package/dist/agent-memories/claude-code.js +1 -1
  3. package/dist/agent-memories/gemini.js +1 -1
  4. package/dist/api-errors.d.ts +27 -0
  5. package/dist/api-errors.js +37 -0
  6. package/dist/api.d.ts +5 -5
  7. package/dist/api.js +40 -47
  8. package/dist/audit.d.ts +4 -0
  9. package/dist/audit.js +11 -0
  10. package/dist/autolearn.d.ts +1 -1
  11. package/dist/autolearn.js +7 -5
  12. package/dist/capture-contract.d.ts +47 -0
  13. package/dist/capture-contract.js +49 -0
  14. package/dist/capture-error.js +2 -1
  15. package/dist/capture.d.ts +0 -13
  16. package/dist/capture.js +5 -66
  17. package/dist/cli/shared.js +10 -6
  18. package/dist/cli.js +100 -39
  19. package/dist/client.js +9 -0
  20. package/dist/codex-patch.js +1 -1
  21. package/dist/compaction-record.d.ts +1 -1
  22. package/dist/compaction-record.js +3 -2
  23. package/dist/config.d.ts +5 -0
  24. package/dist/config.js +17 -0
  25. package/dist/connectors/github/dlq.js +5 -2
  26. package/dist/connectors/github/octokit-client.js +4 -2
  27. package/dist/connectors/slack/dlq.js +6 -2
  28. package/dist/connectors/slack/web-client.js +7 -5
  29. package/dist/consolidate.d.ts +10 -0
  30. package/dist/consolidate.js +36 -34
  31. package/dist/customer-notes.js +14 -13
  32. package/dist/dag.js +3 -2
  33. package/dist/dashboard.js +1 -1
  34. package/dist/db.d.ts +12 -0
  35. package/dist/db.js +62 -1
  36. package/dist/decisions.js +9 -8
  37. package/dist/doctor.js +5 -0
  38. package/dist/embedding-provider.js +3 -3
  39. package/dist/embeddings.d.ts +4 -4
  40. package/dist/embeddings.js +72 -16
  41. package/dist/extract.js +3 -2
  42. package/dist/http-retry.d.ts +21 -0
  43. package/dist/http-retry.js +50 -0
  44. package/dist/http-util.d.ts +8 -0
  45. package/dist/http-util.js +10 -0
  46. package/dist/importers.d.ts +2 -0
  47. package/dist/importers.js +16 -5
  48. package/dist/incidents.js +11 -10
  49. package/dist/judgment.js +10 -17
  50. package/dist/log.d.ts +25 -0
  51. package/dist/log.js +48 -0
  52. package/dist/mcp/server.js +52 -24
  53. package/dist/mcp/tool-args.d.ts +21 -0
  54. package/dist/mcp/tool-args.js +80 -0
  55. package/dist/memory.js +3 -2
  56. package/dist/overlap-index.d.ts +7 -0
  57. package/dist/overlap-index.js +38 -0
  58. package/dist/pilot-arm.d.ts +9 -0
  59. package/dist/pilot-arm.js +47 -0
  60. package/dist/policies.js +12 -11
  61. package/dist/predictions.js +9 -8
  62. package/dist/processes.js +14 -13
  63. package/dist/project-briefs.js +16 -15
  64. package/dist/project-identity.d.ts +1 -1
  65. package/dist/project-identity.js +25 -1
  66. package/dist/raw-archive.js +7 -6
  67. package/dist/recall-scope.d.ts +2 -1
  68. package/dist/recall-scope.js +2 -1
  69. package/dist/refine-llm.js +3 -2
  70. package/dist/reject-flow.js +6 -9
  71. package/dist/rejection.d.ts +2 -1
  72. package/dist/rejection.js +2 -1
  73. package/dist/search.js +14 -2
  74. package/dist/secret-detect.d.ts +13 -1
  75. package/dist/secret-detect.js +33 -1
  76. package/dist/server.d.ts +3 -1
  77. package/dist/server.js +180 -409
  78. package/dist/session-digest.js +2 -1
  79. package/dist/shared.js +7 -6
  80. package/dist/skills.js +15 -14
  81. package/dist/store.js +4 -4
  82. package/dist/token-ledger.d.ts +4 -2
  83. package/dist/token-ledger.js +2 -2
  84. package/dist/version.d.ts +1 -1
  85. package/dist/version.js +1 -1
  86. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  87. package/extensions/openclaw-plugin/package.json +1 -1
  88. package/openclaw.plugin.json +1 -1
  89. package/package.json +1 -1
  90. package/dist/connectors/slack/ratelimit.d.ts +0 -9
  91. package/dist/connectors/slack/ratelimit.js +0 -18
package/dist/server.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createServer } from 'node:http';
2
- import { createHash } from 'node:crypto';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
3
  import { dirname, resolve } from 'node:path';
4
4
  import { resolveProjectIdentity } from './project-identity.js';
5
5
  import { assembleCost, contextCost, drillCost } from './context-render.js';
@@ -8,7 +8,7 @@ import { resolveTenantId } from './tenant.js';
8
8
  import { openHippoDb, closeHippoDb } from './db.js';
9
9
  import { updateStats } from './store.js';
10
10
  import { buildSessionKey, getOrCreateRing, appendRecall, snapshotRing, hashQueryText, biasHintEnabled, } from './recall-history.js';
11
- import { appendAuditEvent, auditQueryFields, AUDIT_OPS } from './audit.js';
11
+ import { appendAuditEvent, auditQueryFields, auditWriteFailureCount, AUDIT_OPS } from './audit.js';
12
12
  // v0.33 / J1 — Module-level per-(tenant, session) recall-history ring map
13
13
  // for the HTTP pipeline. Separate from CLI/MCP rings per plan v3 (per-
14
14
  // pipeline rings; no IPC). HTTP is the only caller that threads its
@@ -21,9 +21,10 @@ export function __resetSessionRecallHistoryHttp() {
21
21
  sessionRecallHistoryHttp.clear();
22
22
  }
23
23
  import { PACKAGE_VERSION } from './version.js';
24
+ import { log } from './log.js';
24
25
  import { API_KEY_PREFIX, validateApiKey } from './auth.js';
25
26
  import { createRateLimiter } from './rate-limit.js';
26
- import { remember, retrieve, RecallContractError, ForbiddenError, drillDown, assemble, forget, promote, supersede, archiveRaw, authCreate, authList, authRevoke, auditList, outcome, outcomeForLastRecall, getContext, sleep, recordTokens, quarantineList, quarantineApprove, quarantineReject, } from './api.js';
27
+ import { remember, retrieve, RecallContractError, drillDown, assemble, forget, promote, supersede, archiveRaw, authCreate, authList, authRevoke, auditList, outcome, outcomeForLastRecall, getContext, sleep, recordTokens, quarantineList, quarantineApprove, quarantineReject, } from './api.js';
27
28
  import { buildGraphModel } from './graph-view.js';
28
29
  import { MAX_ENTITY_NAME_LEN } from './graph.js';
29
30
  import { savePrediction, closePrediction, loadPredictionById, loadPredictionsByClass, loadOpenPredictions, computePredictionBaserate, VALID_CLOSURE_STATES, } from './predictions.js';
@@ -37,7 +38,8 @@ import { saveCustomerNote, closeCustomerNote, loadCustomerNoteById, loadCustomer
37
38
  import { handleMcpRequest } from './mcp/server.js';
38
39
  import { handleSlackEventsWebhook } from './connectors/slack/webhook.js';
39
40
  import { handleGitHubEventsWebhook } from './connectors/github/webhook.js';
40
- import { HttpError, JSON_HEADERS, BodyTooLargeError, isHeaderString, isJsonObjectRecord, readBody, sendJson, } from './http-util.js';
41
+ import { HttpError, JSON_HEADERS, BodyTooLargeError, isHeaderString, isJsonObjectRecord, mapApiError, readBody, sendJson, } from './http-util.js';
42
+ import { NotFoundError } from './api-errors.js';
41
43
  // Review patch #2: explicit allow-list for unauthenticated /v1/* routes.
42
44
  // New unauth routes MUST be added here AND get a corresponding entry in
43
45
  // tests/server-bearer-lockdown.test.ts. Do not gate auth elsewhere by
@@ -156,6 +158,22 @@ const VALID_KINDS = new Set([
156
158
  // v1.3.1: source from src/version.ts so /health no longer reports stale 0.39.0.
157
159
  const VERSION = PACKAGE_VERSION;
158
160
  const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']);
161
+ // The caller's id lands in a response header and in logs, so only a short plain token is echoed back.
162
+ const REQUEST_ID_RE = /^[A-Za-z0-9._:-]{1,128}$/;
163
+ /** The caller's `X-Request-Id` when it is a short plain token, else a fresh UUID. */
164
+ function resolveRequestId(header) {
165
+ const value = Array.isArray(header) ? undefined : header;
166
+ return value && REQUEST_ID_RE.test(value) ? value : randomUUID();
167
+ }
168
+ /** One line per failed request; 4xx is the caller's mistake, so it stays below the default level. */
169
+ function logRequestFailure(req, err, requestId, status) {
170
+ const message = err instanceof Error ? err.message : String(err);
171
+ const line = `${req.method ?? 'GET'} ${(req.url ?? '/').split('?')[0]} failed: ${message}`;
172
+ if (status >= 500)
173
+ log.error(line, { requestId, status });
174
+ else
175
+ log.info(line, { requestId, status });
176
+ }
159
177
  function sendError(res, status, message) {
160
178
  sendJson(res, status, { error: message });
161
179
  }
@@ -176,35 +194,17 @@ async function parseJsonBody(req) {
176
194
  throw new HttpError(400, 'invalid JSON body');
177
195
  }
178
196
  }
179
- /**
180
- * Map an error thrown by an api.* function into an HTTP status + message.
181
- * api.* uses plain Error, so we discriminate by message pattern. Stable
182
- * patterns we rely on:
183
- * - /not found/i → 404 (forget on unknown id, supersede on unknown old id, etc.)
184
- * - /unknown/i → 404 (auth_revoke on unknown key_id)
185
- * - /already superseded/i → 409 (chain conflict)
186
- * - /not raw/i → 400 (archive_raw on non-raw row)
187
- * ForbiddenError maps to 403; everything else to 400 (bad input).
188
- */
189
- function mapApiError(err) {
190
- const message = err instanceof Error ? err.message : String(err);
191
- if (err instanceof ForbiddenError) {
192
- return { status: 403, message };
193
- }
194
- const lower = message.toLowerCase();
195
- if (/not found/.test(lower) || /^unknown /.test(lower)) {
196
- return { status: 404, message };
197
- }
198
- if (/already superseded/.test(lower)) {
199
- return { status: 409, message };
197
+ function parseRequest(req) {
198
+ let url;
199
+ try {
200
+ url = new URL(req.url ?? '/', 'http://placeholder');
200
201
  }
201
- if (/requires admin role/.test(lower)) {
202
- return { status: 403, message };
202
+ catch (e) {
203
+ // A request target the URL parser rejects is the caller's fault, not a server failure.
204
+ if (e instanceof TypeError)
205
+ throw new HttpError(400, e.message);
206
+ throw e;
203
207
  }
204
- return { status: 400, message };
205
- }
206
- function parseRequest(req) {
207
- const url = new URL(req.url ?? '/', 'http://placeholder');
208
208
  return {
209
209
  method: req.method ?? 'GET',
210
210
  path: url.pathname,
@@ -219,6 +219,16 @@ function parseRequest(req) {
219
219
  * mapping each :param name to its value. Path segments are exact-matched
220
220
  * except for parameter slots.
221
221
  */
222
+ function decodePathSegment(segment) {
223
+ try {
224
+ return decodeURIComponent(segment);
225
+ }
226
+ catch (e) {
227
+ if (e instanceof URIError)
228
+ throw new HttpError(400, e.message);
229
+ throw e;
230
+ }
231
+ }
222
232
  function matchPath(pattern, path) {
223
233
  const patternParts = pattern.split('/');
224
234
  const pathParts = path.split('/');
@@ -231,7 +241,7 @@ function matchPath(pattern, path) {
231
241
  if (pp.startsWith(':')) {
232
242
  if (ap.length === 0)
233
243
  return null;
234
- params[pp.slice(1)] = decodeURIComponent(ap);
244
+ params[pp.slice(1)] = decodePathSegment(ap);
235
245
  }
236
246
  else if (pp !== ap) {
237
247
  return null;
@@ -265,10 +275,22 @@ export function isCrossSite(req) {
265
275
  const origin = req.headers.origin;
266
276
  return origin !== undefined && origin !== `http://${req.headers.host}`;
267
277
  }
278
+ // A proxy on this host (nginx, Caddy, cloudflared) connects from loopback, so these headers mean the caller is not local.
279
+ const PROXY_HEADERS = [
280
+ 'forwarded', 'x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-real-ip', 'cf-connecting-ip', 'true-client-ip',
281
+ ];
282
+ // The auth helpers only see the request, so its id rides here for their log lines.
283
+ const requestIds = new WeakMap();
268
284
  // A browser on this machine is loopback too, so the no-key fallback also needs a local Host and a same-site caller.
269
285
  function assertLocalCaller(req) {
270
286
  if (!isLoopback(req.socket.remoteAddress))
271
287
  throw new HttpError(401, 'auth required');
288
+ const proxyHeader = PROXY_HEADERS.find((name) => req.headers[name] !== undefined);
289
+ if (proxyHeader !== undefined) {
290
+ log.warn(`proxied loopback request refused: it carries ${proxyHeader}, so the no-key local fallback does not apply. ` +
291
+ 'Send an API key (hippo auth create, then Authorization: Bearer hk_...).', { requestId: requestIds.get(req) });
292
+ throw new HttpError(401, 'auth required');
293
+ }
272
294
  const host = req.headers.host;
273
295
  if ((host !== undefined && !LOOPBACK_HOST_HEADER.test(host)) || isCrossSite(req)) {
274
296
  throw new HttpError(403, 'cross-site or non-local request refused; send an API key');
@@ -452,9 +474,8 @@ async function buildContextWithAuth(req, opts) {
452
474
  actor.viaAuthResolver = true;
453
475
  return { hippoRoot: opts.hippoRoot, tenantId: id.tenantId, actor };
454
476
  }
455
- // No Authorization header. Loopback-only fallback, unless explicitly
456
- // disabled via HIPPO_REQUIRE_AUTH=1 (used by the bearer-lockdown test
457
- // and by deployments that want to forbid the local-CLI escape hatch).
477
+ // No Authorization header. Loopback-only fallback for a direct local caller (no proxy headers),
478
+ // unless HIPPO_REQUIRE_AUTH=1 forbids the local-CLI escape hatch.
458
479
  if (process.env.HIPPO_REQUIRE_AUTH === '1') {
459
480
  throw new HttpError(401, 'auth required');
460
481
  }
@@ -1117,36 +1138,16 @@ async function handleListQuarantine({ req, res, opts, query }) {
1117
1138
  async function handleApproveQuarantine({ req, res, opts }, quarantineApproveMatch) {
1118
1139
  validateIdSegment(quarantineApproveMatch.id, 'memory id');
1119
1140
  const ctx = await buildContextWithAuth(req, opts);
1120
- try {
1121
- quarantineApprove(ctx, quarantineApproveMatch.id);
1122
- sendJson(res, 200, { approved: quarantineApproveMatch.id });
1123
- }
1124
- catch (e) {
1125
- const msg = e instanceof Error ? e.message : String(e);
1126
- if (msg.includes('not quarantined'))
1127
- throw new HttpError(404, msg);
1128
- if (msg.includes('is already') || msg.includes('scope changed'))
1129
- throw new HttpError(409, msg);
1130
- throw e;
1131
- }
1141
+ quarantineApprove(ctx, quarantineApproveMatch.id);
1142
+ sendJson(res, 200, { approved: quarantineApproveMatch.id });
1132
1143
  return;
1133
1144
  }
1134
1145
  // POST /v1/quarantine/:id/reject: admin only; ForbiddenError falls through to mapApiError's 403.
1135
1146
  async function handleRejectQuarantine({ req, res, opts }, quarantineRejectMatch) {
1136
1147
  validateIdSegment(quarantineRejectMatch.id, 'memory id');
1137
1148
  const ctx = await buildContextWithAuth(req, opts);
1138
- try {
1139
- quarantineReject(ctx, quarantineRejectMatch.id);
1140
- sendJson(res, 200, { rejected: quarantineRejectMatch.id });
1141
- }
1142
- catch (e) {
1143
- const msg = e instanceof Error ? e.message : String(e);
1144
- if (msg.includes('not quarantined'))
1145
- throw new HttpError(404, msg);
1146
- if (msg.includes('is already'))
1147
- throw new HttpError(409, msg);
1148
- throw e;
1149
- }
1149
+ quarantineReject(ctx, quarantineRejectMatch.id);
1150
+ sendJson(res, 200, { rejected: quarantineRejectMatch.id });
1150
1151
  return;
1151
1152
  }
1152
1153
  // GET /v1/audit?op=&since=&limit= — read audit events. All three filters
@@ -1335,21 +1336,12 @@ async function handleClosePrediction({ req, res, opts }, predictionCloseMatch) {
1335
1336
  closureNote = note;
1336
1337
  }
1337
1338
  const ctx = await buildContextWithAuth(req, opts);
1338
- try {
1339
- const prediction = closePrediction(opts.hippoRoot, ctx.tenantId, id, {
1340
- closureState: state,
1341
- actualValue,
1342
- closureNote,
1343
- }, ctx.actor.subject);
1344
- sendJson(res, 200, { prediction });
1345
- }
1346
- catch (e) {
1347
- const msg = e instanceof Error ? e.message : String(e);
1348
- if (msg.includes('not found')) {
1349
- throw new HttpError(404, msg);
1350
- }
1351
- throw e;
1352
- }
1339
+ const prediction = closePrediction(opts.hippoRoot, ctx.tenantId, id, {
1340
+ closureState: state,
1341
+ actualValue,
1342
+ closureNote,
1343
+ }, ctx.actor.subject);
1344
+ sendJson(res, 200, { prediction });
1353
1345
  return;
1354
1346
  }
1355
1347
  // ── decisions (E2 first-class object) ──
@@ -1400,10 +1392,9 @@ async function handleCreateDecision({ req, res, opts }) {
1400
1392
  sendJson(res, 201, { decision });
1401
1393
  }
1402
1394
  catch (e) {
1403
- const msg = e instanceof Error ? e.message : String(e);
1404
- if (msg.includes('not found') || msg.includes('not active')) {
1405
- throw new HttpError(409, msg);
1406
- }
1395
+ // A missing referenced row is a conflict with the create, not a missing target.
1396
+ if (e instanceof NotFoundError)
1397
+ throw new HttpError(409, e.message);
1407
1398
  throw e;
1408
1399
  }
1409
1400
  return;
@@ -1450,43 +1441,19 @@ async function handleSupersedeDecision({ req, res, opts }, decisionSupersedeMatc
1450
1441
  context = contextRaw;
1451
1442
  }
1452
1443
  const ctx = await buildContextWithAuth(req, opts);
1453
- try {
1454
- const decision = saveDecision(opts.hippoRoot, ctx.tenantId, {
1455
- decisionText: text,
1456
- context,
1457
- supersedesDecisionId: oldId,
1458
- }, ctx.actor.subject);
1459
- sendJson(res, 201, { decision });
1460
- }
1461
- catch (e) {
1462
- const msg = e instanceof Error ? e.message : String(e);
1463
- if (msg.includes('not found')) {
1464
- throw new HttpError(404, msg);
1465
- }
1466
- if (msg.includes('not active')) {
1467
- throw new HttpError(409, msg);
1468
- }
1469
- throw e;
1470
- }
1444
+ const decision = saveDecision(opts.hippoRoot, ctx.tenantId, {
1445
+ decisionText: text,
1446
+ context,
1447
+ supersedesDecisionId: oldId,
1448
+ }, ctx.actor.subject);
1449
+ sendJson(res, 201, { decision });
1471
1450
  return;
1472
1451
  }
1473
1452
  async function handleCloseDecision({ req, res, opts }, decisionCloseMatch) {
1474
1453
  const id = parseInt(decisionCloseMatch[1], 10);
1475
1454
  const ctx = await buildContextWithAuth(req, opts);
1476
- try {
1477
- const decision = closeDecision(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
1478
- sendJson(res, 200, { decision });
1479
- }
1480
- catch (e) {
1481
- const msg = e instanceof Error ? e.message : String(e);
1482
- if (msg.includes('not found')) {
1483
- throw new HttpError(404, msg);
1484
- }
1485
- if (msg.includes('not active')) {
1486
- throw new HttpError(409, msg);
1487
- }
1488
- throw e;
1489
- }
1455
+ const decision = closeDecision(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
1456
+ sendJson(res, 200, { decision });
1490
1457
  return;
1491
1458
  }
1492
1459
  async function handleGetDecision({ req, res, opts }, decisionByIdMatch) {
@@ -1555,10 +1522,9 @@ async function handleCreateIncident({ req, res, opts }) {
1555
1522
  sendJson(res, 201, { incident });
1556
1523
  }
1557
1524
  catch (e) {
1558
- const msg = e instanceof Error ? e.message : String(e);
1559
- if (msg.includes('not found')) {
1560
- throw new HttpError(409, msg);
1561
- }
1525
+ // A missing referenced row is a conflict with the create, not a missing target.
1526
+ if (e instanceof NotFoundError)
1527
+ throw new HttpError(409, e.message);
1562
1528
  throw e;
1563
1529
  }
1564
1530
  return;
@@ -1594,39 +1560,15 @@ async function handleResolveIncident({ req, res, opts }, incidentResolveMatch) {
1594
1560
  throw new HttpError(400, 'resolutionText exceeds 4096-character cap');
1595
1561
  }
1596
1562
  const ctx = await buildContextWithAuth(req, opts);
1597
- try {
1598
- const incident = resolveIncident(opts.hippoRoot, ctx.tenantId, id, resolutionText, ctx.actor.subject);
1599
- sendJson(res, 200, { incident });
1600
- }
1601
- catch (e) {
1602
- const msg = e instanceof Error ? e.message : String(e);
1603
- if (msg.includes('not found')) {
1604
- throw new HttpError(404, msg);
1605
- }
1606
- if (msg.includes('not open')) {
1607
- throw new HttpError(409, msg);
1608
- }
1609
- throw e;
1610
- }
1563
+ const incident = resolveIncident(opts.hippoRoot, ctx.tenantId, id, resolutionText, ctx.actor.subject);
1564
+ sendJson(res, 200, { incident });
1611
1565
  return;
1612
1566
  }
1613
1567
  async function handleCloseIncident({ req, res, opts }, incidentCloseMatch) {
1614
1568
  const id = parseInt(incidentCloseMatch[1], 10);
1615
1569
  const ctx = await buildContextWithAuth(req, opts);
1616
- try {
1617
- const incident = closeIncident(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
1618
- sendJson(res, 200, { incident });
1619
- }
1620
- catch (e) {
1621
- const msg = e instanceof Error ? e.message : String(e);
1622
- if (msg.includes('not found')) {
1623
- throw new HttpError(404, msg);
1624
- }
1625
- if (msg.includes('already closed')) {
1626
- throw new HttpError(409, msg);
1627
- }
1628
- throw e;
1629
- }
1570
+ const incident = closeIncident(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
1571
+ sendJson(res, 200, { incident });
1630
1572
  return;
1631
1573
  }
1632
1574
  async function handleGetIncident({ req, res, opts }, incidentByIdMatch) {
@@ -1737,45 +1679,21 @@ async function handleSupersedeProcess({ req, res, opts }, processSupersedeMatch)
1737
1679
  if (!existing) {
1738
1680
  throw new HttpError(404, `process ${id} not found`);
1739
1681
  }
1740
- try {
1741
- const process = saveProcess(opts.hippoRoot, ctx.tenantId, {
1742
- processName: existing.processName,
1743
- steps,
1744
- description,
1745
- changeSummary,
1746
- supersedesProcessId: id,
1747
- }, ctx.actor.subject);
1748
- sendJson(res, 200, { process });
1749
- }
1750
- catch (e) {
1751
- const msg = e instanceof Error ? e.message : String(e);
1752
- if (msg.includes('not found')) {
1753
- throw new HttpError(404, msg);
1754
- }
1755
- if (msg.includes('not active') || msg.includes('could not be superseded')) {
1756
- throw new HttpError(409, msg);
1757
- }
1758
- throw e;
1759
- }
1682
+ const process = saveProcess(opts.hippoRoot, ctx.tenantId, {
1683
+ processName: existing.processName,
1684
+ steps,
1685
+ description,
1686
+ changeSummary,
1687
+ supersedesProcessId: id,
1688
+ }, ctx.actor.subject);
1689
+ sendJson(res, 200, { process });
1760
1690
  return;
1761
1691
  }
1762
1692
  async function handleCloseProcess({ req, res, opts }, processCloseMatch) {
1763
1693
  const id = parseInt(processCloseMatch[1], 10);
1764
1694
  const ctx = await buildContextWithAuth(req, opts);
1765
- try {
1766
- const process = closeProcess(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
1767
- sendJson(res, 200, { process });
1768
- }
1769
- catch (e) {
1770
- const msg = e instanceof Error ? e.message : String(e);
1771
- if (msg.includes('not found')) {
1772
- throw new HttpError(404, msg);
1773
- }
1774
- if (msg.includes('not active')) {
1775
- throw new HttpError(409, msg);
1776
- }
1777
- throw e;
1778
- }
1695
+ const process = closeProcess(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
1696
+ sendJson(res, 200, { process });
1779
1697
  return;
1780
1698
  }
1781
1699
  async function handleGetProcess({ req, res, opts }, processByIdMatch) {
@@ -1816,19 +1734,13 @@ async function handleCreatePolicy({ req, res, opts }) {
1816
1734
  const validFrom = optionalDateField(body['validFrom'], 'validFrom');
1817
1735
  const validTo = optionalDateField(body['validTo'], 'validTo');
1818
1736
  const ctx = await buildContextWithAuth(req, opts);
1819
- try {
1820
- const policy = savePolicy(opts.hippoRoot, ctx.tenantId, {
1821
- policyName,
1822
- policyText,
1823
- validFrom,
1824
- validTo,
1825
- }, ctx.actor.subject);
1826
- sendJson(res, 201, { policy });
1827
- }
1828
- catch (e) {
1829
- // savePolicy throws on invalid/inverted dates (validation) -> 400.
1830
- throw new HttpError(400, e instanceof Error ? e.message : String(e));
1831
- }
1737
+ const policy = savePolicy(opts.hippoRoot, ctx.tenantId, {
1738
+ policyName,
1739
+ policyText,
1740
+ validFrom,
1741
+ validTo,
1742
+ }, ctx.actor.subject);
1743
+ sendJson(res, 201, { policy });
1832
1744
  return;
1833
1745
  }
1834
1746
  async function handleListPolicies({ req, res, opts, query }) {
@@ -1860,13 +1772,8 @@ async function handlePoliciesAsOf({ req, res, opts, query }) {
1860
1772
  }
1861
1773
  const name = query.get('name') ?? undefined;
1862
1774
  const ctx = await buildContextWithAuth(req, opts);
1863
- try {
1864
- const policies = loadPoliciesAsOf(opts.hippoRoot, ctx.tenantId, date, { name });
1865
- sendJson(res, 200, { policies });
1866
- }
1867
- catch (e) {
1868
- throw new HttpError(400, e instanceof Error ? e.message : String(e));
1869
- }
1775
+ const policies = loadPoliciesAsOf(opts.hippoRoot, ctx.tenantId, date, { name });
1776
+ sendJson(res, 200, { policies });
1870
1777
  return;
1871
1778
  }
1872
1779
  async function handleSupersedePolicy({ req, res, opts }, policySupersedeMatch) {
@@ -1897,47 +1804,22 @@ async function handleSupersedePolicy({ req, res, opts }, policySupersedeMatch) {
1897
1804
  if (!existing) {
1898
1805
  throw new HttpError(404, `policy ${id} not found`);
1899
1806
  }
1900
- try {
1901
- const policy = savePolicy(opts.hippoRoot, ctx.tenantId, {
1902
- policyName: existing.policyName,
1903
- policyText,
1904
- validFrom,
1905
- validTo,
1906
- changeSummary,
1907
- supersedesPolicyId: id,
1908
- }, ctx.actor.subject);
1909
- sendJson(res, 200, { policy });
1910
- }
1911
- catch (e) {
1912
- const msg = e instanceof Error ? e.message : String(e);
1913
- if (msg.includes('not found')) {
1914
- throw new HttpError(404, msg);
1915
- }
1916
- if (msg.includes('not active') || msg.includes('could not be superseded')) {
1917
- throw new HttpError(409, msg);
1918
- }
1919
- // invalid/inverted date or missing field -> validation.
1920
- throw new HttpError(400, msg);
1921
- }
1807
+ const policy = savePolicy(opts.hippoRoot, ctx.tenantId, {
1808
+ policyName: existing.policyName,
1809
+ policyText,
1810
+ validFrom,
1811
+ validTo,
1812
+ changeSummary,
1813
+ supersedesPolicyId: id,
1814
+ }, ctx.actor.subject);
1815
+ sendJson(res, 200, { policy });
1922
1816
  return;
1923
1817
  }
1924
1818
  async function handleClosePolicy({ req, res, opts }, policyCloseMatch) {
1925
1819
  const id = parseInt(policyCloseMatch[1], 10);
1926
1820
  const ctx = await buildContextWithAuth(req, opts);
1927
- try {
1928
- const policy = closePolicy(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
1929
- sendJson(res, 200, { policy });
1930
- }
1931
- catch (e) {
1932
- const msg = e instanceof Error ? e.message : String(e);
1933
- if (msg.includes('not found')) {
1934
- throw new HttpError(404, msg);
1935
- }
1936
- if (msg.includes('not active')) {
1937
- throw new HttpError(409, msg);
1938
- }
1939
- throw e;
1940
- }
1821
+ const policy = closePolicy(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
1822
+ sendJson(res, 200, { policy });
1941
1823
  return;
1942
1824
  }
1943
1825
  async function handleGetPolicy({ req, res, opts }, policyByIdMatch) {
@@ -1990,18 +1872,12 @@ async function handleCreateSkill({ req, res, opts }) {
1990
1872
  trigger = triggerRaw;
1991
1873
  }
1992
1874
  const ctx = await buildContextWithAuth(req, opts);
1993
- try {
1994
- const skill = saveSkill(opts.hippoRoot, ctx.tenantId, {
1995
- skillName,
1996
- instructions,
1997
- trigger,
1998
- }, ctx.actor.subject);
1999
- sendJson(res, 201, { skill });
2000
- }
2001
- catch (e) {
2002
- // saveSkill throws on validation (single-line name etc.) -> 400.
2003
- throw new HttpError(400, e instanceof Error ? e.message : String(e));
2004
- }
1875
+ const skill = saveSkill(opts.hippoRoot, ctx.tenantId, {
1876
+ skillName,
1877
+ instructions,
1878
+ trigger,
1879
+ }, ctx.actor.subject);
1880
+ sendJson(res, 201, { skill });
2005
1881
  return;
2006
1882
  }
2007
1883
  async function handleListSkills({ req, res, opts, query }) {
@@ -2069,45 +1945,21 @@ async function handleSupersedeSkill({ req, res, opts }, skillSupersedeMatch) {
2069
1945
  if (!existing) {
2070
1946
  throw new HttpError(404, `skill ${id} not found`);
2071
1947
  }
2072
- try {
2073
- const skill = saveSkill(opts.hippoRoot, ctx.tenantId, {
2074
- skillName: existing.skillName,
2075
- instructions,
2076
- trigger,
2077
- changeSummary,
2078
- supersedesSkillId: id,
2079
- }, ctx.actor.subject);
2080
- sendJson(res, 200, { skill });
2081
- }
2082
- catch (e) {
2083
- const msg = e instanceof Error ? e.message : String(e);
2084
- if (msg.includes('not found')) {
2085
- throw new HttpError(404, msg);
2086
- }
2087
- if (msg.includes('not active') || msg.includes('could not be superseded')) {
2088
- throw new HttpError(409, msg);
2089
- }
2090
- throw new HttpError(400, msg);
2091
- }
1948
+ const skill = saveSkill(opts.hippoRoot, ctx.tenantId, {
1949
+ skillName: existing.skillName,
1950
+ instructions,
1951
+ trigger,
1952
+ changeSummary,
1953
+ supersedesSkillId: id,
1954
+ }, ctx.actor.subject);
1955
+ sendJson(res, 200, { skill });
2092
1956
  return;
2093
1957
  }
2094
1958
  async function handleCloseSkill({ req, res, opts }, skillCloseMatch) {
2095
1959
  const id = parseInt(skillCloseMatch[1], 10);
2096
1960
  const ctx = await buildContextWithAuth(req, opts);
2097
- try {
2098
- const skill = closeSkill(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
2099
- sendJson(res, 200, { skill });
2100
- }
2101
- catch (e) {
2102
- const msg = e instanceof Error ? e.message : String(e);
2103
- if (msg.includes('not found')) {
2104
- throw new HttpError(404, msg);
2105
- }
2106
- if (msg.includes('not active')) {
2107
- throw new HttpError(409, msg);
2108
- }
2109
- throw e;
2110
- }
1961
+ const skill = closeSkill(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
1962
+ sendJson(res, 200, { skill });
2111
1963
  return;
2112
1964
  }
2113
1965
  async function handleGetSkill({ req, res, opts }, skillByIdMatch) {
@@ -2147,17 +1999,11 @@ async function handleCreateProjectBrief({ req, res, opts }) {
2147
1999
  throw new HttpError(400, 'summary exceeds 8192-character cap');
2148
2000
  }
2149
2001
  const ctx = await buildContextWithAuth(req, opts);
2150
- try {
2151
- const brief = saveProjectBrief(opts.hippoRoot, ctx.tenantId, {
2152
- repo,
2153
- summary,
2154
- }, ctx.actor.subject);
2155
- sendJson(res, 201, { brief });
2156
- }
2157
- catch (e) {
2158
- // saveProjectBrief throws on validation (single-line repo etc.) -> 400.
2159
- throw new HttpError(400, e instanceof Error ? e.message : String(e));
2160
- }
2002
+ const brief = saveProjectBrief(opts.hippoRoot, ctx.tenantId, {
2003
+ repo,
2004
+ summary,
2005
+ }, ctx.actor.subject);
2006
+ sendJson(res, 201, { brief });
2161
2007
  return;
2162
2008
  }
2163
2009
  async function handleListProjectBriefs({ req, res, opts, query }) {
@@ -2192,29 +2038,13 @@ async function handleRefreshProjectBrief({ req, res, opts }) {
2192
2038
  }
2193
2039
  const dryRun = body['dryRun'] === true;
2194
2040
  const ctx = await buildContextWithAuth(req, opts);
2195
- try {
2196
- if (dryRun) {
2197
- const { markdown, receiptCount } = assembleBriefFromReceipts(opts.hippoRoot, ctx.tenantId, repo);
2198
- sendJson(res, 200, { markdown, receiptCount });
2199
- return;
2200
- }
2201
- const brief = refreshBrief(opts.hippoRoot, ctx.tenantId, repo, ctx.actor.subject);
2202
- sendJson(res, 200, { brief });
2203
- }
2204
- catch (e) {
2205
- // A refresh race (the active brief is closed/superseded between
2206
- // loadActiveBriefForRepo and the supersede CAS) is a state conflict, not a
2207
- // validation error — map it to 409 like the explicit supersede route
2208
- // (codex-review 2026-05-30, P3).
2209
- const msg = e instanceof Error ? e.message : String(e);
2210
- if (msg.includes('not found')) {
2211
- throw new HttpError(404, msg);
2212
- }
2213
- if (msg.includes('not active') || msg.includes('could not be superseded')) {
2214
- throw new HttpError(409, msg);
2215
- }
2216
- throw new HttpError(400, msg);
2041
+ if (dryRun) {
2042
+ const { markdown, receiptCount } = assembleBriefFromReceipts(opts.hippoRoot, ctx.tenantId, repo);
2043
+ sendJson(res, 200, { markdown, receiptCount });
2044
+ return;
2217
2045
  }
2046
+ const brief = refreshBrief(opts.hippoRoot, ctx.tenantId, repo, ctx.actor.subject);
2047
+ sendJson(res, 200, { brief });
2218
2048
  return;
2219
2049
  }
2220
2050
  async function handleSupersedeProjectBrief({ req, res, opts }, briefSupersedeMatch) {
@@ -2243,44 +2073,20 @@ async function handleSupersedeProjectBrief({ req, res, opts }, briefSupersedeMat
2243
2073
  if (!existing) {
2244
2074
  throw new HttpError(404, `project brief ${id} not found`);
2245
2075
  }
2246
- try {
2247
- const brief = saveProjectBrief(opts.hippoRoot, ctx.tenantId, {
2248
- repo: existing.repo,
2249
- summary,
2250
- changeSummary,
2251
- supersedesBriefId: id,
2252
- }, ctx.actor.subject);
2253
- sendJson(res, 200, { brief });
2254
- }
2255
- catch (e) {
2256
- const msg = e instanceof Error ? e.message : String(e);
2257
- if (msg.includes('not found')) {
2258
- throw new HttpError(404, msg);
2259
- }
2260
- if (msg.includes('not active') || msg.includes('could not be superseded')) {
2261
- throw new HttpError(409, msg);
2262
- }
2263
- throw new HttpError(400, msg);
2264
- }
2076
+ const brief = saveProjectBrief(opts.hippoRoot, ctx.tenantId, {
2077
+ repo: existing.repo,
2078
+ summary,
2079
+ changeSummary,
2080
+ supersedesBriefId: id,
2081
+ }, ctx.actor.subject);
2082
+ sendJson(res, 200, { brief });
2265
2083
  return;
2266
2084
  }
2267
2085
  async function handleCloseProjectBrief({ req, res, opts }, briefCloseMatch) {
2268
2086
  const id = parseInt(briefCloseMatch[1], 10);
2269
2087
  const ctx = await buildContextWithAuth(req, opts);
2270
- try {
2271
- const brief = closeProjectBrief(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
2272
- sendJson(res, 200, { brief });
2273
- }
2274
- catch (e) {
2275
- const msg = e instanceof Error ? e.message : String(e);
2276
- if (msg.includes('not found')) {
2277
- throw new HttpError(404, msg);
2278
- }
2279
- if (msg.includes('not active')) {
2280
- throw new HttpError(409, msg);
2281
- }
2282
- throw e;
2283
- }
2088
+ const brief = closeProjectBrief(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
2089
+ sendJson(res, 200, { brief });
2284
2090
  return;
2285
2091
  }
2286
2092
  async function handleGetProjectBrief({ req, res, opts }, briefByIdMatch) {
@@ -2318,17 +2124,11 @@ async function handleCreateCustomerNote({ req, res, opts }) {
2318
2124
  throw new HttpError(400, 'note exceeds 8192-character cap');
2319
2125
  }
2320
2126
  const ctx = await buildContextWithAuth(req, opts);
2321
- try {
2322
- const customerNote = saveCustomerNote(opts.hippoRoot, ctx.tenantId, {
2323
- customer,
2324
- note,
2325
- }, ctx.actor.subject);
2326
- sendJson(res, 201, { note: customerNote });
2327
- }
2328
- catch (e) {
2329
- // saveCustomerNote throws on validation (single-line customer etc.) -> 400.
2330
- throw new HttpError(400, e instanceof Error ? e.message : String(e));
2331
- }
2127
+ const customerNote = saveCustomerNote(opts.hippoRoot, ctx.tenantId, {
2128
+ customer,
2129
+ note,
2130
+ }, ctx.actor.subject);
2131
+ sendJson(res, 201, { note: customerNote });
2332
2132
  return;
2333
2133
  }
2334
2134
  async function handleListCustomerNotes({ req, res, opts, query }) {
@@ -2376,44 +2176,20 @@ async function handleSupersedeCustomerNote({ req, res, opts }, noteSupersedeMatc
2376
2176
  if (!existing) {
2377
2177
  throw new HttpError(404, `customer note ${id} not found`);
2378
2178
  }
2379
- try {
2380
- const customerNote = saveCustomerNote(opts.hippoRoot, ctx.tenantId, {
2381
- customer: existing.customer,
2382
- note,
2383
- changeSummary,
2384
- supersedesNoteId: id,
2385
- }, ctx.actor.subject);
2386
- sendJson(res, 200, { note: customerNote });
2387
- }
2388
- catch (e) {
2389
- const msg = e instanceof Error ? e.message : String(e);
2390
- if (msg.includes('not found')) {
2391
- throw new HttpError(404, msg);
2392
- }
2393
- if (msg.includes('not active') || msg.includes('could not be superseded')) {
2394
- throw new HttpError(409, msg);
2395
- }
2396
- throw new HttpError(400, msg);
2397
- }
2179
+ const customerNote = saveCustomerNote(opts.hippoRoot, ctx.tenantId, {
2180
+ customer: existing.customer,
2181
+ note,
2182
+ changeSummary,
2183
+ supersedesNoteId: id,
2184
+ }, ctx.actor.subject);
2185
+ sendJson(res, 200, { note: customerNote });
2398
2186
  return;
2399
2187
  }
2400
2188
  async function handleCloseCustomerNote({ req, res, opts }, noteCloseMatch) {
2401
2189
  const id = parseInt(noteCloseMatch[1], 10);
2402
2190
  const ctx = await buildContextWithAuth(req, opts);
2403
- try {
2404
- const customerNote = closeCustomerNote(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
2405
- sendJson(res, 200, { note: customerNote });
2406
- }
2407
- catch (e) {
2408
- const msg = e instanceof Error ? e.message : String(e);
2409
- if (msg.includes('not found')) {
2410
- throw new HttpError(404, msg);
2411
- }
2412
- if (msg.includes('not active')) {
2413
- throw new HttpError(409, msg);
2414
- }
2415
- throw e;
2416
- }
2191
+ const customerNote = closeCustomerNote(opts.hippoRoot, ctx.tenantId, id, ctx.actor.subject);
2192
+ sendJson(res, 200, { note: customerNote });
2417
2193
  return;
2418
2194
  }
2419
2195
  async function handleGetCustomerNote({ req, res, opts }, noteByIdMatch) {
@@ -2536,6 +2312,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2536
2312
  version: VERSION,
2537
2313
  started_at: startedAt,
2538
2314
  pid: process.pid,
2315
+ audit_write_failures: auditWriteFailureCount(),
2539
2316
  });
2540
2317
  }
2541
2318
  else {
@@ -2738,7 +2515,9 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
2738
2515
  * webhooks in PUBLIC_ROUTES, which are HMAC-gated by their own signing
2739
2516
  * secrets and 404 when those secrets are unset. But the loopback
2740
2517
  * no-auth fallback inside buildContextWithAuth still admits unauthenticated
2741
- * requests from a loopback remote address, so binding to a non-loopback host
2518
+ * requests from a loopback remote address (unless they carry Forwarded,
2519
+ * X-Forwarded-For/-Host/-Proto, X-Real-IP, Cf-Connecting-Ip or True-Client-Ip, which mark a same-host proxy and get
2520
+ * a 401 like any keyless remote request), so binding to a non-loopback host
2742
2521
  * is only safe once that fallback is disabled with HIPPO_REQUIRE_AUTH=1,
2743
2522
  * which forces every request (loopback or not) through Bearer-token
2744
2523
  * validation. Without that env var set, a non-loopback bind would expose the
@@ -2777,7 +2556,12 @@ export async function serve(opts) {
2777
2556
  ? createRateLimiter({ ratePerSec: v1Rps, burst: v1Rps * 2, idleEvictMs: 60000, maxKeys: 10000 })
2778
2557
  : undefined;
2779
2558
  const server = createServer((req, res) => {
2559
+ const requestId = resolveRequestId(req.headers['x-request-id']);
2560
+ requestIds.set(req, requestId);
2561
+ res.setHeader('X-Request-Id', requestId);
2780
2562
  handleRequest(req, res, opts, startedAt, limiter).catch((err) => {
2563
+ const mapped = mapApiError(err);
2564
+ logRequestFailure(req, err, requestId, mapped.status);
2781
2565
  if (res.headersSent) {
2782
2566
  try {
2783
2567
  res.end();
@@ -2785,33 +2569,20 @@ export async function serve(opts) {
2785
2569
  catch { /* socket already gone */ }
2786
2570
  return;
2787
2571
  }
2788
- if (err instanceof BodyTooLargeError) {
2789
- sendError(res, 413, err.message);
2790
- // M3: readBody hit the 1 MB cap mid-stream, so the request body is
2791
- // only partially consumed. Destroy the socket rather than let the
2792
- // client's remaining (unbounded) bytes drain into an exchange we have
2793
- // already answered.
2794
- req.destroy();
2795
- return;
2796
- }
2797
- if (err instanceof HttpError) {
2798
- sendError(res, err.status, err.message);
2572
+ if (mapped.status === 500) {
2573
+ // The id lets an operator find the logged cause without the client seeing internal text.
2574
+ sendJson(res, 500, { error: mapped.message, requestId });
2799
2575
  return;
2800
2576
  }
2801
- // F5 (v1.6.5) + v1.7.0 api-contract review: RecallContractError lands
2802
- // at 400 with {error: <message>, code: <code>}. The `error` field
2803
- // matches `sendError`'s shape (human message, used by HttpError /
2804
- // BodyTooLargeError / mapApiError). The `code` field is the typed
2805
- // discriminator — clients can branch on `body.code` without parsing
2806
- // prose. Earlier draft used {error: code, message: text} but that
2807
- // diverged from the rest of v1/* and forced clients to special-case
2808
- // the error path.
2577
+ // RecallContractError keeps the shared {error} shape and adds `code` so clients branch without parsing prose.
2809
2578
  if (err instanceof RecallContractError) {
2810
2579
  sendJson(res, 400, { error: err.message, code: err.code });
2811
2580
  return;
2812
2581
  }
2813
- const mapped = mapApiError(err);
2814
2582
  sendError(res, mapped.status, mapped.message);
2583
+ // M3: readBody hit the 1 MB cap mid-stream, so drop the socket rather than drain unbounded bytes.
2584
+ if (err instanceof BodyTooLargeError)
2585
+ req.destroy();
2815
2586
  });
2816
2587
  });
2817
2588
  // T3b capture (v1.26.2): tests/server-concurrency.test.ts's ECONNRESET flake