hippo-memory 1.57.0 → 1.59.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 (109) hide show
  1. package/README.md +24 -1
  2. package/dist/agent-memories/apply.d.ts +1 -1
  3. package/dist/agent-memories/claude-code.js +1 -1
  4. package/dist/agent-memories/gemini.js +1 -1
  5. package/dist/agent-memories/legacy.js +4 -1
  6. package/dist/api-errors.d.ts +27 -0
  7. package/dist/api-errors.js +37 -0
  8. package/dist/api.d.ts +5 -5
  9. package/dist/api.js +40 -47
  10. package/dist/audit.d.ts +5 -1
  11. package/dist/audit.js +13 -0
  12. package/dist/autolearn.d.ts +1 -1
  13. package/dist/autolearn.js +7 -5
  14. package/dist/capture-contract.d.ts +47 -0
  15. package/dist/capture-contract.js +49 -0
  16. package/dist/capture-error.js +2 -1
  17. package/dist/capture.d.ts +0 -13
  18. package/dist/capture.js +5 -66
  19. package/dist/cli/output.d.ts +3 -0
  20. package/dist/cli/output.js +7 -0
  21. package/dist/cli/projects.d.ts +4 -0
  22. package/dist/cli/projects.js +90 -0
  23. package/dist/cli/shared.js +23 -13
  24. package/dist/cli/sleep.js +5 -3
  25. package/dist/cli.d.ts +1 -0
  26. package/dist/cli.js +486 -397
  27. package/dist/client.js +9 -0
  28. package/dist/codex-patch.js +1 -1
  29. package/dist/compaction-record.d.ts +1 -1
  30. package/dist/compaction-record.js +3 -2
  31. package/dist/config.d.ts +5 -0
  32. package/dist/config.js +17 -0
  33. package/dist/connectors/github/dlq.js +5 -2
  34. package/dist/connectors/github/octokit-client.js +4 -2
  35. package/dist/connectors/slack/dlq.js +6 -2
  36. package/dist/connectors/slack/web-client.js +7 -5
  37. package/dist/consolidate.d.ts +10 -0
  38. package/dist/consolidate.js +48 -35
  39. package/dist/customer-notes.js +14 -13
  40. package/dist/dag.js +7 -4
  41. package/dist/dashboard.js +1 -1
  42. package/dist/db.d.ts +12 -0
  43. package/dist/db.js +62 -1
  44. package/dist/decisions.js +9 -8
  45. package/dist/dedupe.js +1 -1
  46. package/dist/doctor.js +28 -0
  47. package/dist/dormant.d.ts +2 -2
  48. package/dist/embedding-provider.js +3 -3
  49. package/dist/embeddings.d.ts +4 -4
  50. package/dist/embeddings.js +72 -16
  51. package/dist/extract.js +19 -18
  52. package/dist/http-retry.d.ts +21 -0
  53. package/dist/http-retry.js +50 -0
  54. package/dist/http-util.d.ts +8 -0
  55. package/dist/http-util.js +10 -0
  56. package/dist/importers.d.ts +2 -0
  57. package/dist/importers.js +16 -5
  58. package/dist/incidents.js +11 -10
  59. package/dist/judgment.js +10 -17
  60. package/dist/log.d.ts +25 -0
  61. package/dist/log.js +48 -0
  62. package/dist/mcp/server.js +52 -24
  63. package/dist/mcp/tool-args.d.ts +21 -0
  64. package/dist/mcp/tool-args.js +80 -0
  65. package/dist/memory.js +3 -2
  66. package/dist/overlap-index.d.ts +7 -0
  67. package/dist/overlap-index.js +38 -0
  68. package/dist/pilot-arm.d.ts +9 -0
  69. package/dist/pilot-arm.js +47 -0
  70. package/dist/policies.js +12 -11
  71. package/dist/predictions.js +9 -8
  72. package/dist/processes.js +14 -13
  73. package/dist/project-briefs.js +16 -15
  74. package/dist/project-identity.d.ts +1 -1
  75. package/dist/project-identity.js +25 -1
  76. package/dist/project-merge.d.ts +52 -0
  77. package/dist/project-merge.js +168 -0
  78. package/dist/raw-archive.js +7 -6
  79. package/dist/recall-scope.d.ts +5 -4
  80. package/dist/recall-scope.js +7 -5
  81. package/dist/refine-llm.js +3 -2
  82. package/dist/reject-flow.js +6 -9
  83. package/dist/rejection.d.ts +2 -1
  84. package/dist/rejection.js +2 -1
  85. package/dist/rerankers/clef.d.ts +29 -0
  86. package/dist/rerankers/clef.js +182 -0
  87. package/dist/rerankers/index.js +3 -0
  88. package/dist/rerankers/jev.d.ts +11 -0
  89. package/dist/rerankers/jev.js +10 -5
  90. package/dist/rerankers/types.d.ts +16 -0
  91. package/dist/search.js +14 -2
  92. package/dist/secret-detect.d.ts +13 -1
  93. package/dist/secret-detect.js +33 -1
  94. package/dist/server.d.ts +9 -2
  95. package/dist/server.js +188 -411
  96. package/dist/session-digest.js +2 -1
  97. package/dist/shared.js +7 -6
  98. package/dist/skills.js +15 -14
  99. package/dist/store.js +10 -10
  100. package/dist/token-ledger.d.ts +4 -2
  101. package/dist/token-ledger.js +2 -2
  102. package/dist/version.d.ts +1 -1
  103. package/dist/version.js +1 -1
  104. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  105. package/extensions/openclaw-plugin/package.json +1 -1
  106. package/openclaw.plugin.json +1 -1
  107. package/package.json +5 -2
  108. package/dist/connectors/slack/ratelimit.d.ts +0 -9
  109. package/dist/connectors/slack/ratelimit.js +0 -18
