hippo-memory 1.46.0 → 1.48.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.
package/README.md CHANGED
@@ -256,7 +256,7 @@ hippo recall "data pipeline" --why --limit 5
256
256
 
257
257
  Input enters the buffer. Important things get encoded into episodic memory. During "sleep," repeated episodes compress into semantic patterns. Weak memories decay and disappear.
258
258
 
259
- The store is SQLite (`.hippo/hippo.db`). The markdown files and `index.json` are mirrors written after each change. From 1.46.0, `index.json` is no longer refreshed on every write; it comes only from an explicit export, so read the store through the CLI, the MCP server or the HTTP API.
259
+ The store is SQLite (`.hippo/hippo.db`). The markdown files are mirrors written after each change. `index.json` is no longer refreshed by writes, deletes or recalls: it is written only when you call `rebuildIndex()` from the package, so a copy an older version left on disk goes stale. Read the store through the CLI, the MCP server or the HTTP API.
260
260
 
261
261
  ```mermaid
262
262
  flowchart TD
@@ -629,7 +629,9 @@ hippo watch "npm run build"
629
629
  | `hippo dormant restore <id>` | Bring a dormant memory back to active memory |
630
630
  | `hippo dormant forget <id>` | Delete a dormant memory permanently |
631
631
  | `hippo doctor [--json]` | Check the install: Node, store, schema, sleep, agent hooks; each problem names its fix |
632
+ | `hippo support-bundle [--out <file>] [--include-logs]` | Write a redacted JSON file for a support ticket: versions, doctor checks, config, store counts and log names, never memory text; `--include-logs` adds each log's last 200 lines, which can quote it |
632
633
  | `hippo tokens [--days n]` | Estimated tokens of memory text handed to agents, per surface, and what skipping unchanged hook blocks saved |
634
+ | `hippo failures [--days n]` | Failed tool calls the capture-error hook saw, by outcome, and how many errors first happened in another session |
633
635
  | `hippo embed` | Embed all memories for semantic search |
634
636
  | `hippo embed --status` | Show embedding coverage |
635
637
  | `hippo watch "<command>"` | Run command, auto-learn from failures |
@@ -725,7 +727,7 @@ For Claude Code, it also adds:
725
727
  - a `PreCompact` hook that runs `hippo pre-compact` before the transcript gets summarized. It saves a working-state snapshot (task/summary/next step) and extracts durable memories from the tail, so mid-session compaction can't drop them.
726
728
  - a second `SessionStart` hook (matcher `compact`) that runs `hippo compact-resume`, printing that snapshot plus the recent session trail back into context right after compaction.
727
729
  - a `PostCompact` hook that runs `hippo post-compact`, which tells you what was saved ("Hippo saved your task snapshot and 2 new memories before compacting"). It prints nothing when nothing was saved.
728
- - a `PostToolUseFailure` hook that runs `hippo capture-error`, which stores a failed tool call as an error memory. It skips interrupts, declined permissions and searches that found nothing, and stores a repeated failure once.
730
+ - a `PostToolUseFailure` hook that runs `hippo capture-error`, which stores a failed tool call as an error memory. It skips interrupts, declined permissions and searches that found nothing, and stores a repeated failure once. It also logs every failure, stored or not, for `hippo failures`: the session, the tool and hashes of the error, never its text. A hash is not anonymous, since anyone who guesses an error's text can check it against the hash. The log keeps 90 days.
729
731
 
730
732
  To remove: `hippo hook uninstall claude-code`
731
733
 
package/bin/hippo.js CHANGED
File without changes
package/dist/api.d.ts CHANGED
@@ -11,6 +11,7 @@ import { deleteEntry, loadAllEntries, type TaskSnapshot, type SessionEvent } fro
11
11
  import { type RejectedValueRow } from './rejection.js';
12
12
  import { type DormantMemory, type ListDormantOpts } from './dormant.js';
13
13
  import { type TokenSummary, type TokenSurface } from './token-ledger.js';
14
+ import { type FailureSummary } from './failure-log.js';
14
15
  import { type SessionHandoff } from './handoff.js';
15
16
  import { type MemoryKind, type MemoryEntry } from './memory.js';
16
17
  import { auditMemories, type AuditEvent, type AuditOp } from './audit.js';
@@ -82,6 +83,7 @@ import { isPrivateScope, passesScopeFilterForRecall } from './recall-scope.js';
82
83
  export { isPrivateScope, passesScopeFilterForRecall };
83
84
  export { passesCliRecallScopeFilter, ScopeForbiddenError } from './recall-scope.js';
84
85
  export type { TokenSummary, TokenSurface, TokenSurfaceSummary } from './token-ledger.js';
86
+ export type { FailureSummary } from './failure-log.js';
85
87
  export { classifyOriginProject } from './project-identity.js';
86
88
  /**
87
89
  * v39 S4: the secret half of the ambient policy on its own, for surfaces
@@ -1019,6 +1021,10 @@ export declare function recordTokens(ctx: Context, surface: TokenSurface, use: {
1019
1021
  export declare function tokenSummary(ctx: Context, opts?: {
1020
1022
  days?: number;
1021
1023
  }): TokenSummary;
1024
+ /** Failed tool calls by outcome, and repeats across sessions, over the last `days` days (default 30); ROADMAP CD13. */
1025
+ export declare function failureSummary(ctx: Context, opts?: {
1026
+ days?: number;
1027
+ }): FailureSummary;
1022
1028
  /**
1023
1029
  * A tenant's dormant memories (src/dormant.ts): what sleep moved out of
1024
1030
  * active memory instead of deleting, when `dormant.enabled` is on. Newest
package/dist/api.js CHANGED
@@ -13,6 +13,7 @@ import { RejectedValueError } from './rejection.js';
13
13
  import { rejectValue, unrejectValue, listRejectionsForTenant } from './reject-flow.js';
14
14
  import { listDormantRows, readDormantSnapshot, deleteDormantRow, hasDormantRow, } from './dormant.js';
15
15
  import { recordTokenUse, summarizeTokenUse } from './token-ledger.js';
16
+ import { summarizeFailures } from './failure-log.js';
16
17
  import { formatHandoffEvidenceLine } from './handoff.js';
17
18
  import { createMemory, applyOutcome, calculateStrength, Layer, } from './memory.js';
18
19
  import { appendAuditEvent, queryAuditEvents, auditMemories, isContentWorthStoring, } from './audit.js';
@@ -1215,11 +1216,11 @@ export function supersede(ctx, oldId, newContent) {
1215
1216
  }
1216
1217
  // Mirrors after COMMIT, while the db handle is still open. Same
1217
1218
  // invariant as the original writeEntry: a mirror failure leaves disk
1218
- // MISSING the markdown for the new memory (self-heals on next backfill
1219
- // via writeIndexMirror reading the DB) but DOES NOT desync the DB or
1219
+ // MISSING the markdown for the new memory (rebuildIndex rewrites every
1220
+ // markdown mirror from the DB) but DOES NOT desync the DB or
1220
1221
  // roll back the supersede. Logged + swallowed, non-fatal.
1221
1222
  try {
1222
- writeEntryMirrors(ctx.hippoRoot, db, newEntry);
1223
+ writeEntryMirrors(ctx.hippoRoot, newEntry);
1223
1224
  }
1224
1225
  catch (mirrorErr) {
1225
1226
  console.error('supersede: mirror write failed (non-fatal, will self-heal):', mirrorErr);
@@ -1896,16 +1897,28 @@ export function recordTokens(ctx, surface, use) {
1896
1897
  * saved, and mean tokens per session.
1897
1898
  */
1898
1899
  export function tokenSummary(ctx, opts = {}) {
1899
- const days = opts.days !== undefined && Number.isFinite(opts.days) && opts.days > 0 ? opts.days : 30;
1900
- const since = new Date(Date.now() - days * 86_400_000).toISOString();
1901
1900
  const db = openHippoDb(ctx.hippoRoot);
1902
1901
  try {
1903
- return summarizeTokenUse(db, ctx.tenantId, since);
1902
+ return summarizeTokenUse(db, ctx.tenantId, reportWindowStart(opts.days));
1904
1903
  }
1905
1904
  finally {
1906
1905
  closeHippoDb(db);
1907
1906
  }
1908
1907
  }
1908
+ /** Failed tool calls by outcome, and repeats across sessions, over the last `days` days (default 30); ROADMAP CD13. */
1909
+ export function failureSummary(ctx, opts = {}) {
1910
+ const db = openHippoDb(ctx.hippoRoot);
1911
+ try {
1912
+ return summarizeFailures(db, ctx.tenantId, reportWindowStart(opts.days));
1913
+ }
1914
+ finally {
1915
+ closeHippoDb(db);
1916
+ }
1917
+ }
1918
+ function reportWindowStart(days) {
1919
+ const span = days !== undefined && Number.isFinite(days) && days > 0 ? days : 30;
1920
+ return new Date(Date.now() - span * 86_400_000).toISOString();
1921
+ }
1909
1922
  /**
1910
1923
  * A tenant's dormant memories (src/dormant.ts): what sleep moved out of
1911
1924
  * active memory instead of deleting, when `dormant.enabled` is on. Newest
@@ -1985,7 +1998,7 @@ export function restoreDormant(ctx, id) {
1985
1998
  }
1986
1999
  throw err;
1987
2000
  }
1988
- writeEntryMirrors(ctx.hippoRoot, db, restored);
2001
+ writeEntryMirrors(ctx.hippoRoot, restored);
1989
2002
  return restored;
1990
2003
  }
1991
2004
  finally {
@@ -1,20 +1,26 @@
1
1
  import type { JsonValue } from './working-memory.js';
2
2
  /** Why a failure was not stored, or `stored`. */
3
3
  export type CaptureErrorOutcome = 'stored' | 'duplicate' | 'skipped-interrupt' | 'skipped-routine' | 'skipped-invalid';
4
- /** Normalised form used to spot repeats of the same failure. */
5
- export declare function failureSignature(text: string): string;
6
- /**
7
- * The memory text for a failure payload, or the reason it is not stored.
8
- * Pure: no store access.
9
- */
10
- export declare function lessonFromFailure(payload: JsonValue): {
4
+ /** Which routine check skipped a failure; the log keeps it so declines can be told apart from empty searches. */
5
+ export type RoutineRule = 'declined' | 'os-permission' | 'no-match' | 'search-tool' | 'quiet-exit';
6
+ /** What {@link lessonFromFailure} read from a payload; `detail` is the finer failure-log key (untruncated, command head). */
7
+ export type FailureReading = {
8
+ text: string;
9
+ detail: string;
10
+ } | {
11
+ skip: 'skipped-routine';
12
+ rule: RoutineRule;
11
13
  text: string;
14
+ detail: string;
12
15
  } | {
13
- skip: Exclude<CaptureErrorOutcome, 'stored' | 'duplicate'>;
16
+ skip: 'skipped-interrupt' | 'skipped-invalid';
17
+ text: null;
18
+ detail: null;
14
19
  };
15
- /**
16
- * Store a failure payload as an error memory unless it is routine or a
17
- * repeat of an auto-captured error already in the store.
18
- */
20
+ /** Normalised form used to spot repeats. The failure log keeps only its hash, so changing it breaks repeat counts. */
21
+ export declare function failureSignature(text: string): string;
22
+ /** The memory text for a failure payload, or why it is not stored; a routine skip keeps its text for the log. Pure. */
23
+ export declare function lessonFromFailure(payload: JsonValue): FailureReading;
24
+ /** Store a failure as an error memory unless it is routine or a repeat, and log it either way, even when storing throws. */
19
25
  export declare function captureToolFailure(hippoRoot: string, tenantId: string, payload: JsonValue): CaptureErrorOutcome;
20
26
  //# sourceMappingURL=capture-error.d.ts.map
@@ -1,25 +1,20 @@
1
- /**
2
- * `hippo capture-error`: turn a failed tool call into an error memory. Run by
3
- * the Claude Code `PostToolUseFailure` hook (both `hippo hook install
4
- * claude-code` and the plugin), which writes the failure as JSON on stdin.
5
- *
6
- * Most failed tool calls are not lessons. An interrupt, a permission the
7
- * user declined, a search that found nothing or a `grep` that exits 1 is
8
- * routine, and error memories decay slower than other memories, so storing
9
- * them would crowd out real lessons. Those are dropped here, repeats of the
10
- * same failure are stored once, and what is stored is marked `observed`
11
- * (auto-captured, not verified): outcome feedback, not capture, is what
12
- * should strengthen it.
13
- */
1
+ // `hippo capture-error`, run by the Claude Code PostToolUseFailure hook: routine failures and repeats are not
2
+ // stored, because error memories decay slowly and would crowd out real lessons; what is stored stays `observed`
3
+ // until outcome feedback confirms it. Every failure, stored or not, goes to the failure log (ROADMAP CD13).
14
4
  import { createMemory } from './memory.js';
15
5
  import { writeEntry, loadAllEntries } from './store.js';
16
6
  import { loadConfig } from './config.js';
7
+ import { closeHippoDb, openHippoDb } from './db.js';
8
+ import { recordFailure } from './failure-log.js';
9
+ import { blockHash } from './token-ledger.js';
17
10
  const MAX_LEN = 200;
18
- /** Failures that are routine, not lessons. */
19
- const ROUTINE_PATTERNS = [
20
- /user (?:doesn't|does not) want|denied by (?:the )?user|user (?:rejected|declined|denied)|permission (?:denied|to use)|was blocked by (?:a )?hook/i,
21
- /\bno (?:matches|files|results) found\b/i,
22
- ];
11
+ /** The user, a permission prompt or a hook said no: routine, not a lesson. */
12
+ const DECLINED = /user (?:doesn't|does not) want|denied by (?:the )?user|user (?:rejected|declined|denied)|permission to use|was blocked by (?:a )?hook/i;
13
+ /** The OS or a remote host refused access (EACCES, SSH publickey): routine too, but nobody declined anything. */
14
+ const OS_PERMISSION = /permission denied/i;
15
+ /** Leading `cd` or `pushd` steps say where a command ran, not what it ran. */
16
+ const LEADING_CD = /^\s*(?:(?:cd|pushd)\b[^;&|]*(?:&&|\|\||;)\s*)+/;
17
+ const NO_MATCH = /\bno (?:matches|files|results) found\b/i;
23
18
  /** Shell commands whose exit code 1 means "nothing found" or "differs", not an error. */
24
19
  const QUIET_EXIT_1 = /^\s*(?:grep|rg|egrep|fgrep|find|test|\[|diff|cmp|git diff|git grep)\b/;
25
20
  function isString(v) {
@@ -28,48 +23,81 @@ function isString(v) {
28
23
  function isObject(v) {
29
24
  return v !== undefined && v !== null && !Array.isArray(v) && v.constructor === Object;
30
25
  }
31
- /** Normalised form used to spot repeats of the same failure. */
26
+ function payloadString(payload, key) {
27
+ if (!isObject(payload))
28
+ return null;
29
+ const value = payload[key];
30
+ return isString(value) && value.trim() !== '' ? value : null;
31
+ }
32
+ /** Normalised form used to spot repeats. The failure log keeps only its hash, so changing it breaks repeat counts. */
32
33
  export function failureSignature(text) {
33
34
  return text.toLowerCase().replace(/[0-9a-f]{7,}/g, '#').replace(/\d+/g, '#').replace(/\s+/g, ' ').trim();
34
35
  }
35
- /**
36
- * The memory text for a failure payload, or the reason it is not stored.
37
- * Pure: no store access.
38
- */
36
+ /** The memory text for a failure payload, or why it is not stored; a routine skip keeps its text for the log. Pure. */
39
37
  export function lessonFromFailure(payload) {
40
38
  if (!isObject(payload))
41
- return { skip: 'skipped-invalid' };
39
+ return { skip: 'skipped-invalid', text: null, detail: null };
42
40
  // SAFETY: isObject narrowed payload to a plain JSON object; the fields read are all optional.
43
41
  const p = payload;
44
42
  if (p.is_interrupt === true)
45
- return { skip: 'skipped-interrupt' };
43
+ return { skip: 'skipped-interrupt', text: null, detail: null };
46
44
  if (!isString(p.error) || p.error.trim().length < 12)
47
- return { skip: 'skipped-invalid' };
45
+ return { skip: 'skipped-invalid', text: null, detail: null };
48
46
  const tool = isString(p.tool_name) ? p.tool_name : 'tool';
49
47
  const error = p.error.replace(/\s+/g, ' ').trim();
50
- if (ROUTINE_PATTERNS.some((re) => re.test(error)))
51
- return { skip: 'skipped-routine' };
48
+ const text = `${tool}: ${error}`.slice(0, MAX_LEN);
49
+ const command = isObject(p.tool_input) && isString(p.tool_input['command']) ? p.tool_input['command'].replace(LEADING_CD, '') : '';
50
+ const head = command.trim().split(/\s+/).slice(0, 2).join(' ');
51
+ const detail = `${tool}${head ? ` ${head}` : ''}: ${error}`;
52
+ const routine = (rule) => ({ skip: 'skipped-routine', rule, text, detail });
53
+ if (DECLINED.test(error))
54
+ return routine('declined');
55
+ if (OS_PERMISSION.test(error))
56
+ return routine('os-permission');
57
+ if (NO_MATCH.test(error))
58
+ return routine('no-match');
52
59
  if (tool === 'Grep' || tool === 'Glob')
53
- return { skip: 'skipped-routine' };
54
- if (tool === 'Bash' && isObject(p.tool_input) && isString(p.tool_input['command'])
55
- && QUIET_EXIT_1.test(p.tool_input['command']) && /exit code 1\b/i.test(error)) {
56
- return { skip: 'skipped-routine' };
57
- }
58
- return { text: `${tool}: ${error}`.slice(0, MAX_LEN) };
60
+ return routine('search-tool');
61
+ if (tool === 'Bash' && QUIET_EXIT_1.test(command) && /exit code 1\b/i.test(error))
62
+ return routine('quiet-exit');
63
+ return { text, detail };
59
64
  }
60
- /**
61
- * Store a failure payload as an error memory unless it is routine or a
62
- * repeat of an auto-captured error already in the store.
63
- */
65
+ /** Store a failure as an error memory unless it is routine or a repeat, and log it either way, even when storing throws. */
64
66
  export function captureToolFailure(hippoRoot, tenantId, payload) {
65
67
  const lesson = lessonFromFailure(payload);
66
- if ('skip' in lesson)
67
- return lesson.skip;
68
- const sig = failureSignature(lesson.text);
68
+ let outcome = 'store-failed';
69
+ try {
70
+ outcome = 'skip' in lesson ? lesson.skip : storeLesson(hippoRoot, tenantId, lesson.text);
71
+ return outcome;
72
+ }
73
+ finally {
74
+ logFailure(hippoRoot, tenantId, payload, lesson, outcome);
75
+ }
76
+ }
77
+ function logFailure(hippoRoot, tenantId, payload, lesson, outcome) {
78
+ const hash = (s) => (s === null ? null : blockHash(failureSignature(s)));
79
+ const db = openHippoDb(hippoRoot);
80
+ try {
81
+ recordFailure(db, {
82
+ tenantId,
83
+ sessionId: payloadString(payload, 'session_id'),
84
+ tool: payloadString(payload, 'tool_name'),
85
+ outcome,
86
+ rule: 'rule' in lesson ? lesson.rule : null,
87
+ sigHash: hash(lesson.text),
88
+ detailHash: hash(lesson.detail),
89
+ });
90
+ }
91
+ finally {
92
+ closeHippoDb(db);
93
+ }
94
+ }
95
+ function storeLesson(hippoRoot, tenantId, text) {
96
+ const sig = failureSignature(text);
69
97
  const repeat = loadAllEntries(hippoRoot, tenantId).some((e) => e.tags.includes('auto-captured') && failureSignature(e.content) === sig);
70
98
  if (repeat)
71
99
  return 'duplicate';
72
- const entry = createMemory(lesson.text, {
100
+ const entry = createMemory(text, {
73
101
  tags: ['error', 'auto-captured'],
74
102
  source: 'tool-failure',
75
103
  confidence: 'observed',
package/dist/cli.js CHANGED
@@ -60,8 +60,11 @@ import { computeSystemEnergy, vecNorm } from './physics.js';
60
60
  import { loadConfig } from './config.js';
61
61
  import { openHippoDb, closeHippoDb } from './db.js';
62
62
  import { runDoctor, formatDoctor } from './doctor.js';
63
+ import { buildSupportBundle, TAIL_MAX_LINES } from './support-bundle.js';
64
+ import { PACKAGE_VERSION } from './version.js';
63
65
  import { captureToolFailure } from './capture-error.js';
64
66
  import { blockHash, hookPayloadSessionId, lastSentState, recordTokenUse, shouldSkipUnchanged } from './token-ledger.js';
67
+ import { FAILURE_LOG_RETENTION_DAYS } from './failure-log.js';
65
68
  import { pushGoal, getActiveGoals, completeGoal, suspendGoal, resumeGoal, applyGoalStackBoost } from './goals.js';
66
69
  import { rowToGoal } from './goals.js';
67
70
  import { captureError, extractLessons, partitionLessons, deduplicateLesson, runWatched, fetchGitLog, isGitRepo, } from './autolearn.js';
@@ -263,7 +266,7 @@ export const BOOLEAN_FLAGS = new Set([
263
266
  'all', 'all-tenants', 'archive', 'auto', 'bad', 'bootstrap', 'classic', 'continuity',
264
267
  'cross-project', 'dry-run', 'equal-sources', 'error', 'evc-adaptive', 'extract',
265
268
  'filter-conflicts', 'fix', 'force', 'forget', 'git', 'global', 'good', 'graph-stream',
266
- 'help', 'include-superseded', 'inferred', 'json', 'last-session', 'multihop', 'no-hooks',
269
+ 'help', 'include-logs', 'include-superseded', 'inferred', 'json', 'last-session', 'multihop', 'no-hooks',
267
270
  'no-learn', 'no-mmr', 'no-propagate', 'no-schedule', 'no-share', 'no-summarize-older',
268
271
  'observed', 'open', 'physics', 'pin', 'pinned-only', 'reject-loser', 'rerank-utility',
269
272
  'reset-physics', 'save-baseline', 'show-cases', 'stats', 'stdin',
@@ -487,7 +490,7 @@ function cmdInit(hippoRoot, flags) {
487
490
  initStore(hippoRoot);
488
491
  console.log('Initialized Hippo at', hippoRoot);
489
492
  console.log(' Directories: buffer/ episodic/ semantic/ conflicts/');
490
- console.log(' Files: hippo.db index.json stats.json');
493
+ console.log(' Files: hippo.db stats.json');
491
494
  }
492
495
  const globalRoot = getGlobalRoot();
493
496
  registerWorkspace(globalRoot, path.dirname(hippoRoot));
@@ -3929,6 +3932,48 @@ function cmdTokens(hippoRoot, flags) {
3929
3932
  console.log(` Mean per session (rows with a session id): ${summary.meanTokensPerSession} tokens.`);
3930
3933
  }
3931
3934
  }
3935
+ /** `hippo failures [--days <n>] [--json] [--global]`: failed tool calls by outcome, and repeats across sessions (CD13). */
3936
+ function cmdFailures(hippoRoot, flags) {
3937
+ // The store the capture-error hook writes to; a report never creates one.
3938
+ const root = flags['global'] ? getGlobalRoot() : hookStoreRoot(hippoRoot);
3939
+ requireInit(root);
3940
+ const ctx = {
3941
+ hippoRoot: root,
3942
+ tenantId: resolveTenantId({}),
3943
+ actor: api.adminActor('cli'),
3944
+ };
3945
+ const days = parseCountFlag(flags['days']);
3946
+ const summary = api.failureSummary(ctx, { days: days > 0 ? days : undefined });
3947
+ if (flags['json']) {
3948
+ console.log(JSON.stringify(summary, null, 2));
3949
+ return;
3950
+ }
3951
+ const windowDays = days > 0 ? days : 30;
3952
+ const kept = windowDays > FAILURE_LOG_RETENTION_DAYS ? ` (rows are kept ${FAILURE_LOG_RETENTION_DAYS} days)` : '';
3953
+ if (summary.total === 0) {
3954
+ console.log(`No failed tool calls recorded in the last ${windowDays} days${kept}.`);
3955
+ return;
3956
+ }
3957
+ const o = summary.outcomes;
3958
+ const errors = o.stored + o.duplicate + o['store-failed'];
3959
+ const unsaved = o['store-failed'] > 0 ? `, ${o['store-failed']} could not be saved` : '';
3960
+ const rows = [
3961
+ ['errors', errors, `(${o.stored} new, ${o.duplicate} already in memory${unsaved})`],
3962
+ ['routine', o['skipped-routine'], ''],
3963
+ ['interrupted', o['skipped-interrupt'], ''],
3964
+ ['unreadable', o['skipped-invalid'], ''],
3965
+ ];
3966
+ console.log(`Failed tool calls seen by the capture-error hook, last ${windowDays} days${kept}\n`);
3967
+ for (const [label, count, note] of rows) {
3968
+ console.log(` ${label.padEnd(13)}${String(count).padStart(6)} ${note}`.trimEnd());
3969
+ }
3970
+ // Counts, not a rate: a share means little without a holdout arm to compare against (CD11).
3971
+ if (summary.rated > 0) {
3972
+ const noSession = errors - summary.rated;
3973
+ const unrated = noSession > 0 ? ` ${noSession} more had no session id.` : '';
3974
+ console.log(`\n Repeats: ${summary.repeats} of ${summary.rated} errors first happened in another session.${unrated}`);
3975
+ }
3976
+ }
3932
3977
  function cmdSnapshot(hippoRoot, args, flags) {
3933
3978
  requireInit(hippoRoot);
3934
3979
  const subcommand = args[0] ?? 'show';
@@ -8632,12 +8677,21 @@ Commands:
8632
8677
  PostToolUseFailure hook payload on stdin; skips routine failures)
8633
8678
  doctor Check the install: Node, store, schema, sleep, agent hooks
8634
8679
  --json Machine-readable report (exit code 1 on any failure)
8680
+ support-bundle Write a redacted JSON file for a support ticket: versions, doctor,
8681
+ config without secrets, store counts, log names; never memory text
8682
+ --out <file> Where to write it (default: hippo-support-<time>.json here)
8683
+ --include-logs Add the last ${TAIL_MAX_LINES} lines of each hippo log, known secret shapes removed
8635
8684
  tokens Tokens of memory text hippo handed agents, per surface
8636
8685
  (hook, context, recall, MCP, HTTP), and what skipping
8637
8686
  unchanged hook blocks saved
8638
8687
  --days <n> Window in days (default: 30)
8639
8688
  --json Output as JSON
8640
8689
  --global Operate on the global store
8690
+ failures Failed tool calls capture-error saw, by outcome, and how
8691
+ many errors first happened in another session
8692
+ --days <n> Window in days (default: 30)
8693
+ --json Output as JSON
8694
+ --global Operate on the global store
8641
8695
  snapshot <sub> Persist or inspect the current active task
8642
8696
  snapshot save Save active task state
8643
8697
  --task <task>
@@ -9373,6 +9427,9 @@ async function main(command, args, flags, hippoRoot) {
9373
9427
  case 'tokens':
9374
9428
  cmdTokens(hippoRoot, flags);
9375
9429
  break;
9430
+ case 'failures':
9431
+ cmdFailures(hippoRoot, flags);
9432
+ break;
9376
9433
  case 'doctor': {
9377
9434
  // SAFETY: package.json always carries a string "version" (checked at release by check-manifest-versions).
9378
9435
  const pkg = JSON.parse(fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf-8'));
@@ -9382,6 +9439,38 @@ async function main(command, args, flags, hippoRoot) {
9382
9439
  process.exit(1);
9383
9440
  break;
9384
9441
  }
9442
+ case 'support-bundle': {
9443
+ const outFlag = cardStringFlag(flags, 'out');
9444
+ if (outFlag === '') {
9445
+ console.error('--out requires a file path.');
9446
+ process.exit(1);
9447
+ }
9448
+ const includeLogs = flags['include-logs'] === true;
9449
+ const home = process.env.HOME || process.env.USERPROFILE || os.homedir();
9450
+ const now = new Date();
9451
+ const bundle = buildSupportBundle({ cwd: process.cwd(), home, version: PACKAGE_VERSION, includeLogs, now });
9452
+ const stamp = now.toISOString().replace(/[:.]/g, '-');
9453
+ const file = outFlag ?? path.join(process.cwd(), `hippo-support-${stamp}.json`);
9454
+ const json = JSON.stringify(bundle, null, 2);
9455
+ try {
9456
+ fs.writeFileSync(file, `${json}\n`, { flag: 'wx', mode: 0o600 });
9457
+ }
9458
+ catch (err) {
9459
+ if (err instanceof Error && 'code' in err && err.code === 'EEXIST') {
9460
+ console.error(`${file} already exists; pass --out to choose another file. Nothing was written.`);
9461
+ }
9462
+ else {
9463
+ console.error(err instanceof Error ? err.message : String(err));
9464
+ }
9465
+ process.exit(1);
9466
+ }
9467
+ const kb = Math.round(Buffer.byteLength(json) / 1024);
9468
+ console.log(`Wrote ${file} (${kb} KB).`);
9469
+ console.log(includeLogs
9470
+ ? `It holds versions, doctor checks, config with secrets removed, store counts, and the last ${TAIL_MAX_LINES} lines of each hippo log with known secret shapes removed. Those log lines can quote memory text. Read it before you attach it to a ticket.`
9471
+ : 'It holds versions, doctor checks, config with secrets removed, store counts and log file names. It never holds memory text. Read it before you attach it to a ticket.');
9472
+ break;
9473
+ }
9385
9474
  case 'snapshot':
9386
9475
  cmdSnapshot(hippoRoot, args, flags);
9387
9476
  break;
package/dist/db.d.ts CHANGED
@@ -13,11 +13,18 @@ export interface DatabaseSyncLike {
13
13
  }
14
14
  export declare function getHippoDbPath(hippoRoot: string): string;
15
15
  export declare function getCurrentSchemaVersion(): number;
16
+ /** Thrown by {@link assertBinaryCompatible}; doctor uses it to pick the upgrade fix over a generic permissions fix. */
17
+ export declare class IncompatibleBinaryError extends Error {
18
+ }
16
19
  export declare function openHippoDb(hippoRoot: string): DatabaseSyncLike;
20
+ /** Open an existing store without changing it: no mkdir, WAL switch, migration or mirror cleanup. Throws when hippo.db is missing. */
21
+ export declare function openHippoDbReadOnly(hippoRoot: string): DatabaseSyncLike;
17
22
  export declare function getSchemaVersion(db: DatabaseSyncLike): number;
18
23
  export declare function closeHippoDb(db: DatabaseSyncLike): void;
19
24
  export declare function getMeta(db: DatabaseSyncLike, key: string, fallback?: string): string;
20
25
  export declare function setMeta(db: DatabaseSyncLike, key: string, value: string): void;
26
+ /** Row count of one table; null when the table is missing or unreadable, which callers show as unknown. */
27
+ export declare function countTableRows(db: DatabaseSyncLike, table: string): number | null;
21
28
  export declare function isFtsAvailable(db: DatabaseSyncLike): boolean;
22
29
  export declare function pruneConsolidationRuns(db: DatabaseSyncLike, keep?: number): void;
23
30
  export {};
package/dist/db.js CHANGED
@@ -11,7 +11,7 @@ const require = createRequire(import.meta.url);
11
11
  // runtime (Node's built-in synchronous SQLite module); there are no bundled
12
12
  // types for it here, so this require + cast is the module's documented boundary.
13
13
  const { DatabaseSync } = require('node:sqlite');
14
- const CURRENT_SCHEMA_VERSION = 45;
14
+ const CURRENT_SCHEMA_VERSION = 46;
15
15
  const MIGRATIONS = [
16
16
  {
17
17
  version: 1,
@@ -2423,6 +2423,30 @@ const MIGRATIONS = [
2423
2423
  `);
