hippo-memory 1.56.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 (129) 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 +21 -14
  7. package/dist/api.js +97 -71
  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/card-detail.d.ts +1 -1
  18. package/dist/card-detail.js +1 -1
  19. package/dist/cli/shared.d.ts +137 -0
  20. package/dist/cli/shared.js +834 -0
  21. package/dist/cli/sleep.d.ts +10 -0
  22. package/dist/cli/sleep.js +171 -0
  23. package/dist/cli.d.ts +0 -7
  24. package/dist/cli.js +322 -1827
  25. package/dist/client.js +9 -0
  26. package/dist/codex-patch.js +1 -1
  27. package/dist/compaction-record.d.ts +1 -1
  28. package/dist/compaction-record.js +3 -2
  29. package/dist/config.d.ts +5 -0
  30. package/dist/config.js +17 -0
  31. package/dist/connectors/github/dlq.js +5 -2
  32. package/dist/connectors/github/octokit-client.js +4 -2
  33. package/dist/connectors/github/webhook.d.ts +19 -0
  34. package/dist/connectors/github/webhook.js +313 -0
  35. package/dist/connectors/slack/dlq.js +6 -2
  36. package/dist/connectors/slack/web-client.js +7 -5
  37. package/dist/connectors/slack/webhook.d.ts +22 -0
  38. package/dist/connectors/slack/webhook.js +203 -0
  39. package/dist/consolidate.d.ts +10 -0
  40. package/dist/consolidate.js +38 -35
  41. package/dist/context-auto.d.ts +3 -0
  42. package/dist/context-auto.js +34 -0
  43. package/dist/customer-notes.js +16 -14
  44. package/dist/dag.js +3 -2
  45. package/dist/dashboard.js +3 -2
  46. package/dist/db.d.ts +12 -0
  47. package/dist/db.js +62 -1
  48. package/dist/decisions.js +11 -9
  49. package/dist/doctor.js +5 -0
  50. package/dist/embedding-provider.js +3 -3
  51. package/dist/embeddings.d.ts +4 -4
  52. package/dist/embeddings.js +72 -16
  53. package/dist/eval-stats.d.ts +58 -0
  54. package/dist/eval-stats.js +111 -0
  55. package/dist/extract.js +3 -2
  56. package/dist/goals.d.ts +49 -25
  57. package/dist/goals.js +39 -22
  58. package/dist/graph-extract.js +1 -1
  59. package/dist/graph-recall.d.ts +1 -1
  60. package/dist/graph-recall.js +1 -1
  61. package/dist/graph.js +1 -1
  62. package/dist/hooks.d.ts +1 -3
  63. package/dist/hooks.js +2 -4
  64. package/dist/http-retry.d.ts +21 -0
  65. package/dist/http-retry.js +50 -0
  66. package/dist/http-util.d.ts +39 -0
  67. package/dist/http-util.js +56 -0
  68. package/dist/importers.d.ts +2 -0
  69. package/dist/importers.js +16 -5
  70. package/dist/incidents.js +13 -11
  71. package/dist/index.d.ts +5 -2
  72. package/dist/index.js +5 -2
  73. package/dist/judgment.js +10 -17
  74. package/dist/log.d.ts +25 -0
  75. package/dist/log.js +48 -0
  76. package/dist/mcp/server.js +224 -308
  77. package/dist/mcp/tool-args.d.ts +21 -0
  78. package/dist/mcp/tool-args.js +80 -0
  79. package/dist/memory.d.ts +19 -0
  80. package/dist/memory.js +41 -2
  81. package/dist/overlap-index.d.ts +7 -0
  82. package/dist/overlap-index.js +38 -0
  83. package/dist/pilot-arm.d.ts +9 -0
  84. package/dist/pilot-arm.js +47 -0
  85. package/dist/policies.js +14 -12
  86. package/dist/predictions.js +11 -9
  87. package/dist/processes.js +16 -14
  88. package/dist/project-briefs.js +19 -16
  89. package/dist/project-identity.d.ts +1 -1
  90. package/dist/project-identity.js +25 -1
  91. package/dist/prompt-recall.js +1 -1
  92. package/dist/raw-archive.js +7 -6
  93. package/dist/recall-history.d.ts +5 -0
  94. package/dist/recall-history.js +9 -0
  95. package/dist/recall-pipeline.d.ts +101 -0
  96. package/dist/recall-pipeline.js +313 -0
  97. package/dist/recall-scope.d.ts +24 -1
  98. package/dist/recall-scope.js +29 -2
  99. package/dist/refine-llm.js +3 -2
  100. package/dist/reject-flow.js +6 -9
  101. package/dist/rejection.d.ts +2 -1
  102. package/dist/rejection.js +2 -1
  103. package/dist/search.d.ts +0 -20
  104. package/dist/search.js +16 -51
  105. package/dist/secret-detect.d.ts +13 -1
  106. package/dist/secret-detect.js +33 -1
  107. package/dist/server.d.ts +3 -1
  108. package/dist/server.js +1854 -2566
  109. package/dist/session-digest.js +2 -1
  110. package/dist/shared.js +7 -6
  111. package/dist/skills.js +17 -15
  112. package/dist/store-cards.d.ts +53 -0
  113. package/dist/store-cards.js +512 -0
  114. package/dist/store.d.ts +2 -89
  115. package/dist/store.js +10 -566
  116. package/dist/tenant.d.ts +22 -0
  117. package/dist/tenant.js +26 -0
  118. package/dist/token-ledger.d.ts +4 -2
  119. package/dist/token-ledger.js +2 -2
  120. package/dist/tokenize.d.ts +2 -0
  121. package/dist/tokenize.js +8 -0
  122. package/dist/version.d.ts +1 -1
  123. package/dist/version.js +1 -1
  124. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  125. package/extensions/openclaw-plugin/package.json +1 -1
  126. package/openclaw.plugin.json +1 -1
  127. package/package.json +1 -1
  128. package/dist/connectors/slack/ratelimit.d.ts +0 -9
  129. package/dist/connectors/slack/ratelimit.js +0 -18
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';
@@ -16,15 +18,15 @@ import { detectInstruction } from './instruction-detect.js';
16
18
  import { quarantineScopeFor, recordQuarantine, getQuarantineRow, listQuarantineRows, approveQuarantineRow, rejectQuarantineRow, } from './quarantine.js';