package/README.md CHANGED
@@ -535,6 +535,18 @@ removes pinned memories, raw receipts (Slack, GitHub, vault imports) or the memo
535
535
  Claude Code compaction saved either way, and duplicate removal and junk cleanup still
536
536
  delete other memories. `hippo forget` still deletes a compaction memory.
537
537
 
538
+ **Clean up project names left by older versions.** Older versions tagged memories saved in
539
+ a git worktree with the worktree's folder name, and older sleep saved merged memories as
540
+ user-global, so every project could see them. Upgrading stops new damage; these commands
541
+ repair old rows. Each is a dry run until you add `--apply`. With `--apply`, it backs up the
542
+ database to `.hippo/backups/` first and logs every id it touched in the audit log:
543
+
544
+ ```bash
545
+ hippo projects --global # names, counts, live worktrees of this repo
546
+ hippo projects merge hippo-wt-fix hippo --global # fold an old worktree name into its repo
547
+ hippo projects repair --global # re-tag user-global merges by their parents
548
+ ```
549
+
538
550
  **See what memory costs in tokens.** Every block of memory text hippo hands an agent (the
539
551
  per-prompt hook, the block `hippo compact-resume` restores after compaction, `hippo context`,
540
552
  `hippo recall`, the MCP tools, the HTTP API) is recorded in a token ledger: counts, surface
@@ -559,6 +571,17 @@ are not recorded yet. It holds ids, hashes, counts and reasons only, never promp
559
571
  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
572
  older than 90 days are pruned; at a heavy 300 prompts a day that is about 190 MB per store.
561
573
 
574
+ **Run a pilot with a holdout group.** Set `{"pilot":{"holdoutRateBp":2000}}` in `.hippo/config.json`
575
+ to hold back memories from about 20% of sessions. The rate is in basis points, 0 to 10000, and 0
576
+ is off (the default). The setting is read from the store the token ledger writes to. A session
577
+ lands in its arm by a hash of its id, and the first hook call writes one row to the token ledger.
578
+ A holdout session gets no memories from the per-prompt hook, the SessionStart hook or compact-resume.
579
+ The agent's own `hippo context` pull is gated in Claude Code only, so a Codex holdout session still
580
+ gets memories from it. Capture still runs. `hippo recall`, the HTTP API and the MCP tools are not gated
581
+ and write no arm row. Agents are told to call the MCP context tool at session start, and those calls
582
+ are not recorded. Set the rate to 0 only after the pilot window closes, because 0 ends every holdout at once.
583
+ `hippo doctor` shows the pilot when it is on.
584
+
562
585
  ---