2424
2424
  },
2425
2425
  },
2426
+ {
2427
+ version: 46,
2428
+ up: (db) => {
2429
+ // CD13 failure log (src/failure-log.ts): hashes only, since failure text can carry paths and secrets.
2430
+ // Additive only: no min_compatible_binary bump.
2431
+ db.exec(`
2432
+ CREATE TABLE IF NOT EXISTS failure_log (
2433
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
2434
+ ts TEXT NOT NULL,
2435
+ tenant_id TEXT NOT NULL DEFAULT 'default',
2436
+ session_id TEXT,
2437
+ tool TEXT,
2438
+ outcome TEXT NOT NULL,
2439
+ skip_rule TEXT,
2440
+ sig_hash TEXT,
2441
+ detail_hash TEXT
2442
+ );
2443
+ CREATE INDEX IF NOT EXISTS idx_failure_log_tenant
2444
+ ON failure_log(tenant_id, id);
2445
+ CREATE INDEX IF NOT EXISTS idx_failure_log_ts
2446
+ ON failure_log(ts);
2447
+ `);
2448
+ },
2449
+ },
2426
2450
  ];
2427
2451
  function tableHasColumn(db, tableName, columnName) {
2428
2452
  if (!/^[a-z_]+$/i.test(tableName))
@@ -2445,11 +2469,14 @@ export function getHippoDbPath(hippoRoot) {
2445
2469
  export function getCurrentSchemaVersion() {
2446
2470
  return CURRENT_SCHEMA_VERSION;
2447
2471
  }
2472
+ /** Thrown by {@link assertBinaryCompatible}; doctor uses it to pick the upgrade fix over a generic permissions fix. */
2473
+ export class IncompatibleBinaryError extends Error {
2474
+ }
2448
2475
  /** Refuse a store stamped for a newer binary. Fails closed: runMigrations creates meta first, so a failed read is a real error. */
2449
2476
  function assertBinaryCompatible(db) {
2450
2477
  const minRequired = getMeta(db, 'min_compatible_binary');
2451
2478
  if (minRequired && compareSemver(minRequired, PACKAGE_VERSION) > 0) {
2452
- throw new Error(`hippo-memory: this database requires hippo-memory >= ${minRequired}, but the running binary is ${PACKAGE_VERSION}. ` +
2479
+ throw new IncompatibleBinaryError(`hippo-memory: this database requires hippo-memory >= ${minRequired}, but the running binary is ${PACKAGE_VERSION}. ` +
2453
2480
  `Upgrade hippo-memory to open it; an older binary does not know this schema and could expose private rows or damage the store.`);
2454
2481
  }
2455
2482
  }
@@ -2506,6 +2533,25 @@ export function openHippoDb(hippoRoot) {
2506
2533
  throw error;
2507
2534
  }
2508
2535
  }
2536
+ /** Open an existing store without changing it: no mkdir, WAL switch, migration or mirror cleanup. Throws when hippo.db is missing. */
2537
+ export function openHippoDbReadOnly(hippoRoot) {
2538
+ const db = new DatabaseSync(getHippoDbPath(hippoRoot), { readOnly: true });
2539
+ try {
2540
+ db.exec('PRAGMA busy_timeout = 5000');
2541
+ if (tableExists(db, 'meta'))
2542
+ assertBinaryCompatible(db);
2543
+ return db;
2544
+ }
2545
+ catch (error) {
2546
+ try {
2547
+ db.close();
2548
+ }
2549
+ catch {
2550
+ // Best effort only.
2551
+ }
2552
+ throw error;
2553
+ }
2554
+ }
2509
2555
  function runMigrations(db, hippoRoot) {
2510
2556
  ensureMetaTable(db);
2511
2557
  // Before anything writes, so a stale binary never repairs or migrates a store it does not understand.
@@ -2554,6 +2600,8 @@ function ensureMetaTable(db) {
2554
2600
  `);
2555
2601
  }
2556
2602
  export function getSchemaVersion(db) {
2603
+ if (!tableExists(db, 'meta'))
2604
+ return 0;
2557
2605
  // SAFETY: row's shape matches the single `value` column named in the
2558
2606
  // SELECT above.
2559
2607
  const row = db.prepare(`SELECT value FROM meta WHERE key = 'schema_version'`).get();
@@ -2760,6 +2808,18 @@ export function getMeta(db, key, fallback = '') {
2760
2808
  export function setMeta(db, key, value) {
2761
2809
  db.prepare(`INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run(key, value);
2762
2810
  }
2811
+ /** Row count of one table; null when the table is missing or unreadable, which callers show as unknown. */
2812
+ export function countTableRows(db, table) {
2813
+ try {
2814
+ // SAFETY: COUNT(*) returns one row with one numeric column.
2815
+ const row = db.prepare(`SELECT COUNT(*) AS n FROM "${table.replace(/"/g, '""')}"`).get();
2816
+ return Number(row?.n ?? 0);
2817
+ }
2818
+ catch {
2819
+ // Callers treat an uncountable table as unknown; the bundle and doctor still finish.
2820
+ return null;
2821
+ }
2822
+ }
2763
2823
  export function isFtsAvailable(db) {
2764
2824
  return getMeta(db, 'fts5_available', '0') === '1';
2765
2825
  }