hippo-memory 1.57.0 → 1.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +11 -0
  2. package/dist/agent-memories/claude-code.js +1 -1
  3. package/dist/agent-memories/gemini.js +1 -1
  4. package/dist/api-errors.d.ts +27 -0
  5. package/dist/api-errors.js +37 -0
  6. package/dist/api.d.ts +5 -5
  7. package/dist/api.js +40 -47
  8. package/dist/audit.d.ts +4 -0
  9. package/dist/audit.js +11 -0
  10. package/dist/autolearn.d.ts +1 -1
  11. package/dist/autolearn.js +7 -5
  12. package/dist/capture-contract.d.ts +47 -0
  13. package/dist/capture-contract.js +49 -0
  14. package/dist/capture-error.js +2 -1
  15. package/dist/capture.d.ts +0 -13
  16. package/dist/capture.js +5 -66
  17. package/dist/cli/shared.js +10 -6
  18. package/dist/cli.js +100 -39
  19. package/dist/client.js +9 -0
  20. package/dist/codex-patch.js +1 -1
  21. package/dist/compaction-record.d.ts +1 -1
  22. package/dist/compaction-record.js +3 -2
  23. package/dist/config.d.ts +5 -0
  24. package/dist/config.js +17 -0
  25. package/dist/connectors/github/dlq.js +5 -2
  26. package/dist/connectors/github/octokit-client.js +4 -2
  27. package/dist/connectors/slack/dlq.js +6 -2
  28. package/dist/connectors/slack/web-client.js +7 -5
  29. package/dist/consolidate.d.ts +10 -0
  30. package/dist/consolidate.js +36 -34
  31. package/dist/customer-notes.js +14 -13
  32. package/dist/dag.js +3 -2
  33. package/dist/dashboard.js +1 -1
  34. package/dist/db.d.ts +12 -0
  35. package/dist/db.js +62 -1
  36. package/dist/decisions.js +9 -8
  37. package/dist/doctor.js +5 -0
  38. package/dist/embedding-provider.js +3 -3
  39. package/dist/embeddings.d.ts +4 -4
  40. package/dist/embeddings.js +72 -16
  41. package/dist/extract.js +3 -2
  42. package/dist/http-retry.d.ts +21 -0
  43. package/dist/http-retry.js +50 -0
  44. package/dist/http-util.d.ts +8 -0
  45. package/dist/http-util.js +10 -0
  46. package/dist/importers.d.ts +2 -0
  47. package/dist/importers.js +16 -5
  48. package/dist/incidents.js +11 -10
  49. package/dist/judgment.js +10 -17
  50. package/dist/log.d.ts +25 -0
  51. package/dist/log.js +48 -0
  52. package/dist/mcp/server.js +52 -24
  53. package/dist/mcp/tool-args.d.ts +21 -0
  54. package/dist/mcp/tool-args.js +80 -0
  55. package/dist/memory.js +3 -2
  56. package/dist/overlap-index.d.ts +7 -0
  57. package/dist/overlap-index.js +38 -0
  58. package/dist/pilot-arm.d.ts +9 -0
  59. package/dist/pilot-arm.js +47 -0
  60. package/dist/policies.js +12 -11
  61. package/dist/predictions.js +9 -8
  62. package/dist/processes.js +14 -13
  63. package/dist/project-briefs.js +16 -15
  64. package/dist/project-identity.d.ts +1 -1
  65. package/dist/project-identity.js +25 -1
  66. package/dist/raw-archive.js +7 -6
  67. package/dist/recall-scope.d.ts +2 -1
  68. package/dist/recall-scope.js +2 -1
  69. package/dist/refine-llm.js +3 -2
  70. package/dist/reject-flow.js +6 -9
  71. package/dist/rejection.d.ts +2 -1
  72. package/dist/rejection.js +2 -1
  73. package/dist/search.js +14 -2
  74. package/dist/secret-detect.d.ts +13 -1
  75. package/dist/secret-detect.js +33 -1
  76. package/dist/server.d.ts +3 -1
  77. package/dist/server.js +180 -409
  78. package/dist/session-digest.js +2 -1
  79. package/dist/shared.js +7 -6
  80. package/dist/skills.js +15 -14
  81. package/dist/store.js +4 -4
  82. package/dist/token-ledger.d.ts +4 -2
  83. package/dist/token-ledger.js +2 -2
  84. package/dist/version.d.ts +1 -1
  85. package/dist/version.js +1 -1
  86. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  87. package/extensions/openclaw-plugin/package.json +1 -1
  88. package/openclaw.plugin.json +1 -1
  89. package/package.json +1 -1
  90. package/dist/connectors/slack/ratelimit.d.ts +0 -9
  91. package/dist/connectors/slack/ratelimit.js +0 -18
package/dist/capture.js CHANGED
@@ -25,6 +25,7 @@ import { RejectedValueError, checkRejectionGuard } from './rejection.js';
25
25
  import { openHippoDb, closeHippoDb } from './db.js';
26
26
  import { loadConfig } from './config.js';
27
27
  import { classifyOriginProject } from './project-identity.js';
28
+ import { isObjectLike, isStringValue, readClaudeCodePreCompact } from './capture-contract.js';
28
29
  // Sentence-level patterns
29
30
  //
30
31
  // T1 (DF2): each pattern now carries TWO capture groups — group 1 is the
@@ -471,23 +472,6 @@ export function extractFromText(text) {
471
472
  }
472
473
  return items;
473
474
  }