563
586
 
564
587
  ### Outcome feedback
@@ -1115,7 +1138,7 @@ Mark it, and it drops out of the top results. `hippo outcome --bad` weakens the
1115
1138
 
1116
1139
  ### Where does hippo keep my data?
1117
1140
 
1118
- On your machine, in SQLite: `.hippo/hippo.db` in each project, plus a global store in `~/.hippo/` for lessons shared across projects, with markdown mirrors you can read and commit. Recall makes no network call by default. Text goes to an outside provider only through features that use one: an API embedder, the Jev or LLM reranker, `hippo refine`, and the fact extraction `hippo sleep` runs through Anthropic's API whenever `ANTHROPIC_API_KEY` is set in its environment. To turn that last one off, set `{"extraction":{"enabled":false}}` in `.hippo/config.json`.
1141
+ On your machine, in SQLite: `.hippo/hippo.db` in each project, plus a global store in `~/.hippo/` for lessons shared across projects, with markdown mirrors you can read and commit. Recall makes no network call by default. Text goes to an outside provider only through features that use one: an API embedder, the Jev, CLEF or LLM reranker, `hippo refine`, and the fact extraction `hippo sleep` runs through Anthropic's API whenever `ANTHROPIC_API_KEY` is set in its environment. To turn that last one off, set `{"extraction":{"enabled":false}}` in `.hippo/config.json`.
1119
1142
 
1120
1143
  ### What does hippo cost?
1121
1144
 
@@ -34,7 +34,7 @@ export interface ContainerOutcome {
34
34
  }
35
35
  /** Throws SQLITE_BUSY when another writer holds the store past its busy timeout; nothing is written then. */
36
36
  export declare function syncContainer(s: StoreSession, work: ContainerWork): ContainerOutcome;
37
- export type SetAsideWhy = 'note-gone' | 'note-changed' | 'handover';
37
+ export type SetAsideWhy = 'note-gone' | 'note-changed' | 'handover' | 'project-merge';
38
38
  export type SetAsideResult = {
39
39
  readonly kind: 'untagged';
40
40
  readonly entry: MemoryEntry;
@@ -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';
@@ -1,6 +1,7 @@
1
1
  // Rows the old Claude import wrote (`claude-memory:<file>`), taken over by the notes they came from (plan design 10).
2
2
  import path from 'node:path';
3
3
  import { duplicateKey } from '../same-text.js';
4
+ import { maskEmails } from '../secret-detect.js';
4
5
  import { selectLiveEntriesBySourcePrefix } from '../store.js';
5
6
  import { matchLegacy } from './plan.js';
6
7
  import { MIN_ITEM_CHARS, storedText } from './source.js';
@@ -25,7 +26,9 @@ export function legacyWork(db, tenantId, listings) {
25
26
  refs.push({ dir: container.path, key: item.key });
26
27
  }
27
28
  }
28
- const match = matchLegacy(rows.map((r) => ({ id: r.id, file: r.source.slice(LEGACY_SOURCE_PREFIX.length), textKey: duplicateKey(r.content) })), targets);
29
+ const match = matchLegacy(
30
+ // The old import kept emails in clear, and targets are masked; without the mask those rows never matched and imported twice.
31
+ rows.map((r) => ({ id: r.id, file: r.source.slice(LEGACY_SOURCE_PREFIX.length), textKey: duplicateKey(maskEmails(r.content)) })), targets);
29
32
  const byId = new Map(rows.map((r) => [r.id, r]));
