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/README.md CHANGED
@@ -559,6 +559,17 @@ are not recorded yet. It holds ids, hashes, counts and reasons only, never promp
559
559
  text; the prompt hash is unsalted, so a very short prompt can be guessed. A ledger failure prints one stderr line and never changes what the hook prints. Rows
560
560
  older than 90 days are pruned; at a heavy 300 prompts a day that is about 190 MB per store.
561
561
 
562
+ **Run a pilot with a holdout group.** Set `{"pilot":{"holdoutRateBp":2000}}` in `.hippo/config.json`
563
+ to hold back memories from about 20% of sessions. The rate is in basis points, 0 to 10000, and 0
564
+ is off (the default). The setting is read from the store the token ledger writes to. A session
565
+ lands in its arm by a hash of its id, and the first hook call writes one row to the token ledger.
566
+ A holdout session gets no memories from the per-prompt hook, the SessionStart hook or compact-resume.
567
+ The agent's own `hippo context` pull is gated in Claude Code only, so a Codex holdout session still
568
+ gets memories from it. Capture still runs. `hippo recall`, the HTTP API and the MCP tools are not gated
569
+ and write no arm row. Agents are told to call the MCP context tool at session start, and those calls
570
+ are not recorded. Set the rate to 0 only after the pilot window closes, because 0 ends every holdout at once.
571
+ `hippo doctor` shows the pilot when it is on.
572
+
562
573
  ---
563
574
 
564
575
  ### Outcome feedback
@@ -2,7 +2,7 @@
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { realpathOrResolve } from '../project-identity.js';
5
- import { isStringValue } from '../capture.js';
5
+ import { isStringValue } from '../capture-contract.js';
6
6
  import { isJsonObject } from '../hooks.js';
7
7
  import { expandHome, frontmatterField, itemTime, readTextFile, splitFrontmatter } from './files.js';
8
8
  import { markdownNotes, readFolderStore, uniqueFolders } from './folder-store.js';
@@ -1,7 +1,7 @@
1
1
  // Gemini CLI: the "Gemini Added Memories" section of GEMINI.md, and the auto-memory folder that projects.json names.
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import { isStringValue } from '../capture.js';
4
+ import { isStringValue } from '../capture-contract.js';
5
5
  import { isJsonObject } from '../hooks.js';
6
6
  import { readTextFile, splitFrontmatter } from './files.js';
7
7
  import { markdownNotes, readFolderStore } from './folder-store.js';
@@ -0,0 +1,27 @@
1
+ /** Errors whose HTTP status is part of the API contract; the server maps by class, so message text can change freely. */
2
+ export type ApiErrorStatus = 400 | 403 | 404 | 409;
3
+ /** Base class: an error the caller caused, carrying the status the HTTP layer answers with. */
4
+ export declare abstract class ApiError extends Error {
5
+ abstract readonly status: ApiErrorStatus;
6
+ }
7
+ /** The request cannot be applied as given: bad input, or a state this route reports as 400. */
8
+ export declare class BadRequestError extends ApiError {
9
+ readonly status = 400;
10
+ constructor(message: string);
11
+ }
12
+ /** The actor's role or identity does not allow the operation. */
13
+ export declare class ForbiddenError extends ApiError {
14
+ readonly status = 403;
15
+ constructor(message: string);
16
+ }
17
+ /** The named row does not exist for this tenant. */
18
+ export declare class NotFoundError extends ApiError {
19
+ readonly status = 404;
20
+ constructor(message: string);
21
+ }
22
+ /** The row exists but its current state forbids the change (already superseded, closed, or decided). */
23
+ export declare class ConflictError extends ApiError {
24
+ readonly status = 409;
25
+ constructor(message: string);
26
+ }
27
+ //# sourceMappingURL=api-errors.d.ts.map
@@ -0,0 +1,37 @@
1
+ /** Errors whose HTTP status is part of the API contract; the server maps by class, so message text can change freely. */
2
+ /** Base class: an error the caller caused, carrying the status the HTTP layer answers with. */
3
+ export class ApiError extends Error {
4
+ }
5
+ /** The request cannot be applied as given: bad input, or a state this route reports as 400. */
6
+ export class BadRequestError extends ApiError {
7
+ status = 400;
8
+ constructor(message) {
9
+ super(message);
10
+ this.name = 'BadRequestError';
11
+ }
12
+ }
13
+ /** The actor's role or identity does not allow the operation. */
14
+ export class ForbiddenError extends ApiError {
15
+ status = 403;
16
+ constructor(message) {
17
+ super(message);
18
+ this.name = 'ForbiddenError';
19
+ }
20
+ }
21
+ /** The named row does not exist for this tenant. */
22
+ export class NotFoundError extends ApiError {
23
+ status = 404;
24
+ constructor(message) {
25
+ super(message);
26
+ this.name = 'NotFoundError';
27
+ }
28
+ }
29
+ /** The row exists but its current state forbids the change (already superseded, closed, or decided). */
30
+ export class ConflictError extends ApiError {
31
+ status = 409;
32
+ constructor(message) {
33
+ super(message);
34
+ this.name = 'ConflictError';
35
+ }
36
+ }
37
+ //# sourceMappingURL=api-errors.js.map
package/dist/api.d.ts CHANGED
@@ -7,6 +7,8 @@
7
7
  * in exactly one place.