474
- /**
475
- * Runtime shape guards used throughout this file wherever a value arrives
476
- * unparsed (JSONL transcript records, stdout/stderr write() chunks). Generic
477
- * over the input so the parameter is never annotated `unknown` directly — TS
478
- * infers it from the call site — while the check itself avoids `typeof` by
479
- * testing identity against the coercion (`String(x) === x`) /
480
- * prototype-chain (`instanceof Object`) instead. Behaviourally equivalent to
481
- * `typeof x === 'string'` / a truthy `typeof x === 'object'` check for
482
- * anything `JSON.parse` can produce (the only divergence is boxed
483
- * primitives, which JSON.parse never yields).
484
- */
485
- export function isStringValue(value) {
486
- return String(value) === value;
487
- }
488
- export function isObjectLike(value) {
489
- return value !== null && value instanceof Object;
490
- }
491
475
  /** Message for a caught value of unknown shape. `cause` names the sanctioned unknown-input case (error-cause enrichment). */
492
476
  export function errorMessage(cause) {
493
477
  return cause instanceof Error ? cause.message : String(cause);
@@ -1161,57 +1145,12 @@ function runPreCompact(hippoRoot, stdinText, stdinTimedOut, logFile) {
1161
1145
  appendPreCompactLog(logFile, 'skip: store not initialized');
1162
1146
  return;
1163
1147
  }
1164
- // Same hazard X4 guards below, different trigger: a read that timed out
1165
- // must not reach auto-discovery either, or it snapshots another session.
1166
- if (stdinTimedOut && (!stdinText || stdinText.trim() === '')) {
1167
- appendPreCompactLog(logFile, 'skip: no PreCompact payload arrived before the stdin wait window closed');
1168
- return;
1169
- }
1170
- // A true manual invocation has no stdin at all (TTY, or a non-TTY pipe
1171
- // that yielded an empty read) — that's the ONLY case newest-transcript
1172
- // auto-discovery is allowed to run. Any other non-empty stdin must
1173
- // JSON-parse to an object carrying a string transcript_path, or it is
1174
- // treated as malformed input and skipped (X4) rather than silently
1175
- // falling back to discovery, which could snapshot an unrelated session's
1176
- // transcript under this payload's session_id.
1177
- const manualInvocation = !stdinText || stdinText.trim() === '';
1178
- let sessionId = null;
1179
- let payloadTranscriptPath = null;
1180
- let payloadCwd = null;
1181
- let payloadTrigger = null;
1182
- if (!manualInvocation) {
1183
- let payload;
1184
- try {
1185
- payload = JSON.parse(stdinText.trim());
1186
- }
1187
- catch {
1188
- // payload stays undefined; the isObjectLike check below rejects it
1189
- // the same way it would reject an explicit null.
1190
- }
1191
- if (!isObjectLike(payload) || !('transcript_path' in payload) || !isStringValue(payload.transcript_path)) {
1192
- // Covers non-JSON stdin, a JSON value that isn't an object, and
1193
- // `"transcript_path": null` (or the key missing entirely) — all fail
1194
- // the string check. Log and skip; never fall through to auto-discovery.
1195
- appendPreCompactLog(logFile, 'skip: malformed or incomplete PreCompact payload (missing string transcript_path)');
1196
- return;
1197
- }
1198
- if ('session_id' in payload && isStringValue(payload.session_id))
1199
- sessionId = payload.session_id;
1200
- payloadTranscriptPath = payload.transcript_path;
1201
- payloadCwd = 'cwd' in payload && isStringValue(payload.cwd) ? payload.cwd : null;
1202
- payloadTrigger = 'trigger' in payload && isStringValue(payload.trigger) ? payload.trigger : null;
1203
- }
1204
- // X11: payload transcript_path must end .jsonl. No directory-containment
1205
- // check is applied on top of this — CLAUDE_CONFIG_DIR can relocate the
1206
- // transcript root entirely, so a path-prefix allowlist would just reject
1207
- // legitimate relocated installs. The trust boundary here is process
1208
- // identity, not path shape: a local process able to feed this hook
1209
- // arbitrary stdin already runs as the same user who owns every transcript
1210
- // this check could gate on, so containment buys no real isolation.
1211
- if (payloadTranscriptPath !== null && !/\.jsonl$/i.test(payloadTranscriptPath)) {
1212
- appendPreCompactLog(logFile, `skip: payload transcript_path is not a .jsonl file: ${payloadTranscriptPath}`);
1148
+ const receipt = readClaudeCodePreCompact(stdinText, stdinTimedOut);
1149
+ if (receipt.status !== 'received') {
1150
+ appendPreCompactLog(logFile, `skip: ${receipt.reason}`);
1213
1151
  return;
1214
1152
  }
1153
+ const { sessionId, transcriptPath: payloadTranscriptPath, cwd: payloadCwd, trigger: payloadTrigger } = receipt.input;
1215
1154
  // The record is the "something saved before every compaction", so it lands even when no snapshot is derivable below.
1216
1155
  let recordId = null;
1217
1156
  if (sessionId !== null && sessionId !== '') {
@@ -10,7 +10,7 @@ import { RejectedValueError } from '../rejection.js';
10
10
  import { explainMatch } from '../search.js';
11
11
  import { embedMemory } from '../embeddings.js';
12
12
  import { loadConfig } from '../config.js';
13
- import { openHippoDb, closeHippoDb } from '../db.js';
13
+ import { openHippoDb, closeHippoDb, isSqliteBusy, noteStoreBusy } from '../db.js';
14
14
  import { hookPayloadSessionId, isSubagentPayload, recordTokenUse } from '../token-ledger.js';
15
15
  import { isGitRepo, fetchGitLog, extractLessons, partitionLessons } from '../autolearn.js';
16
16
  import { storedTextKeys, duplicateKey } from '../same-text.js';
@@ -22,7 +22,7 @@ import { extractPathTags } from '../path-context.js';
22
22
  import { getGlobalRoot, initGlobal } from '../shared.js';
23
23
  import { DAILY_TASK_NAME, buildDailyRunnerCommand, buildSchtasksCreateArgs, buildWindowsTaskRun } from '../scheduler.js';
24
24
  import { sanitizeLogMessage } from '../capture.js';
25
- import { appendAuditEvent } from '../audit.js';
25
+ import { appendAuditEvent, reportAuditWriteFailure } from '../audit.js';
26
26
  import { createHash } from 'node:crypto';
27
27
  import * as client from '../client.js';
28
28
  import { detectServer, removePidfileIfOwned } from '../server-detect.js';
@@ -77,8 +77,9 @@ export function emitCliAudit(hippoRoot, op, targetId, metadata) {
77
77
  closeHippoDb(db);
78
78
  }
79
79
  }
80
- catch {
81
- // Audit is best-effort; surface failures only via missing rows.
80
+ catch (error) {
81
+ // Best effort: the command already did its work.
82
+ reportAuditWriteFailure(op, String(error), targetId);
82
83
  }
83
84
  }
84
85
  export function requireInit(hippoRoot) {
@@ -158,7 +159,7 @@ export async function runViaServerIfAvailable(hippoRoot, httpFn) {
158
159
  // Every caller of this helper is a non-idempotent write, so replaying on
159
160
  // the direct path would store a row the server may already have committed.
160
161
  // Leave the pidfile alone: the next command's connect-phase failure heals it.
161
- console.error(`hippo: the connection to ${info.url} dropped mid-request, so the write may already have been applied. Not retrying locally. Check with \`hippo recall\` before running this again.`);
162
+ console.error(`hippo: the connection to ${info.url} dropped or timed out mid-request, so the write may already have been applied. Not retrying locally. Check with \`hippo recall\` before running this again.`);
162
163
  process.exit(1);
163
164
  }
164
165
  throw err;
@@ -485,7 +486,10 @@ export function withLedgerDb(hippoRoot, fn) {
485
486
  db = openHippoDb(root);
486
487
  return fn(db);
487
488
  }
488
- catch {
489
+ catch (error) {
490
+ // Best effort, but a busy store is the one failure an operator can act on, so it warns once.
491
+ if (isSqliteBusy(error))
492
+ noteStoreBusy('token ledger row skipped');
489
493
  return undefined;
490
494
  }
491
495
  finally {
package/dist/cli.js CHANGED
@@ -59,7 +59,8 @@ import { resolveEmbeddingProvider } from './embedding-provider.js';
59
59
  import { loadPhysicsState, resetAllPhysicsState } from './physics-state.js';
60
60
  import { computeSystemEnergy, vecNorm } from './physics.js';
61
61
  import { loadConfig } from './config.js';
62
- import { openHippoDb, closeHippoDb } from './db.js';
62
+ import { ensurePilotArm, hashArm, readPilotArm } from './pilot-arm.js';
63
+ import { openHippoDb, closeHippoDb, withSharedStoreHandles, HOOK_DB_WAIT_MS, isSqliteBusy, noteStoreBusy } from './db.js';
63
64
  import { runDoctor, formatDoctor } from './doctor.js';
64
65
  import { buildSupportBundle, TAIL_MAX_LINES } from './support-bundle.js';
65
66
  import { PACKAGE_VERSION } from './version.js';
@@ -88,6 +89,7 @@ import { listApiKeys } from './auth.js';
88
89
  import { buildProvenanceCoverage } from './provenance-coverage.js';
89
90
  import { buildCorrectionLatency } from './correction-latency.js';
90
91
  import * as api from './api.js';
92
+ import { vetSecrets } from './secret-detect.js';
91
93
  import * as predictionsModule from './predictions.js';
92
94
  import { computePlanningFallacyOutput } from './predictions.js';
93
95
  import * as decisionsModule from './decisions.js';
@@ -695,6 +697,8 @@ async function cmdRemember(hippoRoot, text, flags) {
695
697
  console.log(` Tags: ${entry.tags.join(', ')}`);
696
698
  if (entry.pinned)
697
699
  console.log(' Pinned (no decay)');
700
+ for (const w of vetSecrets(entry.content, entry.tags, false).warnings)
701
+ console.error(`Warning: ${w}`);
698
702
  void embedMemory(targetRoot, entry);
699
703
  const config = loadConfig(targetRoot);
700
704
  const shouldExtract = flags['extract'] || config.extraction.enabled === true;
@@ -1908,6 +1912,10 @@ function cmdCompactResume(hippoRoot, stdinText, stdinTimedOut) {
1908
1912
  }
1909
1913
  }
1910
1914
  }
1915
+ // A compaction follows a prompt or SessionStart that booked the arm, so this only reads it.
1916
+ if (!suppressOutput && payloadSessionId !== null && inPilotHoldout(hippoRoot, resolveTenantId({}), payloadSessionId, false)) {
1917
+ suppressOutput = true;
1918
+ }
1911
1919
  if (!suppressOutput) {
1912
1920
  const tenantId = resolveTenantId({});
1913
1921
  const snapshot = loadFreshActiveTaskSnapshot(hippoRoot, tenantId, { maxAgeMs: COMPACT_RESUME_MAX_AGE_MS });
@@ -5191,6 +5199,18 @@ function cmdCurrent(hippoRoot, args, flags) {
5191
5199
  console.error('Usage: hippo current <show>');
5192
5200
  process.exit(1);
5193
5201
  }
5202
+ /** Hook commands share one handle per store and wait at most HOOK_DB_WAIT_MS for a lock; a store still busy after that skips the hook's work with one warning, exit 0. */
5203
+ async function runHookWithStores(fn) {
5204
+ try {
5205
+ return await withSharedStoreHandles(fn, { busyWaitMs: HOOK_DB_WAIT_MS });
5206
+ }
5207
+ catch (error) {
5208
+ if (!isSqliteBusy(error))
5209
+ throw error;
5210
+ noteStoreBusy('hook skipped');
5211
+ return undefined;
5212
+ }
5213
+ }
5194
5214
  async function cmdContext(hippoRoot, args, flags, stdinText) {
5195
5215
  const rec = startDeliveryRecorder(hippoRoot, flags, stdinText);
5196
5216
  // No try/finally: a render throw keeps its own exit code and writes no event.
@@ -5231,6 +5251,22 @@ function flushDeliveryRecorder(rec, db) {
5231
5251
  console.error(`[hippo] delivery ledger write failed: ${error instanceof Error ? error.message : String(error)}`);
5232
5252
  }
5233
5253
  }
5254
+ /**
5255
+ * Whether this session sits in the pilot's holdout arm (src/pilot-arm.ts). Off at rate 0 and with no session id.
5256
+ * `write` books the arm row; a read-only caller (env-only id, sub-agent) follows the stored arm, else the hash.
5257
+ */
5258
+ function inPilotHoldout(hippoRoot, tenantId, sessionId, write) {
5259
+ if (sessionId === undefined || sessionId.trim() === '')
5260
+ return false;
5261
+ const root = isInitialized(hippoRoot) ? hippoRoot : isInitialized(getGlobalRoot()) ? getGlobalRoot() : null;
5262
+ if (root === null)
5263
+ return false;
5264
+ const rate = loadConfig(root).pilot.holdoutRateBp;
5265
+ if (rate <= 0)
5266
+ return false;
5267
+ const arm = withLedgerDb(hippoRoot, (db) => write ? ensurePilotArm(db, tenantId, sessionId, rate) : readPilotArm(db, sessionId) ?? hashArm(sessionId, rate));
5268
+ return (arm ?? hashArm(sessionId, rate)) === 'holdout';
5269
+ }
5234
5270
  async function renderContext(hippoRoot, args, flags, stdinText, rec) {
5235
5271
  // --pinned-only fires on every UserPromptSubmit — including in directories
5236
5272
  // that don't have a local .hippo. Skip requireInit for that path and fall
@@ -5240,30 +5276,6 @@ async function renderContext(hippoRoot, args, flags, stdinText, rec) {
5240
5276
  if (!pinnedOnly) {
5241
5277
  requireInit(hippoRoot);
5242
5278
  }
5243
- const budget = parseBudgetFlag(flags['budget'], 1500);
5244
- if (budget <= 0) {
5245
- rec?.disabled();
5246
- return;
5247
- }
5248
- // Resolve query: explicit args, --auto (git diff via CLI-side helper), or
5249
- // fall through to api.getContext's '*' fallback. api.getContext is host-
5250
- // agnostic so the auto-detect (which shells out to git) stays CLI-side.
5251
- let query = args.join(' ').trim();
5252
- if (!query && flags['auto']) {
5253
- query = autoDetectContext();
5254
- }
5255
- // Scope detection (CLI-side: uses cwd). api.getContext takes the resolved
5256
- // scope via opts.scope to stay host-agnostic.
5257
- const ctxExplicitScope = flags['scope'] !== undefined ? String(flags['scope']).trim() : null;
5258
- const ctxActiveScope = ctxExplicitScope || detectScope();
5259
- const ctx = {
5260
- hippoRoot,
5261
- tenantId: resolveTenantId({}),
5262
- actor: api.adminActor('cli'),
5263
- };
5264
- // v39 memory scope isolation: --cross-project re-includes other-project
5265
- // memories (rendered under a demarcated section below).
5266
- const crossProject = flags['cross-project'] === true;
5267
5279
  // DF1 T2: resolve the calling session's id for the bounded active-task-
5268
5280
  // snapshot read (api.getContext -> loadFreshActiveTaskSnapshot). Stdin
5269
5281
  // payload (the UserPromptSubmit hook JSON) wins; falls back to
@@ -5291,6 +5303,36 @@ async function renderContext(hippoRoot, args, flags, stdinText, rec) {
5291
5303
  const ledgerSessionId = subagent ? undefined : currentSessionId;
5292
5304
  if (subagent)
5293
5305
  payloadSessionId = undefined;
5306
+ // The pilot arm is booked at the first hook call whatever the flags, so the holdout sees no budget or content branch.
5307
+ const resolvedTenant = resolveTenantId({});
5308
+ if (inPilotHoldout(hippoRoot, resolvedTenant, currentSessionId, payloadSessionId !== undefined)) {
5309
+ rec?.disabled();
5310
+ return;
5311
+ }
5312
+ const budget = parseBudgetFlag(flags['budget'], 1500);
5313
+ if (budget <= 0) {
5314
+ rec?.disabled();
5315
+ return;
5316
+ }
5317
+ // Resolve query: explicit args, --auto (git diff via CLI-side helper), or
5318
+ // fall through to api.getContext's '*' fallback. api.getContext is host-
5319
+ // agnostic so the auto-detect (which shells out to git) stays CLI-side.
5320
+ let query = args.join(' ').trim();
5321
+ if (!query && flags['auto']) {
5322
+ query = autoDetectContext();
5323
+ }
5324
+ // Scope detection (CLI-side: uses cwd). api.getContext takes the resolved
5325
+ // scope via opts.scope to stay host-agnostic.
5326
+ const ctxExplicitScope = flags['scope'] !== undefined ? String(flags['scope']).trim() : null;
5327
+ const ctxActiveScope = ctxExplicitScope || detectScope();
5328
+ const ctx = {
5329
+ hippoRoot,
5330
+ tenantId: resolvedTenant,
5331
+ actor: api.adminActor('cli'),
5332
+ };
5333
+ // v39 memory scope isolation: --cross-project re-includes other-project
5334
+ // memories (rendered under a demarcated section below).
5335
+ const crossProject = flags['cross-project'] === true;
5294
5336
  const format = String(flags['format'] ?? 'markdown');
5295
5337
  const framing = String(flags['framing'] ?? 'observe');
5296
5338
  const opts = {
@@ -5448,7 +5490,10 @@ async function renderContext(hippoRoot, args, flags, stdinText, rec) {
5448
5490
  items: staticItems.length, tokens: estimateTokens(finalStatic), hash: blockHash(finalStatic),
5449
5491
  });
5450
5492
  }
5451
- catch { /* best effort; see withLedgerDb doc comment */ }
5493
+ catch (error) {
5494
+ if (isSqliteBusy(error))
5495
+ noteStoreBusy('token ledger row skipped');
5496
+ }
5452
5497
  }
5453
5498
  if (recallBlock) {
5454
5499
  try {
@@ -5457,7 +5502,10 @@ async function renderContext(hippoRoot, args, flags, stdinText, rec) {
5457
5502
  items: recallItems.length, tokens: estimateTokens(recallBlock), hash: blockHash(recallBlock),
5458
5503
  });
5459
5504
  }
5460
- catch { /* best effort; see withLedgerDb doc comment */ }
5505
+ catch (error) {
5506
+ if (isSqliteBusy(error))
5507
+ noteStoreBusy('token ledger row skipped');
5508
+ }
5461
5509
  }
5462
5510
  flushDeliveryRecorder(rec, db);
5463
5511
  });
@@ -5678,6 +5726,10 @@ function cmdLearn(hippoRoot, flags) {
5678
5726
  // ---------------------------------------------------------------------------
5679
5727
  // Import command
5680
5728
  // ---------------------------------------------------------------------------
5729
+ function warnRedacted(count) {
5730
+ if (count)
5731
+ console.error(`Warning: secret-shaped text was redacted from ${count} imported ${count === 1 ? 'entry' : 'entries'} before storing`);
5732
+ }
5681
5733
  function cmdImport(hippoRoot, args, flags) {
5682
5734
  const useGlobal = Boolean(flags['global']);
5683
5735
  const dryRun = Boolean(flags['dry-run']);
@@ -5758,6 +5810,7 @@ function cmdImport(hippoRoot, args, flags) {
5758
5810
  if ((vaultResult.rejected ?? 0) > 0) {
5759
5811
  console.log(` Rejected (tombstoned): ${vaultResult.rejected}`);
5760
5812
  }
5813
+ warnRedacted(vaultResult.redacted);
5761
5814
  console.log(` ${dryRun ? 'Would archive: ' : 'Archived (removed): '}${vaultResult.archived ?? 0}`);
5762
5815
  console.log(` Store: ${hippoRoot}`);
5763
5816
  // Batch producer, same contract as the single-file import below: vault rows
@@ -5829,6 +5882,7 @@ function cmdImport(hippoRoot, args, flags) {
5829
5882
  if ((result.rejected ?? 0) > 0) {
5830
5883
  console.log(` Rejected (tombstoned): ${result.rejected}`);
5831
5884
  }
5885
+ warnRedacted(result.redacted);
5832
5886
  if (dryRun) {
5833
5887
  console.log('\n (dry run - nothing written)');
5834
5888
  if (result.entries.length > 0) {
@@ -7922,6 +7976,8 @@ async function main(command, args, flags, hippoRoot) {
7922
7976
  });
7923
7977
  console.log(`Remembered [${result.id}] (via ${info.url})`);
7924
7978
  console.log(` Kind: ${result.kind} | Tenant: ${result.tenantId}`);
7979
+ for (const w of result.warnings ?? [])
7980
+ console.error(`Warning: ${w}`);
7925
7981
  });
7926
7982
  if (remembered)
7927
7983
  break;
@@ -8012,11 +8068,13 @@ async function main(command, args, flags, hippoRoot) {
8012
8068
  case 'pre-compact': {
8013
8069
  // Bounded wait, not a TTY guard: an idle non-TTY pipe must not hang.
8014
8070
  const { text: stdinText, timedOut: stdinTimedOut } = await readStdinBounded();
8015
- resetHookInjection(hippoRoot, stdinText, null);
8016
- await cmdPreCompact(hookStoreRoot(hippoRoot), {
8017
- stdinText,
8018
- stdinTimedOut,
8019
- logFile: typeof flags['log-file'] === 'string' ? flags['log-file'] : undefined,
8071
+ await runHookWithStores(async () => {
8072
+ resetHookInjection(hippoRoot, stdinText, null);
8073
+ await cmdPreCompact(hookStoreRoot(hippoRoot), {
8074
+ stdinText,
8075
+ stdinTimedOut,
8076
+ logFile: typeof flags['log-file'] === 'string' ? flags['log-file'] : undefined,
8077
+ });
8020
8078
  });
8021
8079
  break;
8022
8080
  }
@@ -8025,7 +8083,7 @@ async function main(command, args, flags, hippoRoot) {
8025
8083
  const { text } = await readStdinBounded();
8026
8084
  const logFlag = flags['log-file'];
8027
8085
  const store = hookStoreRoot(hippoRoot);
8028
- const line = cmdPostCompact(store, {
8086
+ const line = await runHookWithStores(() => cmdPostCompact(store, {
8029
8087
  stdinText: text,
8030
8088
  logFile: logFlag === true || logFlag === false || Array.isArray(logFlag) ? undefined : logFlag,
8031
8089
  // Passed in, since capture.ts importing the sync would close an import cycle.
@@ -8037,8 +8095,8 @@ async function main(command, args, flags, hippoRoot) {
8037
8095
  for (const warning of report.warnings)
8038
8096
  log(`agent memories: ${warning}`);
8039
8097
  },
8040
- });
8041
- if (line !== null)
8098
+ }));
8099
+ if (line !== null && line !== undefined)
8042
8100
  console.log(line);
8043
8101
  break;
8044
8102
  }
@@ -8051,7 +8109,8 @@ async function main(command, args, flags, hippoRoot) {
8051
8109
  const payload = (text ?? '').trim();
8052
8110
  if (isInitialized(root) && payload) {
8053
8111
  // SAFETY: JSON.parse returns a JSON value by definition.
8054
- captureToolFailure(root, resolveTenantId({}), JSON.parse(payload));
8112
+ const failure = JSON.parse(payload);
8113
+ await runHookWithStores(() => captureToolFailure(root, resolveTenantId({}), failure));
8055
8114
  }
8056
8115
  }
8057
8116
  catch {
@@ -8061,8 +8120,10 @@ async function main(command, args, flags, hippoRoot) {
8061
8120
  }
8062
8121
  case 'compact-resume': {
8063
8122
  const { text: stdinText, timedOut: stdinTimedOut } = await readStdinBounded();
8064
- resetHookInjection(hippoRoot, stdinText, 'compact');
8065
- cmdCompactResume(hookStoreRoot(hippoRoot), stdinText, stdinTimedOut);
8123
+ await runHookWithStores(() => {
8124
+ resetHookInjection(hippoRoot, stdinText, 'compact');
8125
+ cmdCompactResume(hookStoreRoot(hippoRoot), stdinText, stdinTimedOut);
8126
+ });
8066
8127
  break;
8067
8128
  }
8068
8129
  case 'codex-run':
@@ -8337,7 +8398,7 @@ async function main(command, args, flags, hippoRoot) {
8337
8398
  // Bounded, not a TTY guard (DF1 T2, docs/plans/2026-08-23-df1-snapshot-lifecycle.md):
8338
8399
  // the hot stdin path and a manual run share this one command.
8339
8400
  const { text: stdinText } = await readStdinBounded();
8340
- await cmdContext(hookStoreRoot(hippoRoot), args, flags, stdinText);
8401
+ await runHookWithStores(() => cmdContext(hookStoreRoot(hippoRoot), args, flags, stdinText));
8341
8402
  break;
8342
8403
  }
8343
8404
  case 'hook':
package/dist/client.js CHANGED
@@ -13,6 +13,8 @@
13
13
  * Network errors (ECONNREFUSED on a stale pidfile, etc.) propagate as the
14
14
  * native fetch failure so the caller can detect them and self-heal.
15
15
  */
16
+ /** A write the local server has not answered in this long is stuck; the caller treats it as delivery-unknown. */
17
+ const SERVER_TIMEOUT_MS = 30_000;
16
18
  function buildHeaders(apiKey, withBody) {
17
19
  const headers = {};
18
20
  if (withBody)
@@ -58,6 +60,7 @@ export async function remember(serverUrl, apiKey, opts) {
58
60
  const res = await fetch(`${serverUrl}/v1/memories`, {
59
61
  method: 'POST',
60
62
  headers: buildHeaders(apiKey, true),
63
+ signal: AbortSignal.timeout(SERVER_TIMEOUT_MS),
61
64
  body: JSON.stringify(opts),
62
65
  });
63
66
  if (!res.ok)
@@ -69,6 +72,7 @@ export async function forget(serverUrl, apiKey, id) {
69
72
  const res = await fetch(`${serverUrl}/v1/memories/${encodeURIComponent(id)}`, {
70
73
  method: 'DELETE',
71
74
  headers: buildHeaders(apiKey, false),
75
+ signal: AbortSignal.timeout(SERVER_TIMEOUT_MS),
72
76
  });
73
77
  if (!res.ok)
74
78
  await throwForStatus(res);
@@ -79,6 +83,7 @@ export async function promote(serverUrl, apiKey, id) {
79
83
  const res = await fetch(`${serverUrl}/v1/memories/${encodeURIComponent(id)}/promote`, {
80
84
  method: 'POST',
81
85
  headers: buildHeaders(apiKey, false),
86
+ signal: AbortSignal.timeout(SERVER_TIMEOUT_MS),
82
87
  });
83
88
  if (!res.ok)
84
89
  await throwForStatus(res);
@@ -89,6 +94,7 @@ export async function archiveRaw(serverUrl, apiKey, id, reason) {
89
94
  const res = await fetch(`${serverUrl}/v1/memories/${encodeURIComponent(id)}/archive`, {
90
95
  method: 'POST',
91
96
  headers: buildHeaders(apiKey, true),
97
+ signal: AbortSignal.timeout(SERVER_TIMEOUT_MS),
92
98
  body: JSON.stringify({ reason }),
93
99
  });
94
100
  if (!res.ok)
@@ -123,6 +129,9 @@ export function classifyTransportFailure(err) {
123
129
  }
124
130
  if (message.includes('socket hang up') || message.includes('fetch failed'))
125
131
  return 'delivery-unknown';
132
+ // The request was sent and the server went quiet, so it may have committed.
133
+ if (err.name === 'TimeoutError')
134
+ return 'delivery-unknown';
126
135
  return 'none';
127
136
  }
128
137
  //# sourceMappingURL=client.js.map
@@ -1,7 +1,7 @@
1
1
  // Files named by a Codex apply_patch call, read the way openai/codex codex-rs/apply-patch reads them
2
2
  // (parser.rs for the body, invocation.rs for the shell forms it runs as apply_patch).
3
3
  import * as path from 'path';
4
- import { isStringValue } from './capture.js';
4
+ import { isStringValue } from './capture-contract.js';
5
5
  /** Codex prints this line only when a patch applied (apply-patch lib.rs). */
6
6
  export const PATCH_SUCCESS_LINE = 'Success. Updated the following files:';
7
7
  const BEGIN_PATCH = '*** Begin Patch';
@@ -2,7 +2,7 @@ import { type DatabaseSyncLike } from './db.js';
2
2
  /** PostCompact has 10 s in all (PreCompact 30 s), so a locked store must be given up on early. */
3
3
  export declare const COMPACTION_DB_WAIT_MS = 2000;
4
4
  /** Tested verbatim: Claude Code hands PreCompact stdout to the summariser as instructions. */
5
- export declare const PRE_COMPACT_INSTRUCTION = "In your summary, add a last section titled 'Memories for hippo'. List, one per line starting with '- ', each lesson learned, decision made (with its reason) and correction the user gave in this session that should outlive it. Write each as a standalone sentence that names its subject. Leave out anything an earlier summary already listed under 'Memories for hippo'. Write '- none' if nothing new remains.";
5
+ export declare const PRE_COMPACT_INSTRUCTION = "In your summary, add a last section titled 'Memories for hippo'. List, one per line starting with '- ', each lesson learned, decision made (with its reason) and correction the user gave in this session that should outlive it. Write each as a standalone sentence that names its subject. Leave out anything an earlier summary already listed under 'Memories for hippo', and anything this session already saved with `hippo remember`. Write '- none' if nothing new remains.";
6
6
  /** Long enough that a live hook has finished with its own record. */
7
7
  export declare const REPLAY_AFTER_MS: number;
8
8
  /** Claude Code deletes transcripts after 30 days, so an older gap can never be filled. */
@@ -1,7 +1,8 @@
1
1
  // One record per Claude Code compaction, and what turns its summary into kept memories.
2
2
  import * as fs from 'fs';
3
3
  import * as path from 'path';
4
- import { errorMessage, isObjectLike, isStringValue, readTranscriptTail, truncateCodePointSafe } from './capture.js';
4
+ import { errorMessage, readTranscriptTail, truncateCodePointSafe } from './capture.js';
5
+ import { isObjectLike, isStringValue } from './capture-contract.js';
5
6
  import { compactSummaryBody, parseCompactionItems, selectItemRows } from './compaction-items.js';
6
7
  import { loadConfig } from './config.js';
7
8
  import { closeHippoDb, isSqliteBusy, openHippoDb } from './db.js';
@@ -14,7 +15,7 @@ import { resolveTenantId } from './tenant.js';
14
15
  /** PostCompact has 10 s in all (PreCompact 30 s), so a locked store must be given up on early. */
15
16
  export const COMPACTION_DB_WAIT_MS = 2000;
16
17
  /** Tested verbatim: Claude Code hands PreCompact stdout to the summariser as instructions. */
17
- export const PRE_COMPACT_INSTRUCTION = "In your summary, add a last section titled 'Memories for hippo'. List, one per line starting with '- ', each lesson learned, decision made (with its reason) and correction the user gave in this session that should outlive it. Write each as a standalone sentence that names its subject. Leave out anything an earlier summary already listed under 'Memories for hippo'. Write '- none' if nothing new remains.";
18
+ export const PRE_COMPACT_INSTRUCTION = "In your summary, add a last section titled 'Memories for hippo'. List, one per line starting with '- ', each lesson learned, decision made (with its reason) and correction the user gave in this session that should outlive it. Write each as a standalone sentence that names its subject. Leave out anything an earlier summary already listed under 'Memories for hippo', and anything this session already saved with `hippo remember`. Write '- none' if nothing new remains.";
18
19
  const SUMMARY_MAX_CHARS = 256 * 1024;
19
20
  /** Long enough that a live hook has finished with its own record. */
20
21
  export const REPLAY_AFTER_MS = 10 * 60_000;
package/dist/config.d.ts CHANGED
@@ -145,6 +145,11 @@ export interface HippoConfig {
145
145
  deliveryLedger: {
146
146
  enabled: boolean;
147
147
  };
148
+ /** Pilot holdout (src/pilot-arm.ts): share of sessions, in basis points 0-10000, that get no memories pushed.
149
+ * Default 0 = off. Read from the store the token ledger writes to. */
150
+ pilot: {
151
+ holdoutRateBp: number;
152
+ };
148
153
  }
149
154
  export declare function loadConfig(hippoRoot: string): HippoConfig;
150
155
  //# sourceMappingURL=config.d.ts.map
package/dist/config.js CHANGED
@@ -88,6 +88,9 @@ const DEFAULT_CONFIG = {
88
88
  deliveryLedger: {
89
89
  enabled: false,
90
90
  },
91
+ pilot: {
92
+ holdoutRateBp: 0,
93
+ },
91
94
  };
92
95
  function isMemoryValueConfig(value) {
93
96
  return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -115,6 +118,19 @@ function deliveryLedgerEnabled(value) {
115
118
  `(got ${JSON.stringify(value)}) - using false.`);
116
119
  return false;
117
120
  }
121
+ // Only an integer 0..10000 counts; anything else warns and turns the pilot off.
122
+ function pilotHoldoutRate(value) {
123
+ if (value === undefined)
124
+ return 0;
125
+ const rate = value?.holdoutRateBp;
126
+ if (rate === undefined && value !== null && value.constructor === Object)
127
+ return 0;
128
+ if (Number.isInteger(rate) && rate >= 0 && rate <= 10000)
129
+ return rate;
130
+ console.error(`Warning: config.json's "pilot" must be an object like {"holdoutRateBp": 2000}, an integer from 0 to 10000 ` +
131
+ `(got ${JSON.stringify(value)}) - using 0 (pilot off).`);
132
+ return 0;
133
+ }
118
134
  function agentMemoryTools(value) {
119
135
  if (value === undefined || value === null)
120
136
  return null;
@@ -226,6 +242,7 @@ export function loadConfig(hippoRoot) {
226
242
  },
227
243
  agentMemories: { tools: agentMemoryTools(raw.agentMemories?.tools) },
228
244
  deliveryLedger: { enabled: deliveryLedgerEnabled(raw.deliveryLedger) },
245
+ pilot: { holdoutRateBp: pilotHoldoutRate(raw.pilot) },
229
246
  };
230
247
  }
231
248
  catch (err) {
@@ -1,13 +1,16 @@
1
1
  import { openHippoDb, closeHippoDb } from '../../db.js';
2
2
  import { verifyGitHubSignature } from './signature.js';
3
3
  import { isGitHubWebhookEnvelope } from './types.js';
4
+ import { redactPayload, DLQ_REDACTED_NOTE } from '../../secret-detect.js';
4
5
  export function writeToDlq(db, opts) {
6
+ const rawPayload = redactPayload(opts.rawPayload);
7
+ const redacted = rawPayload !== opts.rawPayload;
5
8
  const result = db
6
9
  .prepare(`INSERT INTO github_dlq
7
10
  (tenant_id, raw_payload, error, event_name, delivery_id, signature,
8
11
  installation_id, repo_full_name, received_at, bucket)
9
12
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
10
- .run(opts.tenantId ?? '__unroutable__', opts.rawPayload, opts.error, opts.eventName ?? null, opts.deliveryId ?? null, opts.signature ?? null, opts.installationId ?? null, opts.repoFullName ?? null, new Date().toISOString(), opts.bucket ?? 'parse_error');
13
+ .run(opts.tenantId ?? '__unroutable__', rawPayload, redacted ? `${opts.error}; ${DLQ_REDACTED_NOTE}` : opts.error, opts.eventName ?? null, opts.deliveryId ?? null, redacted ? null : opts.signature ?? null, opts.installationId ?? null, opts.repoFullName ?? null, new Date().toISOString(), opts.bucket ?? 'parse_error');
11
14
  return Number(result.lastInsertRowid);
12
15
  }
13
16
  const SELECT_COLUMNS = `id, tenant_id, raw_payload, error, event_name, delivery_id,
@@ -107,7 +110,7 @@ export async function replayDlqEntry(ctx, id, opts = {}) {
107
110
  status: 'sig_missing',
108
111
  memoryId: null,
109
112
  retryCount: row.retryCount,
110
- reason: 'legacy row without signature; pass --force to replay',
113
+ reason: 'row has no signature (legacy, or redacted before storing); pass --force to replay',
111
114
  };
112
115
  }
113
116
  const sigOk = verifyGitHubSignature({
@@ -10,6 +10,7 @@
10
10
  * operator signal, so this code path is now load-bearing.
11
11
  */
12
12
  import { parseRateLimit } from './ratelimit.js';
13
+ import { fetchWithRetry } from '../../http-retry.js';
13
14
  export class GitHubFetchError extends Error {
14
15
  status;
15
16
  bodyExcerpt;
@@ -42,14 +43,15 @@ function headersToRecord(h) {
42
43
  });
43
44
  return Object.fromEntries(entries);
44
45
  }
46
+ const GITHUB_TIMEOUT_MS = 30_000;
45
47
  export const realGitHubFetcher = async ({ url, token }) => {
46
- const res = await fetch(url, {
48
+ const res = await fetchWithRetry(url, {
47
49
  headers: {
48
50
  Authorization: `Bearer ${token}`,
49
51
  Accept: 'application/vnd.github+json',
50
52
  'X-GitHub-Api-Version': '2022-11-28',
51
53
  },
52
- });
54
+ }, { timeoutMs: GITHUB_TIMEOUT_MS });
53
55
  const headers = headersToRecord(res.headers);
54
56
  const rateLimit = parseRateLimit(headers, res.status);
55
57
  // Codex P1 #4: don't silently turn 401/403/404/500 into empty pages.
@@ -5,12 +5,16 @@ import { resolveTenantForTeam } from './tenant-routing.js';
5
5
  import { verifySlackSignature } from './signature.js';
6
6
  import { isSlackEventEnvelope, isSlackMessageEvent } from './types.js';
7
7
  import { handleMessageDeleted } from './deletion.js';
8
+ import { redactPayload, DLQ_REDACTED_NOTE } from '../../secret-detect.js';
8
9
  export function writeToDlq(db, opts) {
10
+ const rawPayload = redactPayload(opts.rawPayload);
11
+ // A redacted body can never match its signature, so the row keeps none and replay needs --force.
12
+ const redacted = rawPayload !== opts.rawPayload;
9
13
  const result = db
10
14
  .prepare(`INSERT INTO slack_dlq
11
15
  (tenant_id, team_id, raw_payload, error, received_at, bucket, signature, slack_timestamp)
12
16
  VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
13
- .run(opts.tenantId ?? '__unroutable__', opts.teamId ?? null, opts.rawPayload, opts.error, new Date().toISOString(), opts.bucket ?? 'parse_error', opts.signature ?? null, opts.slackTimestamp ?? null);
17
+ .run(opts.tenantId ?? '__unroutable__', opts.teamId ?? null, rawPayload, redacted ? `${opts.error}; ${DLQ_REDACTED_NOTE}` : opts.error, new Date().toISOString(), opts.bucket ?? 'parse_error', redacted ? null : opts.signature ?? null, opts.slackTimestamp ?? null);
14
18
  return Number(result.lastInsertRowid);
15
19
  }
16
20
  export function listDlq(db, opts) {
@@ -95,7 +99,7 @@ export function replayDlqEntry(ctx, id, opts = {}) {
95
99
  status: 'sig_missing',
96
100
  memoryId: null,
97
101
  retryCount: row.retryCount,
98
- reason: 'legacy row without signature/timestamp; pass --force to replay',
102
+ reason: 'row has no signature/timestamp (legacy, or redacted before storing); pass --force to replay',
99
103
  };
100
104
  }
101
105
  if (opts.signingSecret) {