17
19
  import { summarizeFailures } from './failure-log.js';
18
20
  import { formatHandoffEvidenceLine } from './handoff.js';
19
- import { createMemory, createSuccessor, applyOutcome, calculateStrength, CHURN_STALE_TAG, COMPACTION_MEMORY_TAG, } from './memory.js';
20
- import { appendAuditEvent, auditQueryFields, queryAuditEvents, auditMemories, isContentWorthStoring, } from './audit.js';
21
+ import { createMemory, createSuccessor, applyOutcome, calculateStrength, markRetrieved, CHURN_STALE_TAG, COMPACTION_MEMORY_TAG, } from './memory.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';
24
26
  import { archiveRawMemory } from './raw-archive.js';
25
27
  import { createApiKey, listApiKeys, revokeApiKey, grantScope, ungrantScope, } from './auth.js';
26
28
  import { applyGoalStackBoost } from './goals.js';
27
- import { markRetrieved, estimateTokens, hybridSearch, physicsSearch, churnStaleFactor } from './search.js';
29
+ import { estimateTokens, hybridSearch, physicsSearch, churnStaleFactor } from './search.js';
28
30
  import { compareEntryIdentity, compareScoredResults } from './compare.js';
29
31
  import { dropHeldCopies, duplicateKey, storedTextKeys } from './same-text.js';
30
32
  import { scopeMatch } from './scope.js';
@@ -32,14 +34,14 @@ 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';
39
41
  import { loadPendingExtractionTenants, markPendingProcessedUpTo } from './graph.js';
40
42
  import { extractGraph } from './graph-extract.js';
41
43
  import { computePlanningFallacyOutput, } from './predictions.js';
42
- import { detectAnchoring, hashQueryText, } from './recall-history.js';
44
+ import { detectAnchoring, hashQueryText, biasHintEnabled, } from './recall-history.js';
43
45
  import { detectAvailabilityBias } from './availability.js';