8
8
  */
9
9
  import { type DatabaseSyncLike } from './db.js';
10
+ import { BadRequestError } from './api-errors.js';
11
+ export { ApiError, BadRequestError, ConflictError, ForbiddenError, NotFoundError } from './api-errors.js';
10
12
  import { deleteEntry, loadAllEntries, type TaskSnapshot, type SessionEvent } from './store.js';
11
13
  import { type RejectedValueRow } from './rejection.js';
12
14
  import { type DormantMemory, type ListDormantOpts } from './dormant.js';
@@ -77,14 +79,10 @@ export declare function adminActor(subject: string): Actor;
77
79
  * full-store fallback (codex v1.7.0 diff-pass P1). Validated upfront
78
80
  * so the contract holds.
79
81
  */
80
- export declare class RecallContractError extends Error {
82
+ export declare class RecallContractError extends BadRequestError {
81
83
  readonly code: 'fresh_tail_requires_session_id' | 'invalid_scorer_window';
82
84
  constructor(code: 'fresh_tail_requires_session_id' | 'invalid_scorer_window', message: string);
83
85
  }
84
- /** The actor's role or identity does not allow the operation. HTTP maps it to 403. */
85
- export declare class ForbiddenError extends Error {
86
- constructor(message: string);
87
- }
88
86
  import { isPrivateScope, passesScopeFilterForRecall } from './recall-scope.js';
89
87
  export { isPrivateScope, passesScopeFilterForRecall };
90
88
  export { passesCliRecallScopeFilter, ScopeForbiddenError } from './recall-scope.js';
@@ -125,6 +123,8 @@ export interface RememberResult {
125
123
  quarantined?: {
126
124
  reason: string;
127
125
  };
126
+ /** Set only when the content held secret material: untrusted text had it redacted, typed text was stored as sent. */
127
+ warnings?: string[];
128
128
  }
129
129
  export declare function remember(ctx: Context, opts: RememberOpts): RememberResult;
130
130
  export interface RecallOpts {
package/dist/api.js CHANGED
@@ -7,6 +7,8 @@
7
7
  * in exactly one place.
8
8
  */
9
9
  import { openHippoDb, closeHippoDb } from './db.js';
10
+ import { BadRequestError, ConflictError, ForbiddenError, NotFoundError } from './api-errors.js';
11
+ export { ApiError, BadRequestError, ConflictError, ForbiddenError, NotFoundError } from './api-errors.js';
10
12
  import { writeEntry, writeEntryDbOnly, strengthenRetrieved, stampOriginProject, writeEntryMirrors, readEntry, deleteEntry, loadRecallSearchEntries, loadEntriesByIds, loadChildrenOf, loadFreshRawMemories, loadSessionRawMemories, countSessionRawMemories, DEFAULT_SEARCH_CANDIDATE_LIMIT, removeEntryMirrors, loadActiveTaskSnapshot, loadFreshActiveTaskSnapshot, loadLatestHandoff, listSessionEvents, SNAPSHOT_AMBIENT_MAX_AGE_MS, loadIndex, saveIndex, loadAllEntries, loadAmbientCandidates, updateStats, isInitialized, markSummaryDirtyInTx, auditRejectionRefusal, memoriesBackingObjects, } from './store.js';
11
13
  import { RejectedValueError } from './rejection.js';
12
14
  import { rejectValue, unrejectValue, listRejectionsForTenant } from './reject-flow.js';
@@ -17,7 +19,7 @@ import { quarantineScopeFor, recordQuarantine, getQuarantineRow, listQuarantineR
17
19
  import { summarizeFailures } from './failure-log.js';
18
20
  import { formatHandoffEvidenceLine } from './handoff.js';
19
21
  import { createMemory, createSuccessor, applyOutcome, calculateStrength, markRetrieved, CHURN_STALE_TAG, COMPACTION_MEMORY_TAG, } from './memory.js';
20
- import { appendAuditEvent, auditQueryFields, queryAuditEvents, auditMemories, isContentWorthStoring, } from './audit.js';
22
+ import { appendAuditEvent, reportAuditWriteFailure, auditQueryFields, queryAuditEvents, auditMemories, isContentWorthStoring, } from './audit.js';
21
23
  import { promoteToGlobal, getGlobalRoot, autoShare, searchBothHybrid } from './shared.js';
22
24
  import { writeRecallTrace, writeRecallTraceAtRoot, recordTraceOutcome } from './recall-trace.js';
23
25
  import { evalNow } from './ablation.js';
@@ -32,7 +34,7 @@ import { consolidate } from './consolidate.js';
32
34
  import { loadConfig } from './config.js';
33
35
  import { resolveProjectIdentity, classifyOriginProject, isGlobalStoreRoot } from './project-identity.js';
34
36
  import { promptTokens, contentTokens, gatePromptRecall, } from './prompt-recall.js';
35
- import { detectSecret } from './secret-detect.js';
37
+ import { detectSecret, vetSecrets } from './secret-detect.js';
36
38
  import { isSessionDigestRow } from './session-digest.js';
37
39
  import { deduplicateStore } from './dedupe.js';
38
40
  import { computeAmbientState } from './ambient.js';
@@ -68,7 +70,7 @@ export function adminActor(subject) {
68
70
  * full-store fallback (codex v1.7.0 diff-pass P1). Validated upfront
69
71
  * so the contract holds.
70
72
  */
71
- export class RecallContractError extends Error {
73
+ export class RecallContractError extends BadRequestError {
72
74
  code;
73
75
  constructor(code, message) {
74
76
  super(message);
@@ -76,13 +78,6 @@ export class RecallContractError extends Error {
76
78
  this.code = code;
77
79
  }
78
80
  }
79
- /** The actor's role or identity does not allow the operation. HTTP maps it to 403. */
80
- export class ForbiddenError extends Error {
81
- constructor(message) {
82
- super(message);
83
- this.name = 'ForbiddenError';
84
- }
85
- }
86
81
  // v1.25.0: the recall-side scope predicates (PRIVATE_SCOPE_RE, isPrivateScope,
87
82
  // passesScopeFilterForRecall) live in recall-scope.ts (leaf) so shared.ts can
88
83
  // apply the same default-deny rule to searchBothHybrid's internal loads
@@ -170,9 +165,10 @@ export function oneCopyPerMemory(local, global, now) {
170
165
  return [local.filter((e) => kept.has(e)), global.filter((e) => kept.has(e))];
171
166
  }
172
167
  export function remember(ctx, opts) {
173
- const detection = opts.untrusted ? detectInstruction(opts.content) : { flagged: false, reason: null };
168
+ const vetted = vetSecrets(opts.content, opts.tags ?? [], opts.untrusted === true);
169
+ const detection = opts.untrusted ? detectInstruction(vetted.content) : { flagged: false, reason: null };
174
170
  const requestedScope = opts.scope ?? null;
175
- const entry = createMemory(opts.content, {
171
+ const entry = createMemory(vetted.content, {
176
172
  kind: opts.kind ?? 'distilled',
177
173
  scope: detection.flagged ? quarantineScopeFor(requestedScope) : requestedScope,
178
174
  owner: opts.owner ?? null,
@@ -199,6 +195,8 @@ export function remember(ctx, opts) {
199
195
  const result = { id: entry.id, kind: entry.kind, tenantId: ctx.tenantId };
200
196
  if (detection.flagged)
201
197
  result.quarantined = { reason: detection.reason ?? 'unknown' };
198
+ if (vetted.warnings.length > 0)
199
+ result.warnings = vetted.warnings;
202
200
  return result;
203
201
  }
204
202
  /**
@@ -1107,7 +1105,7 @@ export function forget(ctx, id) {
1107
1105
  .prepare(`SELECT tenant_id FROM memories WHERE id = ?`)
1108
1106
  .get(id);
1109
1107
  if (!row || row.tenant_id !== ctx.tenantId) {
1110
- throw new Error(`memory not found: ${id}`);
1108
+ throw new NotFoundError(`memory not found: ${id}`);
1111
1109
  }
1112
1110
  }
1113
1111
  finally {
@@ -1115,7 +1113,7 @@ export function forget(ctx, id) {
1115
1113
  }
1116
1114
  const removed = deleteEntry(ctx.hippoRoot, id, { actor: ctx.actor.subject });
1117
1115
  if (!removed) {
1118
- throw new Error(`memory not found: ${id}`);
1116
+ throw new NotFoundError(`memory not found: ${id}`);
1119
1117
  }
1120
1118
  // Counted here, not in the CLI: both callers of this function (cmdForget and
1121
1119
  // the HTTP route) are the two paths of one user command, so neither can miss
@@ -1150,7 +1148,7 @@ export function reject(ctx, opts) {
1150
1148
  .prepare(`SELECT tenant_id FROM memories WHERE id = ?`)
1151
1149
  .get(opts.memoryId);
1152
1150
  if (!row || row.tenant_id !== ctx.tenantId) {
1153
- throw new Error(`memory not found: ${opts.memoryId}`);
1151
+ throw new NotFoundError(`memory not found: ${opts.memoryId}`);
1154
1152
  }
1155
1153
  }
1156
1154
  finally {
@@ -1176,10 +1174,10 @@ export function reject(ctx, opts) {
1176
1174
  export function unreject(ctx, digestOrPrefix) {
1177
1175
  const outcome = unrejectValue(ctx.hippoRoot, ctx.tenantId, digestOrPrefix, ctx.actor.subject);
1178
1176
  if (outcome.status === 'not_found') {
1179
- throw new Error(`no rejected value matches: ${digestOrPrefix}`);
1177
+ throw new NotFoundError(`no rejected value matches: ${digestOrPrefix}`);
1180
1178
  }
1181
1179
  if (outcome.status === 'ambiguous') {
1182
- throw new Error(`"${digestOrPrefix}" matches ${outcome.candidates.length} tombstones; use a longer prefix`);
1180
+ throw new BadRequestError(`"${digestOrPrefix}" matches ${outcome.candidates.length} tombstones; use a longer prefix`);
1183
1181
  }
1184
1182
  return { ok: true, digest: outcome.digest };
1185
1183
  }
@@ -1202,7 +1200,7 @@ export function promote(ctx, id) {
1202
1200
  .prepare(`SELECT tenant_id FROM memories WHERE id = ?`)
1203
1201
  .get(id);
1204
1202
  if (!row || row.tenant_id !== ctx.tenantId) {
1205
- throw new Error(`memory not found: ${id}`);
1203
+ throw new NotFoundError(`memory not found: ${id}`);
1206
1204
  }
1207
1205
  }
1208
1206
  finally {
@@ -1234,13 +1232,13 @@ export function supersede(ctx, oldId, newContent) {
1234
1232
  // info leak.
1235
1233
  const old = readEntry(ctx.hippoRoot, oldId, ctx.tenantId);
1236
1234
  if (!old) {
1237
- throw new Error(`Memory not found: ${oldId}`);
1235
+ throw new NotFoundError(`Memory not found: ${oldId}`);
1238
1236
  }
1239
1237
  // Guard: not already superseded. The CAS UPDATE below race-safely closes
1240
1238
  // the window between this read and the write; this check just produces a
1241
1239
  // clearer error in the common single-writer case.
1242
1240
  if (old.superseded_by) {
1243
- throw new Error(`Memory ${oldId} is already superseded by ${old.superseded_by}. Supersede that one instead.`);
1241
+ throw new ConflictError(`Memory ${oldId} is already superseded by ${old.superseded_by}. Supersede that one instead.`);
1244
1242
  }
1245
1243
  const newEntry = createSuccessor(old, newContent, {
1246
1244
  tenantId: ctx.tenantId,
@@ -1268,7 +1266,7 @@ export function supersede(ctx, oldId, newContent) {
1268
1266
  `).run(newEntry.id, oldId, ctx.tenantId);
1269
1267
  if ((result.changes ?? 0) === 0) {
1270
1268
  db.exec('ROLLBACK');
1271
- throw new Error(`Memory ${oldId} already superseded by another writer`);
1269
+ throw new ConflictError(`Memory ${oldId} already superseded by another writer`);
1272
1270
  }
1273
1271
  // v0.30 / E2 — DAG live-coupling: OLD entry just transitioned to
1274
1272
  // superseded. Its parent (if any) needs rebuild. Lands strictly
@@ -1339,7 +1337,7 @@ export function archiveRaw(ctx, id, reason, opts = {}) {
1339
1337
  .prepare(`SELECT tenant_id FROM memories WHERE id = ?`)
1340
1338
  .get(id);
1341
1339
  if (!row || row.tenant_id !== ctx.tenantId) {
1342
- throw new Error(`memory not found: ${id}`);
1340
+ throw new NotFoundError(`memory not found: ${id}`);
1343
1341
  }
1344
1342
  archiveRawMemory(db, id, {
1345
1343
  reason,
@@ -1418,8 +1416,9 @@ export function authCreate(ctx, opts) {
1418
1416
  },
1419
1417
  });
1420
1418
  }
1421
- catch {
1419
+ catch (error) {
1422
1420
  // Audit must not crash a successful mint.
1421
+ reportAuditWriteFailure('auth_create', String(error), result.keyId);
1423
1422
  }
1424
1423
  return { keyId: result.keyId, plaintext: result.plaintext, tenantId: ctx.tenantId, role };
1425
1424
  }
@@ -1457,11 +1456,11 @@ export function authRevoke(ctx, keyId) {
1457
1456
  .prepare(`SELECT key_id, tenant_id, revoked_at, role FROM api_keys WHERE key_id = ?`)
1458
1457
  .get(keyId);
1459
1458
  if (!row) {
1460
- throw new Error(`Unknown key_id: ${keyId}`);
1459
+ throw new NotFoundError(`Unknown key_id: ${keyId}`);
1461
1460
  }
1462
1461
  // Cross-tenant access denied: same message as missing key, no info leak.
1463
1462
  if (row.tenant_id !== ctx.tenantId) {
1464
- throw new Error(`Unknown key_id: ${keyId}`);
1463
+ throw new NotFoundError(`Unknown key_id: ${keyId}`);
1465
1464
  }
1466
1465
  if (ctx.actor.viaAuthResolver && row.role === 'admin') {
1467
1466
  throw new ForbiddenError('An auth resolver admin cannot revoke an admin key, which outranks it');
@@ -1490,8 +1489,9 @@ export function authRevoke(ctx, keyId) {
1490
1489
  targetId: keyId,
1491
1490
  });
1492
1491
  }
1493
- catch {
1492
+ catch (error) {
1494
1493
  // Audit must not crash a successful revoke.
1494
+ reportAuditWriteFailure('auth_revoke', String(error), keyId);
1495
1495
  }
1496
1496
  }
1497
1497
  return { ok: true, revokedAt };
@@ -1519,13 +1519,13 @@ function changeScopeGrant(ctx, keyId, scope, op) {
1519
1519
  .prepare(`SELECT tenant_id, revoked_at FROM api_keys WHERE key_id = ?`)
1520
1520
  .get(keyId);
1521
1521
  if (!row || row.tenant_id !== ctx.tenantId) {
1522
- throw new Error(`Unknown key_id: ${keyId}`);
1522
+ throw new NotFoundError(`Unknown key_id: ${keyId}`);
1523
1523
  }
1524
1524
  if (op === 'auth_grant' && row.revoked_at) {
1525
- throw new Error(`${keyId} is revoked; a grant on it would never apply`);
1525
+ throw new ConflictError(`${keyId} is revoked; a grant on it would never apply`);
1526
1526
  }
1527
1527
  if (!isRestrictedScope(scope)) {
1528
- throw new Error(`${scope} is not a restricted scope; it is already readable by default`);
1528
+ throw new BadRequestError(`${scope} is not a restricted scope; it is already readable by default`);
1529
1529
  }
1530
1530
  if (op === 'auth_grant')
1531
1531
  grantScope(db, keyId, scope);
@@ -1536,7 +1536,7 @@ function changeScopeGrant(ctx, keyId, scope, op) {
1536
1536
  }
1537
1537
  catch (err) {
1538
1538
  // Audit must not undo a grant change that already committed; surface it instead.
1539
- console.error(`auth: audit write failed for ${op} ${keyId}: ${err instanceof Error ? err.message : String(err)}`);
1539
+ reportAuditWriteFailure(op, String(err), keyId);
1540
1540
  }
1541
1541
  return { ok: true };
1542
1542
  }
@@ -2231,10 +2231,10 @@ export function restoreDormant(ctx, id) {
2231
2231
  try {
2232
2232
  const dormant = readDormantSnapshot(db, ctx.tenantId, id);
2233
2233
  if (!dormant) {
2234
- throw new Error(`dormant memory not found: ${id}`);
2234
+ throw new NotFoundError(`dormant memory not found: ${id}`);
2235
2235
  }
2236
2236
  if (db.prepare(`SELECT 1 FROM memories WHERE id = ?`).get(id) !== undefined) {
2237
- throw new Error(`memory ${id} is already active; forget it before restoring its dormant copy`);
2237
+ throw new ConflictError(`memory ${id} is already active; forget it before restoring its dormant copy`);
2238
2238
  }
2239
2239
  const now = new Date();
2240
2240
  // Dormant rows are long-lived, so a snapshot can predate a field added
@@ -2293,7 +2293,7 @@ export function forgetDormant(ctx, id) {
2293
2293
  const db = openHippoDb(ctx.hippoRoot);
2294
2294
  try {
2295
2295
  if (!deleteDormantRow(db, ctx.tenantId, id)) {
2296
- throw new Error(`dormant memory not found: ${id}`);
2296
+ throw new NotFoundError(`dormant memory not found: ${id}`);
2297
2297
  }
2298
2298
  try {
2299
2299
  appendAuditEvent(db, {
@@ -2304,8 +2304,9 @@ export function forgetDormant(ctx, id) {
2304
2304
  metadata: { dormant: true },
2305
2305
  });
2306
2306
  }
2307
- catch {
2307
+ catch (error) {
2308
2308
  // Best-effort, like every other forget audit row: the delete stands.
2309
+ reportAuditWriteFailure('forget', String(error), id);
2309
2310
  }
2310
2311
  }
2311
2312
  finally {
@@ -2351,9 +2352,9 @@ export function quarantineList(ctx, opts = {}) {
2351
2352
  function loadPendingQuarantineRow(db, tenantId, id) {
2352
2353
  const row = getQuarantineRow(db, tenantId, id);
2353
2354
  if (!row)
2354
- throw new Error(`not quarantined: ${id}`);
2355
+ throw new NotFoundError(`not quarantined: ${id}`);
2355
2356
  if (row.status !== 'pending')
2356
- throw new Error(`${id} is already ${row.status}`);
2357
+ throw new ConflictError(`${id} is already ${row.status}`);
2357
2358
  return row;
2358
2359
  }
2359
2360
  /** Release a quarantined memory to its original scope. Admin only; the scope guard refuses a row moved since (mirrors restoreDormant). */
@@ -2371,7 +2372,7 @@ export function quarantineApprove(ctx, id) {
2371
2372
  .prepare(`UPDATE memories SET scope = ? WHERE id = ? AND tenant_id = ? AND scope = ?`)
2372
2373
  .run(row.originalScope, id, ctx.tenantId, quarantineScope);
2373
2374
  if (Number(updated.changes ?? 0) !== 1) {
2374
- throw new Error(`memory ${id} scope changed since quarantine; refusing to approve`);
2375
+ throw new ConflictError(`memory ${id} scope changed since quarantine; refusing to approve`);
2375
2376
  }
2376
2377
  approveQuarantineRow(db, ctx.tenantId, id, ctx.actor.subject);
2377
2378
  appendAuditEvent(db, {
@@ -2688,16 +2689,8 @@ export async function sleep(ctx, opts = {}) {
2688
2689
  }
2689
2690
  }
2690
2691
  catch (auditErr) {
2691
- // Audit emit failure must NOT mask the original phaseError. Log to
2692
- // stderr so the secondary failure is observable but does not throw.
2693
- // This guards the case where consolidation AND audit-emit fail in the
2694
- // same invocation against the same DB (correlated: same disk, same
2695
- // schema state) — losing the original error makes diagnosis much harder.
2696
- // SAFETY: this is a best-effort log message only; property access on
2697
- // any JS value is safe (undefined if absent), preserving the existing
2698
- // lenient formatting even when something non-Error was thrown.
2699
- // eslint-disable-next-line no-console
2700
- console.error(`[hippo] api.sleep audit emit failed: ${auditErr.message}`);
2692
+ // Logged, never thrown: a second failure must not mask the original phaseError.
2693
+ reportAuditWriteFailure('consolidate', String(auditErr));
2701
2694
  }
2702
2695
  }
2703
2696
  }
package/dist/audit.d.ts CHANGED
@@ -33,6 +33,10 @@ export type AuditQueryFields = {
33
33
  };
34
34
  export declare function auditQueryFields(query: string): AuditQueryFields;
35
35
  export declare function appendAuditEvent(db: DatabaseSyncLike, opts: AppendAuditOpts): void;
36
+ /** For callers that keep a mutation when its audit row fails: the failure is logged and counted, never silent. */
37
+ export declare function reportAuditWriteFailure(op: AuditOp, reason: string, targetId?: string | null): void;
38
+ /** Audit rows this process failed to write; the loopback `/health` body reports it. */
39
+ export declare function auditWriteFailureCount(): number;
36
40
  export interface QueryAuditOpts {
37
41
  tenantId: string;
38
42
  op?: AuditOp;
package/dist/audit.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { canAutoDelete } from './memory.js';
3
+ import { log } from './log.js';
3
4
  export const STOP_WORDS = new Set([
4
5
  'the', 'a', 'an', 'is', 'was', 'are', 'were', 'be', 'been', 'being',
5
6
  'to', 'of', 'in', 'for', 'on', 'with', 'at', 'by', 'from', 'it',
@@ -250,6 +251,16 @@ export function auditQueryFields(query) {
250
251
  export function appendAuditEvent(db, opts) {
251
252
  db.prepare(`INSERT INTO audit_log (ts, tenant_id, actor, op, target_id, metadata_json) VALUES (?, ?, ?, ?, ?, ?)`).run(new Date().toISOString(), opts.tenantId, opts.actor, opts.op, opts.targetId ?? null, JSON.stringify(opts.metadata ?? {}, bigintSafeReplacer));
252
253
  }
254
+ let auditWriteFailures = 0;
255
+ /** For callers that keep a mutation when its audit row fails: the failure is logged and counted, never silent. */
256
+ export function reportAuditWriteFailure(op, reason, targetId) {
257
+ auditWriteFailures++;
258
+ log.error(`audit write failed: ${reason}`, { op, target: targetId ?? undefined });
259
+ }
260
+ /** Audit rows this process failed to write; the loopback `/health` body reports it. */
261
+ export function auditWriteFailureCount() {
262
+ return auditWriteFailures;
263
+ }
253
264
  export function queryAuditEvents(db, opts) {
254
265
  const where = ['tenant_id = ?'];
255
266
  const params = [opts.tenantId];
@@ -20,7 +20,7 @@ export declare function extractLessons(gitLog: string, customPatterns?: string[]
20
20
  * steps: this function is the write-path gate, called by each caller that
21
21
  * actually stores a lesson, not by the parser itself.
22
22
  *
23
- * Order is preserved in both output arrays.
23
+ * Order is preserved in both output arrays; both hold lessons with secret shapes redacted.
24
24
  */
25
25
  export declare function partitionLessons(lessons: string[]): {
26
26
  kept: string[];
package/dist/autolearn.js CHANGED
@@ -7,14 +7,16 @@ import { createMemory, Layer, DEFAULT_HALF_LIFE_DAYS } from './memory.js';
7
7
  import { loadAllEntries } from './store.js';
8
8
  import { textOverlap } from './search.js';
9
9
  import { isContentWorthStoring } from './audit.js';
10
+ import { redactSecretsStrict } from './secret-detect.js';
10
11
  /** A memory of a failed command, "Command '<cmd>' failed: <truncated stderr>"; no store is in reach, so `hippo watch` re-derives its half-life from the store's config. */
11
12
  export function captureError(exitCode, stderr, command, tenantId) {
12
13
  // Truncate to first 500 chars to avoid storing megabytes of build logs
13
- const wasTruncated = stderr.length > 500;
14
- const truncated = stderr.slice(0, 500).trim();
14
+ const clean = redactSecretsStrict(stderr);
15
+ const wasTruncated = clean.length > 500;
16
+ const truncated = clean.slice(0, 500).trim();
15
17
  const suffix = wasTruncated ? ' [truncated]' : '';
16
18
  // Strip leading env var assignments (KEY=val or key=val) before the actual command name
17
- const safeCmd = command.replace(/^([A-Za-z_][A-Za-z0-9_]*=\S+\s+)+/, '').trim() || '(redacted)';
19
+ const safeCmd = redactSecretsStrict(command.replace(/^([A-Za-z_][A-Za-z0-9_]*=\S+\s+)+/, '').trim()) || '(redacted)';
18
20
  const content = `Command '${safeCmd}' failed (exit ${exitCode}): ${truncated}${suffix}`;
19
21
  // Derive a sanitized tag from the command name (first word, strip path)
20
22
  const cmdBase = safeCmd.split(/\s+/)[0].replace(/[^a-zA-Z0-9-]/g, '');
@@ -78,12 +80,12 @@ export function extractLessons(gitLog, customPatterns) {
78
80
  * steps: this function is the write-path gate, called by each caller that
79
81
  * actually stores a lesson, not by the parser itself.
80
82
  *
81
- * Order is preserved in both output arrays.
83
+ * Order is preserved in both output arrays; both hold lessons with secret shapes redacted.
82
84
  */
83
85
  export function partitionLessons(lessons) {
84
86
  const kept = [];
85
87
  const dropped = [];
86
- for (const lesson of lessons) {
88
+ for (const lesson of lessons.map((l) => redactSecretsStrict(l))) {
87
89
  if (isContentWorthStoring(lesson)) {
88
90
  kept.push(lesson);
89
91
  }
@@ -0,0 +1,47 @@
1
+ /** A host lifecycle moment hippo can capture from. */
2
+ export type CaptureEvent = 'prompt' | 'tool-failure' | 'pre-compact' | 'post-compact' | 'session-start' | 'session-end' | 'reset';
3
+ /** What hippo read from one host payload, before anything is written. */
4
+ export interface CaptureInput {
5
+ readonly runtime: string;
6
+ readonly event: CaptureEvent;
7
+ /** True when no host payload arrived: a person ran the verb by hand. */
8
+ readonly manual: boolean;
9
+ readonly sessionId: string | null;
10
+ readonly cwd: string | null;
11
+ readonly transcriptPath: string | null;
12
+ /** The host's own reason for the event, such as `auto` or `manual` compaction. */
13
+ readonly trigger: string | null;
14
+ }
15
+ /** The outcome of reading a payload: usable input, a payload hippo refuses, or no payload at all. */
16
+ export type CaptureReceipt = {
17
+ readonly status: 'received';
18
+ readonly input: CaptureInput;
19
+ } | {
20
+ readonly status: 'skipped' | 'unavailable';
21
+ readonly reason: string;
22
+ };
23
+ /** Working state saved before the host drops context, so the session can resume; it is not a lesson. */
24
+ export interface Checkpoint {
25
+ readonly runtime: string;
26
+ readonly sessionId: string | null;
27
+ readonly task: string;
28
+ readonly summary: string;
29
+ readonly nextStep: string;
30
+ readonly savedAt: string;
31
+ }
32
+ /** How far capture has read one session's source, so a retry resumes instead of re-reading or skipping. */
33
+ export interface ProgressCursor {
34
+ readonly runtime: string;
35
+ readonly sessionId: string;
36
+ readonly source: string;
37
+ /** Opaque position in the source, such as a byte offset or the last turn id read. */
38
+ readonly position: string;
39
+ readonly updatedAt: string;
40
+ }
41
+ /** A string guard that avoids `typeof`; it matches it for every value `JSON.parse` can produce. */
42
+ export declare function isStringValue<T>(value: T): value is T & string;
43
+ /** An object guard that avoids `typeof`; arrays count as objects, as they do for `typeof`. */
44
+ export declare function isObjectLike<T>(value: T): value is T & object;
45
+ /** Reads a Claude Code PreCompact payload; only an empty stdin counts as a manual run. */
46
+ export declare function readClaudeCodePreCompact(stdinText: string | undefined, timedOut: boolean): CaptureReceipt;
47
+ //# sourceMappingURL=capture-contract.d.ts.map
@@ -0,0 +1,49 @@
1
+ // The shared shape every agent adapter turns a host payload into, so capture code is written once for all runtimes.
2
+ // Field meanings, statuses and how to add a runtime: docs/integrations/agent-inventory.md.
3
+ /** A string guard that avoids `typeof`; it matches it for every value `JSON.parse` can produce. */
4
+ export function isStringValue(value) {
5
+ return String(value) === value;
6
+ }
7
+ /** An object guard that avoids `typeof`; arrays count as objects, as they do for `typeof`. */
8
+ export function isObjectLike(value) {
9
+ return value !== null && value instanceof Object;
10
+ }
11
+ /** Reads a Claude Code PreCompact payload; only an empty stdin counts as a manual run. */
12
+ export function readClaudeCodePreCompact(stdinText, timedOut) {
13
+ const empty = !stdinText || stdinText.trim() === '';
14
+ if (timedOut && empty) {
15
+ return { status: 'unavailable', reason: 'no PreCompact payload arrived before the stdin wait window closed' };
16
+ }
17
+ const base = { runtime: 'claude-code', event: 'pre-compact' };
18
+ if (empty) {
19
+ return { status: 'received', input: { ...base, manual: true, sessionId: null, cwd: null, transcriptPath: null, trigger: null } };
20
+ }
21
+ let payload;
22
+ try {
23
+ payload = JSON.parse(stdinText.trim());
24
+ }
25
+ catch {
26
+ // Non-JSON stdin leaves payload undefined, which the shape check rejects like an explicit null.
27
+ }
28
+ // A bad payload is skipped, never treated as manual: discovery could pick up another session's transcript.
29
+ if (!isObjectLike(payload) || !('transcript_path' in payload) || !isStringValue(payload.transcript_path)) {
30
+ return { status: 'skipped', reason: 'malformed or incomplete PreCompact payload (missing string transcript_path)' };
31
+ }
32
+ const transcriptPath = payload.transcript_path;
33
+ // Path shape only: CLAUDE_CONFIG_DIR can move the transcript root, and a same-user process can already read every transcript.
34
+ if (!/\.jsonl$/i.test(transcriptPath)) {
35
+ return { status: 'skipped', reason: `payload transcript_path is not a .jsonl file: ${transcriptPath}` };
36
+ }
37
+ return {
38
+ status: 'received',
39
+ input: {
40
+ ...base,
41
+ manual: false,
42
+ sessionId: 'session_id' in payload && isStringValue(payload.session_id) ? payload.session_id : null,
43
+ cwd: 'cwd' in payload && isStringValue(payload.cwd) ? payload.cwd : null,
44
+ transcriptPath,
45
+ trigger: 'trigger' in payload && isStringValue(payload.trigger) ? payload.trigger : null,
46
+ },
47
+ };
48
+ }
49
+ //# sourceMappingURL=capture-contract.js.map
@@ -7,6 +7,7 @@ import { loadConfig } from './config.js';
7
7
  import { closeHippoDb, openHippoDb } from './db.js';
8
8
  import { recordFailure } from './failure-log.js';
9
9
  import { blockHash } from './token-ledger.js';
10
+ import { redactSecretsStrict } from './secret-detect.js';
10
11
  const MAX_LEN = 200;
11
12
  /** The user, a permission prompt or a hook said no: routine, not a lesson. */
12
13
  const DECLINED = /user (?:doesn't|does not) want|denied by (?:the )?user|user (?:rejected|declined|denied)|permission to use|was blocked by (?:a )?hook/i;
@@ -44,7 +45,7 @@ export function lessonFromFailure(payload) {
44
45
  if (!isString(p.error) || p.error.trim().length < 12)
45
46
  return { skip: 'skipped-invalid', text: null, detail: null };
46
47
  const tool = isString(p.tool_name) ? p.tool_name : 'tool';
47
- const error = p.error.replace(/\s+/g, ' ').trim();
48
+ const error = redactSecretsStrict(p.error.replace(/\s+/g, ' ').trim());
48
49
  const text = `${tool}: ${error}`.slice(0, MAX_LEN);
49
50
  const command = isObject(p.tool_input) && isString(p.tool_input['command']) ? p.tool_input['command'].replace(LEADING_CD, '') : '';
50
51
  const head = command.trim().split(/\s+/).slice(0, 2).join(' ');
package/dist/capture.d.ts CHANGED
@@ -49,19 +49,6 @@ export interface CaptureOptions {
49
49
  tenantId?: string;
50
50
  originProject?: string;
51
51
  }
52
- /**
53
- * Runtime shape guards used throughout this file wherever a value arrives
54
- * unparsed (JSONL transcript records, stdout/stderr write() chunks). Generic
55
- * over the input so the parameter is never annotated `unknown` directly — TS
56
- * infers it from the call site — while the check itself avoids `typeof` by
57
- * testing identity against the coercion (`String(x) === x`) /
58
- * prototype-chain (`instanceof Object`) instead. Behaviourally equivalent to
59
- * `typeof x === 'string'` / a truthy `typeof x === 'object'` check for
60
- * anything `JSON.parse` can produce (the only divergence is boxed
61
- * primitives, which JSON.parse never yields).
62
- */
63
- export declare function isStringValue<T>(value: T): value is T & string;
64
- export declare function isObjectLike<T>(value: T): value is T & object;
65
52
  /** Message for a caught value of unknown shape. `cause` names the sanctioned unknown-input case (error-cause enrichment). */
66
53
  export declare function errorMessage(cause: unknown): string;
67
54
  /** Transcript-line flags isNonHumanUserLine reads (any may be absent); `promptSource: 'system'` marks Claude Code's own notices. */