30
33
  const add = (pick, ref, id) => {
31
34
  const { dir, key } = refs[Number(ref)];
@@ -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
@@ -18,7 +18,7 @@ export declare function auditMemory(entry: MemoryEntry, backsObject?: boolean):
18
18
  /** `backing`: ids of memories that back an object (store.memoriesBackingObjects). */
19
19
  export declare function auditMemories(entries: MemoryEntry[], backing: ReadonlySet<string>): AuditResult;
20
20
  export declare function isContentWorthStoring(content: string): boolean;
21
- export declare const AUDIT_OPS: readonly ["remember", "recall", "promote", "supersede", "forget", "archive_raw", "auth_revoke", "auth_create", "outcome", "consolidate", "audit_prune", "summary_marked_dirty", "summary_marked_clean", "summary_rebuilt", "predict_create", "predict_close", "predict_baserate", "recall_autodebias_hint", "recall_autodebias_hint_no_class_match", "recall_autodebias_hint_tiebreak", "recall_anchor_detected_query_repeat", "recall_anchor_detected_memory_dominance", "recall_anchor_skipped_no_session", "recall_availability_detected", "decision_create", "decision_supersede", "decision_close", "incident_open", "incident_resolve", "incident_close", "process_create", "process_supersede", "process_close", "policy_create", "policy_supersede", "policy_close", "skill_create", "skill_supersede", "skill_close", "project_brief_create", "project_brief_supersede", "project_brief_close", "customer_note_create", "customer_note_supersede", "customer_note_close", "mv_rescue", "reject_value", "reject_refusal", "unreject_value", "conflict_resolve", "half_life_migrate", "dormant_restore", "auth_grant", "auth_ungrant", "quarantine", "quarantine_approve", "quarantine_reject", "agent_memory_restore", "agent_memory_set_aside"];
21
+ export declare const AUDIT_OPS: readonly ["remember", "recall", "promote", "supersede", "forget", "archive_raw", "auth_revoke", "auth_create", "outcome", "consolidate", "audit_prune", "summary_marked_dirty", "summary_marked_clean", "summary_rebuilt", "predict_create", "predict_close", "predict_baserate", "recall_autodebias_hint", "recall_autodebias_hint_no_class_match", "recall_autodebias_hint_tiebreak", "recall_anchor_detected_query_repeat", "recall_anchor_detected_memory_dominance", "recall_anchor_skipped_no_session", "recall_availability_detected", "decision_create", "decision_supersede", "decision_close", "incident_open", "incident_resolve", "incident_close", "process_create", "process_supersede", "process_close", "policy_create", "policy_supersede", "policy_close", "skill_create", "skill_supersede", "skill_close", "project_brief_create", "project_brief_supersede", "project_brief_close", "customer_note_create", "customer_note_supersede", "customer_note_close", "mv_rescue", "reject_value", "reject_refusal", "unreject_value", "conflict_resolve", "half_life_migrate", "dormant_restore", "auth_grant", "auth_ungrant", "quarantine", "quarantine_approve", "quarantine_reject", "agent_memory_restore", "agent_memory_set_aside", "project_merge", "project_repair"];
22
22
  export type AuditOp = (typeof AUDIT_OPS)[number];
23
23
  export interface AppendAuditOpts {
24
24
  tenantId: string;
@@ -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',
@@ -234,6 +235,8 @@ export const AUDIT_OPS = [
234
235
  'quarantine_reject', // emitted by api.quarantineReject
235
236
  'agent_memory_restore', // emitted by the agent memory sync when a deleted note comes back
236
237
  'agent_memory_set_aside', // emitted by the agent memory sync when a note is deleted or refused
238
+ 'project_merge', // emitted by `hippo projects merge --apply` with every id it touched
239
+ 'project_repair', // emitted by `hippo projects repair --apply` with every id it touched
237
240
  ];
238
241
  function isBigIntValue(value) {
239
242
  return typeof value === 'bigint';
@@ -250,6 +253,16 @@ export function auditQueryFields(query) {
250
253
  export function appendAuditEvent(db, opts) {
251
254
  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
255
  }
256
+ let auditWriteFailures = 0;
257
+ /** For callers that keep a mutation when its audit row fails: the failure is logged and counted, never silent. */
258
+ export function reportAuditWriteFailure(op, reason, targetId) {
259
+ auditWriteFailures++;
260
+ log.error(`audit write failed: ${reason}`, { op, target: targetId ?? undefined });
261
+ }
262
+ /** Audit rows this process failed to write; the loopback `/health` body reports it. */
263
+ export function auditWriteFailureCount() {
264
+ return auditWriteFailures;
265
+ }
253
266
  export function queryAuditEvents(db, opts) {
254
267
  const where = ['tenant_id = ?'];
255
268
  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