44
46
  /**
45
47
  * Helper for building process-local (admin-by-default) Actor values. v1.12.0
@@ -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
@@ -107,23 +102,22 @@ export { classifyOriginProject } from './project-identity.js';
107
102
  * injects inside its owning project; flagged rows with no project origin
108
103
  * (''/null) never ambient-inject at all. Explicit recall is unaffected -
109
104
  * recalling a secret is a deliberate act.
110
- * - S2 envelope parity: private scopes + quarantine buckets never inject.
105
+ * - S2 envelope parity: private/quarantine scopes never inject unless `exactScope` names one.
111
106
  * - S3 origin partition: other-project rows are excluded unless
112
107
  * `includeCrossProject`.
113
108
  */
114
- function ambientAdmitEntry(e, currentProjectName, includeCrossProject) {
109
+ function ambientAdmitEntry(e, currentProjectName, includeCrossProject, exactScope) {
115
110
  if (!ambientSecretAdmit(e, currentProjectName))
116
111
  return false;
117
- if (!passesScopeFilterForRecall(e.scope ?? null, undefined))
112
+ if (!passesScopeFilterForRecall(e.scope ?? null, exactScope))
118
113
  return false;
119
114
  if (includeCrossProject)
120
115
  return true;
121
116
  return classifyOriginProject(e.origin_project, currentProjectName) !== 'cross-project';
122
117
  }
123
118
  /**
124
- * v39 S4: the secret half of the ambient policy on its own, for surfaces
125
- * with their own scope semantics (MCP hippo_context's explicit-scope
126
- * exact-match). A flagged row is only admitted inside its owning project;
119
+ * v39 S4: the secret half of the ambient policy on its own, for callers
120
+ * that apply their own scope rule. A flagged row is only admitted inside its owning project;
127
121
  * flagged rows with no project origin never ambient-inject.
128
122
  */
129
123
  export function ambientSecretAdmit(e, currentProjectName) {
@@ -171,9 +165,10 @@ export function oneCopyPerMemory(local, global, now) {
171
165
  return [local.filter((e) => kept.has(e)), global.filter((e) => kept.has(e))];
172
166
  }
173
167
  export function remember(ctx, opts) {
174
- 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 };
175
170
  const requestedScope = opts.scope ?? null;
176
- const entry = createMemory(opts.content, {
171
+ const entry = createMemory(vetted.content, {
177
172
  kind: opts.kind ?? 'distilled',
178
173
  scope: detection.flagged ? quarantineScopeFor(requestedScope) : requestedScope,
179
174
  owner: opts.owner ?? null,
@@ -200,6 +195,8 @@ export function remember(ctx, opts) {
200
195
  const result = { id: entry.id, kind: entry.kind, tenantId: ctx.tenantId };
201
196
  if (detection.flagged)
202
197
  result.quarantined = { reason: detection.reason ?? 'unknown' };
198
+ if (vetted.warnings.length > 0)
199
+ result.warnings = vetted.warnings;
203
200
  return result;
204
201
  }
205
202
  /**
@@ -244,6 +241,8 @@ export function recall(ctx, opts) {
244
241
  export async function retrieve(ctx, opts) {
245
242
  assertScopeRequestAllowed(ctx.actor, opts.scope);
246
243
  const windowSize = recallWindowSize(opts);
244
+ if (opts.showRanked)
245
+ return retrieveFromStore(ctx, opts, windowSize, opts.showRanked);
247
246
  let candidates = loadRecallSearchEntries(ctx.hippoRoot, opts.query, windowSize, ctx.tenantId, opts.scope, 'exact', false);
248
247
  if (opts.mode === 'hybrid' || opts.mode === 'physics') {
249
248
  const searchOpts = { budget: Infinity, hippoRoot: ctx.hippoRoot, scope: opts.scope ?? null };
@@ -257,6 +256,40 @@ export async function retrieve(ctx, opts) {
257
256
  strengthenRetrieved(ctx.hippoRoot, result.results.map((r) => r.id), ctx.tenantId);
258
257
  return result;
259
258
  }
259
+ /** `retrieve` under `showRanked`: physics when `mode` says so, hybrid otherwise, over every admitted row. */
260
+ async function retrieveFromStore(ctx, opts, windowSize, show) {
261
+ const store = loadAllEntries(ctx.hippoRoot, ctx.tenantId);
262
+ const pool = store.filter((e) => passesScopeFilterForRecall(e.scope ?? null, opts.scope));
263
+ // No scope option: the scope boost follows HIPPO_SCOPE and the skill env, as MCP recall always ranked.
264
+ const searchOpts = { budget: Infinity, hippoRoot: ctx.hippoRoot };
265
+ let ranked = opts.mode === 'physics'
266
+ ? await physicsSearch(opts.query, pool, { ...searchOpts, physicsConfig: loadConfig(ctx.hippoRoot).physics })
267
+ : await hybridSearch(opts.query, pool, searchOpts);
268
+ if (opts.sessionId && !opts.goalTag) {
269
+ const db = openHippoDb(ctx.hippoRoot);
270
+ try {
271
+ ranked = applyGoalStackBoost(db, ranked, { sessionId: opts.sessionId, tenantId: ctx.tenantId, limit: ranked.length });
272
+ }
273
+ finally {
274
+ closeHippoDb(db);
275
+ }
276
+ }
277
+ const window = ranked.slice(0, windowSize).map((r) => r.entry);
278
+ const result = recallFrom(ctx, { ...opts, suppressRecallTrace: true }, windowSize, window);
279
+ const shown = show({ ranked, pool, droppedByScope: store.length - pool.length }, result);
280
+ strengthenRetrieved(ctx.hippoRoot, shown, ctx.tenantId);
281
+ if (!opts.suppressRecallTrace) {
282
+ const scores = new Map(ranked.map((r) => [r.entry.id, r.score]));
283
+ writeRecallTraceAtRoot(ctx.hippoRoot, {
284
+ tenantId: ctx.tenantId,
285
+ sessionId: opts.sessionId ?? null,
286
+ pipeline: 'mcp',
287
+ query: opts.query,
288
+ results: shown.map((id) => ({ memoryId: id, score: scores.get(id) ?? 0 })),
289
+ });
290
+ }
291
+ return result;
292
+ }
260
293
  /** Contract preflight: throws before any store-touching work. */
261
294
  function recallWindowSize(opts) {
262
295
  // F5 (v1.6.5) preflight — codex P1: original guard fired AFTER
@@ -564,9 +597,9 @@ function recallFrom(ctx, opts, windowSize, all) {
564
597
  // handle. v1.11.5 contract lock holds — api.recall does NOT write
565
598
  // last_trace_id (tests/api-recall-no-side-effects.test.ts); a trace INSERT
566
599
  // is the same observability class as the audit row it sits beside, not
567
- // retrieval state. F2 fix: suppressed when the caller (currently only the
568
- // MCP handler) traces its own, different result set — see
569
- // opts.suppressRecallTrace JSDoc. Fail-soft internally; never throws.
600
+ // retrieval state. F2 fix: suppressed when the caller traces its own,
601
+ // different result set (retrieve under showRanked traces the shown list as
602
+ // 'mcp'). Fail-soft internally; never throws.
570
603
  if (!opts.suppressRecallTrace) {
571
604
  writeRecallTrace(db, {
572
605
  tenantId: ctx.tenantId,
@@ -656,7 +689,7 @@ function recallFrom(ctx, opts, windowSize, all) {
656
689
  // detect call returns null and api.recall's anchoringHint stays absent.
657
690
  let anchoringHint = null;
658
691
  let suppressedByInterferenceCount = 0;
659
- if (process.env.HIPPO_ANCHORING !== 'off' && opts.recallHistory) {
692
+ if (biasHintEnabled('anchoring') && opts.recallHistory) {
660
693
  const queryHash = hashQueryText(opts.query);
661
694
  const topMemoryId = rankedOut[0]?.id ?? null;
662
695
  anchoringHint = detectAnchoring(opts.recallHistory, queryHash, topMemoryId);
@@ -709,7 +742,7 @@ function recallFrom(ctx, opts, windowSize, all) {
709
742
  // opts.recallHistory gate above so we never double-emit the audit op. Audit
710
743
  // emission is pipeline-local, mirroring the J1 block above.
711
744
  let availabilityHint = null;
712
- if (process.env.HIPPO_AVAILABILITY !== 'off' && !opts.suppressAvailabilityHint) {
745
+ if (biasHintEnabled('availability') && !opts.suppressAvailabilityHint) {
713
746
  availabilityHint = detectAvailabilityBias({
714
747
  topK: baseSlice.map((e) => ({ id: e.id, created: e.created })),
715
748
  pool: entries.map((e) => ({ id: e.id, created: e.created })),
@@ -1072,7 +1105,7 @@ export function forget(ctx, id) {
1072
1105
  .prepare(`SELECT tenant_id FROM memories WHERE id = ?`)
1073
1106
  .get(id);
1074
1107
  if (!row || row.tenant_id !== ctx.tenantId) {
1075
- throw new Error(`memory not found: ${id}`);
1108
+ throw new NotFoundError(`memory not found: ${id}`);
1076
1109
  }
1077
1110
  }
1078
1111
  finally {
@@ -1080,7 +1113,7 @@ export function forget(ctx, id) {
1080
1113
  }
1081
1114
  const removed = deleteEntry(ctx.hippoRoot, id, { actor: ctx.actor.subject });
1082
1115
  if (!removed) {
1083
- throw new Error(`memory not found: ${id}`);
1116
+ throw new NotFoundError(`memory not found: ${id}`);
1084
1117
  }
1085
1118
  // Counted here, not in the CLI: both callers of this function (cmdForget and
1086
1119
  // the HTTP route) are the two paths of one user command, so neither can miss
@@ -1115,7 +1148,7 @@ export function reject(ctx, opts) {
1115
1148
  .prepare(`SELECT tenant_id FROM memories WHERE id = ?`)
1116
1149
  .get(opts.memoryId);
1117
1150
  if (!row || row.tenant_id !== ctx.tenantId) {
1118
- throw new Error(`memory not found: ${opts.memoryId}`);
1151
+ throw new NotFoundError(`memory not found: ${opts.memoryId}`);
1119
1152
  }
1120
1153
  }
1121
1154
  finally {
@@ -1141,10 +1174,10 @@ export function reject(ctx, opts) {
1141
1174
  export function unreject(ctx, digestOrPrefix) {
1142
1175
  const outcome = unrejectValue(ctx.hippoRoot, ctx.tenantId, digestOrPrefix, ctx.actor.subject);
1143
1176
  if (outcome.status === 'not_found') {
1144
- throw new Error(`no rejected value matches: ${digestOrPrefix}`);
1177
+ throw new NotFoundError(`no rejected value matches: ${digestOrPrefix}`);
1145
1178
  }
1146
1179
  if (outcome.status === 'ambiguous') {
1147
- 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`);
1148
1181
  }
1149
1182
  return { ok: true, digest: outcome.digest };
1150
1183
  }
@@ -1167,7 +1200,7 @@ export function promote(ctx, id) {
1167
1200
  .prepare(`SELECT tenant_id FROM memories WHERE id = ?`)
1168
1201
  .get(id);
1169
1202
  if (!row || row.tenant_id !== ctx.tenantId) {
1170
- throw new Error(`memory not found: ${id}`);
1203
+ throw new NotFoundError(`memory not found: ${id}`);
1171
1204
  }
1172
1205
  }
1173
1206
  finally {
@@ -1199,13 +1232,13 @@ export function supersede(ctx, oldId, newContent) {
1199
1232
  // info leak.
1200
1233
  const old = readEntry(ctx.hippoRoot, oldId, ctx.tenantId);
1201
1234
  if (!old) {
1202
- throw new Error(`Memory not found: ${oldId}`);
1235
+ throw new NotFoundError(`Memory not found: ${oldId}`);
1203
1236
  }
1204
1237
  // Guard: not already superseded. The CAS UPDATE below race-safely closes
1205
1238
  // the window between this read and the write; this check just produces a
1206
1239
  // clearer error in the common single-writer case.
1207
1240
  if (old.superseded_by) {
1208
- 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.`);
1209
1242
  }
1210
1243
  const newEntry = createSuccessor(old, newContent, {
1211
1244
  tenantId: ctx.tenantId,
@@ -1233,7 +1266,7 @@ export function supersede(ctx, oldId, newContent) {
1233
1266
  `).run(newEntry.id, oldId, ctx.tenantId);
1234
1267
  if ((result.changes ?? 0) === 0) {
1235
1268
  db.exec('ROLLBACK');
1236
- throw new Error(`Memory ${oldId} already superseded by another writer`);
1269
+ throw new ConflictError(`Memory ${oldId} already superseded by another writer`);
1237
1270
  }
1238
1271
  // v0.30 / E2 — DAG live-coupling: OLD entry just transitioned to
1239
1272
  // superseded. Its parent (if any) needs rebuild. Lands strictly
@@ -1304,7 +1337,7 @@ export function archiveRaw(ctx, id, reason, opts = {}) {
1304
1337
  .prepare(`SELECT tenant_id FROM memories WHERE id = ?`)
1305
1338
  .get(id);
1306
1339
  if (!row || row.tenant_id !== ctx.tenantId) {
1307
- throw new Error(`memory not found: ${id}`);
1340
+ throw new NotFoundError(`memory not found: ${id}`);
1308
1341
  }
1309
1342
  archiveRawMemory(db, id, {
1310
1343
  reason,
@@ -1383,8 +1416,9 @@ export function authCreate(ctx, opts) {
1383
1416
  },
1384
1417
  });
1385
1418
  }
1386
- catch {
1419
+ catch (error) {
1387
1420
  // Audit must not crash a successful mint.
1421
+ reportAuditWriteFailure('auth_create', String(error), result.keyId);
1388
1422
  }
1389
1423
  return { keyId: result.keyId, plaintext: result.plaintext, tenantId: ctx.tenantId, role };
1390
1424
  }
@@ -1422,11 +1456,11 @@ export function authRevoke(ctx, keyId) {
1422
1456
  .prepare(`SELECT key_id, tenant_id, revoked_at, role FROM api_keys WHERE key_id = ?`)
1423
1457
  .get(keyId);
1424
1458
  if (!row) {
1425
- throw new Error(`Unknown key_id: ${keyId}`);
1459
+ throw new NotFoundError(`Unknown key_id: ${keyId}`);
1426
1460
  }
1427
1461
  // Cross-tenant access denied: same message as missing key, no info leak.
1428
1462
  if (row.tenant_id !== ctx.tenantId) {
1429
- throw new Error(`Unknown key_id: ${keyId}`);
1463
+ throw new NotFoundError(`Unknown key_id: ${keyId}`);
1430
1464
  }
1431
1465
  if (ctx.actor.viaAuthResolver && row.role === 'admin') {
1432
1466
  throw new ForbiddenError('An auth resolver admin cannot revoke an admin key, which outranks it');
@@ -1455,8 +1489,9 @@ export function authRevoke(ctx, keyId) {
1455
1489
  targetId: keyId,
1456
1490
  });
1457
1491
  }
1458
- catch {
1492
+ catch (error) {
1459
1493
  // Audit must not crash a successful revoke.
1494
+ reportAuditWriteFailure('auth_revoke', String(error), keyId);
1460
1495
  }
1461
1496
  }
1462
1497
  return { ok: true, revokedAt };
@@ -1484,13 +1519,13 @@ function changeScopeGrant(ctx, keyId, scope, op) {
1484
1519
  .prepare(`SELECT tenant_id, revoked_at FROM api_keys WHERE key_id = ?`)
1485
1520
  .get(keyId);
1486
1521
  if (!row || row.tenant_id !== ctx.tenantId) {
1487
- throw new Error(`Unknown key_id: ${keyId}`);
1522
+ throw new NotFoundError(`Unknown key_id: ${keyId}`);
1488
1523
  }
1489
1524
  if (op === 'auth_grant' && row.revoked_at) {
1490
- 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`);
1491
1526
  }
1492
1527
  if (!isRestrictedScope(scope)) {
1493
- 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`);
1494
1529
  }
1495
1530
  if (op === 'auth_grant')
1496
1531
  grantScope(db, keyId, scope);
@@ -1501,7 +1536,7 @@ function changeScopeGrant(ctx, keyId, scope, op) {
1501
1536
  }
1502
1537
  catch (err) {
1503
1538
  // Audit must not undo a grant change that already committed; surface it instead.
1504
- console.error(`auth: audit write failed for ${op} ${keyId}: ${err instanceof Error ? err.message : String(err)}`);
1539
+ reportAuditWriteFailure(op, String(err), keyId);
1505
1540
  }
1506
1541
  return { ok: true };
1507
1542
  }
@@ -1551,6 +1586,8 @@ export async function getContext(ctx, opts = {}) {
1551
1586
  const limit = opts.limit ?? Number.POSITIVE_INFINITY;
1552
1587
  const includeRecent = opts.includeRecent ?? 0;
1553
1588
  const activeScope = opts.scope ?? '';
1589
+ assertScopeRequestAllowed(ctx.actor, opts.exactScope);
1590
+ const exactScope = opts.exactScope || undefined;
1554
1591
  if (budget <= 0) {
1555
1592
  return { entries: [], tokens: 0 };
1556
1593
  }
@@ -1562,10 +1599,7 @@ export async function getContext(ctx, opts = {}) {
1562
1599
  const primaryIsGlobal = isGlobalStoreRoot(ctx.hippoRoot);
1563
1600
  const hasLocalTaskState = hasLocal && !primaryIsGlobal;
1564
1601
  // v39 memory scope isolation (docs/plans/2026-07-01-memory-scope-isolation.md).
1565
- // S2: envelope-filter parity with api.recall for AMBIENT context - private
1566
- // scopes and quarantine buckets never inject. `requested` is deliberately
1567
- // undefined: opts.scope is the scope-TAG boost input here, not an
1568
- // envelope-scope request (api.recall's exact-match semantics don't apply).
1602
+ // S2: envelope-filter parity with api.recall; opts.scope is only the tag boost, opts.exactScope the envelope request.
1569
1603
  // S3: origin partition - other-project memories are excluded unless the
1570
1604
  // caller explicitly asks for them (crossProject) or isolation is disabled.
1571
1605
  const config = loadConfig(ctx.hippoRoot);
@@ -1612,9 +1646,8 @@ export async function getContext(ctx, opts = {}) {
1612
1646
  sessionId: opts.currentSessionId,
1613
1647
  })
1614
1648
  : null;
1615
- // W1: pre-existing leak; same `requested: undefined` ambientAdmitEntry
1616
- // already uses when it scope-filters memory rows above.
1617
- const activeSnapshot = rawActiveSnapshot && passesScopeFilterForRecall(rowScope(rawActiveSnapshot), undefined)
1649
+ // W1: the same envelope rule ambientAdmitEntry applies to memory rows.
1650
+ const activeSnapshot = rawActiveSnapshot && passesScopeFilterForRecall(rowScope(rawActiveSnapshot), exactScope)
1618
1651
  ? rawActiveSnapshot
1619
1652
  : null;
1620
1653
  // Key on the RAW snapshot: a scope-hidden active session must not fall through to another session's ambient handoff.
@@ -1628,7 +1661,7 @@ export async function getContext(ctx, opts = {}) {
1628
1661
  // codex P2: admit scope in SQL so a newer denied row can't hide an older eligible one before LIMIT 1.
1629
1662
  scopeFilter: 'default-deny',
1630
1663
  });
1631
- const sessionHandoff = rawSessionHandoff && passesScopeFilterForRecall(rowScope(rawSessionHandoff), undefined)
1664
+ const sessionHandoff = rawSessionHandoff && passesScopeFilterForRecall(rowScope(rawSessionHandoff), exactScope)
1632
1665
  ? rawSessionHandoff
1633
1666
  : null;
1634
1667
  // Raw session id here too: each event is admitted on its own scope, same as recall and the CLI.
@@ -1636,7 +1669,7 @@ export async function getContext(ctx, opts = {}) {
1636
1669
  ? listSessionEvents(ctx.hippoRoot, ctx.tenantId, {
1637
1670
  session_id: rawActiveSnapshot.session_id,
1638
1671
  limit: 5,
1639
- }).filter((e) => passesScopeFilterForRecall(rowScope(e), undefined))
1672
+ }).filter((e) => passesScopeFilterForRecall(rowScope(e), exactScope))
1640
1673
  : [];
1641
1674
  const shownSnapshot = activeSnapshot && (!cost || pays(cost.snapshot(activeSnapshot))) ? activeSnapshot : null;
1642
1675
  const shownHandoff = sessionHandoff && (!cost || pays(cost.handoff(sessionHandoff))) ? sessionHandoff : null;
@@ -1650,7 +1683,7 @@ export async function getContext(ctx, opts = {}) {
1650
1683
  digestHiddenForHandoff = true;
1651
1684
  return false;
1652
1685
  }
1653
- return ambientAdmitEntry(e, currentProjectName, includeCrossProject);
1686
+ return ambientAdmitEntry(e, currentProjectName, includeCrossProject, exactScope);
1654
1687
  };
1655
1688
  const ownSessionId = opts.currentSessionId || '';
1656
1689
  // Inside admit, not after the load, so the loader's window widens past a session's own items.
@@ -2198,10 +2231,10 @@ export function restoreDormant(ctx, id) {
2198
2231
  try {
2199
2232
  const dormant = readDormantSnapshot(db, ctx.tenantId, id);
2200
2233
  if (!dormant) {
2201
- throw new Error(`dormant memory not found: ${id}`);
2234
+ throw new NotFoundError(`dormant memory not found: ${id}`);
2202
2235
  }
2203
2236
  if (db.prepare(`SELECT 1 FROM memories WHERE id = ?`).get(id) !== undefined) {
2204
- 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`);
2205
2238
  }
2206
2239
  const now = new Date();
2207
2240
  // Dormant rows are long-lived, so a snapshot can predate a field added
@@ -2260,7 +2293,7 @@ export function forgetDormant(ctx, id) {
2260
2293
  const db = openHippoDb(ctx.hippoRoot);
2261
2294
  try {
2262
2295
  if (!deleteDormantRow(db, ctx.tenantId, id)) {
2263
- throw new Error(`dormant memory not found: ${id}`);
2296
+ throw new NotFoundError(`dormant memory not found: ${id}`);
2264
2297
  }
2265
2298
  try {
2266
2299
  appendAuditEvent(db, {
@@ -2271,8 +2304,9 @@ export function forgetDormant(ctx, id) {
2271
2304
  metadata: { dormant: true },
2272
2305
  });
2273
2306
  }
2274
- catch {
2307
+ catch (error) {
2275
2308
  // Best-effort, like every other forget audit row: the delete stands.
2309
+ reportAuditWriteFailure('forget', String(error), id);
2276
2310
  }
2277
2311
  }
2278
2312
  finally {
@@ -2318,9 +2352,9 @@ export function quarantineList(ctx, opts = {}) {
2318
2352
  function loadPendingQuarantineRow(db, tenantId, id) {
2319
2353
  const row = getQuarantineRow(db, tenantId, id);
2320
2354
  if (!row)
2321
- throw new Error(`not quarantined: ${id}`);
2355
+ throw new NotFoundError(`not quarantined: ${id}`);
2322
2356
  if (row.status !== 'pending')
2323
- throw new Error(`${id} is already ${row.status}`);
2357
+ throw new ConflictError(`${id} is already ${row.status}`);
2324
2358
  return row;
2325
2359
  }
2326
2360
  /** Release a quarantined memory to its original scope. Admin only; the scope guard refuses a row moved since (mirrors restoreDormant). */
@@ -2338,7 +2372,7 @@ export function quarantineApprove(ctx, id) {
2338
2372
  .prepare(`UPDATE memories SET scope = ? WHERE id = ? AND tenant_id = ? AND scope = ?`)
2339
2373
  .run(row.originalScope, id, ctx.tenantId, quarantineScope);
2340
2374
  if (Number(updated.changes ?? 0) !== 1) {
2341
- 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`);
2342
2376
  }
2343
2377
  approveQuarantineRow(db, ctx.tenantId, id, ctx.actor.subject);
2344
2378
  appendAuditEvent(db, {
@@ -2655,16 +2689,8 @@ export async function sleep(ctx, opts = {}) {
2655
2689
  }
2656
2690
  }
2657
2691
  catch (auditErr) {
2658
- // Audit emit failure must NOT mask the original phaseError. Log to
2659
- // stderr so the secondary failure is observable but does not throw.
2660
- // This guards the case where consolidation AND audit-emit fail in the
2661
- // same invocation against the same DB (correlated: same disk, same
2662
- // schema state) — losing the original error makes diagnosis much harder.
2663
- // SAFETY: this is a best-effort log message only; property access on
2664
- // any JS value is safe (undefined if absent), preserving the existing
2665
- // lenient formatting even when something non-Error was thrown.
2666
- // eslint-disable-next-line no-console
2667
- 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));
2668
2694
  }
2669
2695
  }
2670
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