hippo-memory 1.47.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,6 +629,7 @@ 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 |
633
634
  | `hippo failures [--days n]` | Failed tool calls the capture-error hook saw, by outcome, and how many errors first happened in another session |
634
635
  | `hippo embed` | Embed all memories for semantic search |
package/dist/api.js CHANGED
@@ -1216,11 +1216,11 @@ export function supersede(ctx, oldId, newContent) {
1216
1216
  }
1217
1217
  // Mirrors after COMMIT, while the db handle is still open. Same
1218
1218
  // invariant as the original writeEntry: a mirror failure leaves disk
1219
- // MISSING the markdown for the new memory (self-heals on next backfill
1220
- // 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
1221
1221
  // roll back the supersede. Logged + swallowed, non-fatal.
1222
1222
  try {
1223
- writeEntryMirrors(ctx.hippoRoot, db, newEntry);
1223
+ writeEntryMirrors(ctx.hippoRoot, newEntry);
1224
1224
  }
1225
1225
  catch (mirrorErr) {
1226
1226
  console.error('supersede: mirror write failed (non-fatal, will self-heal):', mirrorErr);
@@ -1998,7 +1998,7 @@ export function restoreDormant(ctx, id) {
1998
1998
  }
1999
1999
  throw err;
2000
2000
  }
2001
- writeEntryMirrors(ctx.hippoRoot, db, restored);
2001
+ writeEntryMirrors(ctx.hippoRoot, restored);
2002
2002
  return restored;
2003
2003
  }
2004
2004
  finally {
package/dist/cli.js CHANGED
@@ -60,6 +60,8 @@ 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';
65
67
  import { FAILURE_LOG_RETENTION_DAYS } from './failure-log.js';
@@ -264,7 +266,7 @@ export const BOOLEAN_FLAGS = new Set([
264
266
  'all', 'all-tenants', 'archive', 'auto', 'bad', 'bootstrap', 'classic', 'continuity',
265
267
  'cross-project', 'dry-run', 'equal-sources', 'error', 'evc-adaptive', 'extract',
266
268
  'filter-conflicts', 'fix', 'force', 'forget', 'git', 'global', 'good', 'graph-stream',
267
- 'help', 'include-superseded', 'inferred', 'json', 'last-session', 'multihop', 'no-hooks',
269
+ 'help', 'include-logs', 'include-superseded', 'inferred', 'json', 'last-session', 'multihop', 'no-hooks',
268
270
  'no-learn', 'no-mmr', 'no-propagate', 'no-schedule', 'no-share', 'no-summarize-older',
269
271
  'observed', 'open', 'physics', 'pin', 'pinned-only', 'reject-loser', 'rerank-utility',
270
272
  'reset-physics', 'save-baseline', 'show-cases', 'stats', 'stdin',
@@ -488,7 +490,7 @@ function cmdInit(hippoRoot, flags) {
488
490
  initStore(hippoRoot);
489
491
  console.log('Initialized Hippo at', hippoRoot);
490
492
  console.log(' Directories: buffer/ episodic/ semantic/ conflicts/');
491
- console.log(' Files: hippo.db index.json stats.json');
493
+ console.log(' Files: hippo.db stats.json');
492
494
  }
493
495
  const globalRoot = getGlobalRoot();
494
496
  registerWorkspace(globalRoot, path.dirname(hippoRoot));
@@ -8675,6 +8677,10 @@ Commands:
8675
8677
  PostToolUseFailure hook payload on stdin; skips routine failures)
8676
8678
  doctor Check the install: Node, store, schema, sleep, agent hooks
8677
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
8678
8684
  tokens Tokens of memory text hippo handed agents, per surface
8679
8685
  (hook, context, recall, MCP, HTTP), and what skipping
8680
8686
  unchanged hook blocks saved
@@ -9433,6 +9439,38 @@ async function main(command, args, flags, hippoRoot) {
9433
9439
  process.exit(1);
9434
9440
  break;
9435
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
+ }
9436
9474
  case 'snapshot':
9437
9475
  cmdSnapshot(hippoRoot, args, flags);
9438
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
@@ -2469,11 +2469,14 @@ export function getHippoDbPath(hippoRoot) {
2469
2469
  export function getCurrentSchemaVersion() {
2470
2470
  return CURRENT_SCHEMA_VERSION;
2471
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
+ }
2472
2475
  /** Refuse a store stamped for a newer binary. Fails closed: runMigrations creates meta first, so a failed read is a real error. */
2473
2476
  function assertBinaryCompatible(db) {
2474
2477
  const minRequired = getMeta(db, 'min_compatible_binary');
2475
2478
  if (minRequired && compareSemver(minRequired, PACKAGE_VERSION) > 0) {
2476
- 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}. ` +
2477
2480
  `Upgrade hippo-memory to open it; an older binary does not know this schema and could expose private rows or damage the store.`);
2478
2481
  }
2479
2482
  }
@@ -2530,6 +2533,25 @@ export function openHippoDb(hippoRoot) {
2530
2533
  throw error;
2531
2534
  }
2532
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
+ }
2533
2555
  function runMigrations(db, hippoRoot) {
2534
2556
  ensureMetaTable(db);
2535
2557
  // Before anything writes, so a stale binary never repairs or migrates a store it does not understand.
@@ -2578,6 +2600,8 @@ function ensureMetaTable(db) {
2578
2600
  `);
2579
2601
  }
2580
2602
  export function getSchemaVersion(db) {
2603
+ if (!tableExists(db, 'meta'))
2604
+ return 0;
2581
2605
  // SAFETY: row's shape matches the single `value` column named in the
2582
2606
  // SELECT above.
2583
2607
  const row = db.prepare(`SELECT value FROM meta WHERE key = 'schema_version'`).get();
@@ -2784,6 +2808,18 @@ export function getMeta(db, key, fallback = '') {
2784
2808
  export function setMeta(db, key, value) {
2785
2809
  db.prepare(`INSERT INTO meta(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run(key, value);
2786
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
+ }
2787
2823
  export function isFtsAvailable(db) {
2788
2824
  return getMeta(db, 'fts5_available', '0') === '1';
2789
2825
  }
package/dist/doctor.js CHANGED
@@ -9,8 +9,8 @@ import * as os from 'node:os';
9
9
  import * as path from 'node:path';
10
10
  import { findHippoStoreDir } from './project-identity.js';
11
11
  import { getGlobalRoot } from './shared.js';
12
- import { isInitialized, loadStats } from './store.js';
13
- import { openHippoDb, closeHippoDb, getSchemaVersion, getCurrentSchemaVersion } from './db.js';
12
+ import { isInitialized } from './store.js';
13
+ import { openHippoDbReadOnly, closeHippoDb, getSchemaVersion, getCurrentSchemaVersion, countTableRows, IncompatibleBinaryError } from './db.js';
14
14
  import { isEmbeddingAvailable } from './embeddings.js';
15
15
  /** Minimum Node.js version hippo supports (package.json engines). */
16
16
  export const MIN_NODE = '22.16.0';
@@ -34,14 +34,42 @@ function readJson(file) {
34
34
  return null;
35
35
  }
36
36
  }
37
- function countRows(db, table) {
37
+ // Migration 46 creates failure_log; a read-only open no longer creates it on an older store.
38
+ const FAILURE_LOG_SCHEMA = 46;
39
+ /** The failed-tool-call count over the last 7 days, or why it could not be read. */
40
+ function failuresCheck(db, since, schemaVersion) {
38
41
  try {
39
- // SAFETY: COUNT(*) returns one row with one numeric column.
40
- const row = db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get();
41
- return Number(row?.n ?? 0);
42
+ // SAFETY: COUNT aggregate row.
43
+ const row = db.prepare(`SELECT COUNT(*) AS n FROM failure_log WHERE ts >= ?`).get(since);
44
+ return { id: 'failures', status: 'info', detail: `${Number(row?.n ?? 0)} failed tool calls logged in 7 days (hippo failures for detail)` };
42
45
  }
43
- catch {
44
- return null;
46
+ catch (err) {
47
+ const message = err instanceof Error ? err.message : String(err);
48
+ if (!message.includes('no such table')) {
49
+ return { id: 'failures', status: 'warn', detail: `cannot read the failure log: ${message}` };
50
+ }
51
+ return schemaVersion < FAILURE_LOG_SCHEMA
52
+ ? { id: 'failures', status: 'info', detail: 'no failure log yet (hippo creates it on the next write)' }
53
+ : { id: 'failures', status: 'warn', detail: 'the failure_log table is missing, so failed tool calls are not being logged' };
54
+ }
55
+ }
56
+ /** How long ago the store last slept (consolidated), or why that history could not be read. */
57
+ function sleepCheck(db, now) {
58
+ try {
59
+ // SAFETY: row's shape matches the single `timestamp` column named in the SELECT above.
60
+ const row = db.prepare(`SELECT timestamp FROM consolidation_runs ORDER BY timestamp DESC, id DESC LIMIT 1`).get();
61
+ const when = row?.timestamp !== undefined ? Date.parse(row.timestamp) : Number.NaN;
62
+ if (Number.isNaN(when)) {
63
+ return { id: 'sleep', status: 'warn', detail: 'hippo has never slept (consolidated) in this store', fix: 'hippo sleep (the session-end hook runs it automatically)' };
64
+ }
65
+ const days = Math.floor((now.getTime() - when) / 86_400_000);
66
+ return days > 7
67
+ ? { id: 'sleep', status: 'warn', detail: `last sleep ${days} days ago`, fix: 'hippo sleep, and check the session-end hook is installed' }
68
+ : { id: 'sleep', status: 'pass', detail: `last sleep ${days === 0 ? 'today' : `${days} day${days === 1 ? '' : 's'} ago`}` };
69
+ }
70
+ catch (err) {
71
+ const message = err instanceof Error ? err.message : String(err);
72
+ return { id: 'sleep', status: 'info', detail: `sleep history unavailable (${message})` };
45
73
  }
46
74
  }
47
75
  /** Run every check. Never throws for a broken install; broken parts become failed checks. */
@@ -58,10 +86,14 @@ export function runDoctor(opts) {
58
86
  const globalRoot = getGlobalRoot();
59
87
  const hasGlobal = isInitialized(globalRoot);
60
88
  let store = null;
61
- if (local !== null) {
89
+ if (local !== null && isInitialized(local)) {
62
90
  store = local;
63
91
  checks.push({ id: 'store', status: 'pass', detail: `project store at ${local}${hasGlobal ? ` (global store at ${globalRoot} too)` : ''}` });
64
92
  }
93
+ else if (local !== null) {
94
+ // The walk stops at the first .hippo it finds, so a bare one (no hippo.db) blocks a parent or global store too.
95
+ checks.push({ id: 'store', status: 'fail', detail: `${local} has no hippo.db, so hippo commands run here stop at it`, fix: `run hippo init in ${path.dirname(local)}, or remove that .hippo folder` });
96
+ }
65
97
  else if (hasGlobal) {
66
98
  store = globalRoot;
67
99
  checks.push({ id: 'store', status: 'warn', detail: `no project store here; using the global store at ${globalRoot}`, fix: 'hippo init (in the project root)' });
@@ -72,7 +104,7 @@ export function runDoctor(opts) {
72
104
  if (store !== null) {
73
105
  let db = null;
74
106
  try {
75
- db = openHippoDb(store);
107
+ db = openHippoDbReadOnly(store);
76
108
  const have = getSchemaVersion(db);
77
109
  const want = getCurrentSchemaVersion();
78
110
  checks.push(have === want
@@ -80,8 +112,8 @@ export function runDoctor(opts) {
80
112
  : have > want
81
113
  ? { id: 'schema', status: 'fail', detail: `database schema v${have} is newer than this hippo (v${want})`, fix: 'npm install -g hippo-memory@latest' }
82
114
  : { id: 'schema', status: 'info', detail: `database schema v${have}; hippo migrates it to v${want} on the next write` });
83
- const memories = countRows(db, 'memories');
84
- const dormant = countRows(db, 'dormant_memories');
115
+ const memories = countTableRows(db, 'memories');
116
+ const dormant = countTableRows(db, 'dormant_memories');
85
117
  const memoryCheck = { id: 'memories', status: 'info', detail: `${memories ?? '?'} memories${dormant !== null ? `, ${dormant} dormant` : ''}` };
86
118
  if (memories === 0) {
87
119
  memoryCheck.status = 'warn';
@@ -97,42 +129,21 @@ export function runDoctor(opts) {
97
129
  catch {
98
130
  checks.push({ id: 'tokens', status: 'info', detail: 'no token ledger yet (created on the next write)' });
99
131
  }
100
- try {
101
- // SAFETY: COUNT aggregate row.
102
- const row = db.prepare(`SELECT COUNT(*) AS n FROM failure_log WHERE ts >= ?`).get(since);
103
- checks.push({ id: 'failures', status: 'info', detail: `${Number(row?.n ?? 0)} failed tool calls logged in 7 days (hippo failures for detail)` });
104
- }
105
- catch {
106
- // Opening the store migrates it, so a missing table was dropped: the hook is logging nothing.
107
- checks.push({ id: 'failures', status: 'warn', detail: 'the failure_log table is missing, so failed tool calls are not being logged' });
108
- }
132
+ checks.push(failuresCheck(db, since, have));
133
+ checks.push(sleepCheck(db, now));
109
134
  }
110
135
  catch (err) {
111
- checks.push({ id: 'schema', status: 'fail', detail: `cannot open the database: ${err instanceof Error ? err.message : String(err)}`, fix: 'check file permissions on the .hippo folder' });
136
+ checks.push({
137
+ id: 'schema',
138
+ status: 'fail',
139
+ detail: `cannot open the database: ${err instanceof Error ? err.message : String(err)}`,
140
+ fix: err instanceof IncompatibleBinaryError ? 'npm install -g hippo-memory@latest' : 'check file permissions on the .hippo folder',
141
+ });
112
142
  }
113
143
  finally {
114
144
  if (db !== null)
115
145
  closeHippoDb(db);
116
146
  }
117
- try {
118
- const runs = loadStats(store)['consolidation_runs'];
119
- const last = Array.isArray(runs) && runs.length > 0 ? runs[runs.length - 1] : null;
120
- // SAFETY: the constructor check above narrows `last` to a plain JSON object.
121
- const ts = last !== null && last !== undefined && !Array.isArray(last) && last.constructor === Object ? last.timestamp : undefined;
122
- const when = ts !== undefined && ts !== null ? Date.parse(String(ts)) : Number.NaN;
123
- if (Number.isNaN(when)) {
124
- checks.push({ id: 'sleep', status: 'warn', detail: 'hippo has never slept (consolidated) in this store', fix: 'hippo sleep (the session-end hook runs it automatically)' });
125
- }
126
- else {
127
- const days = Math.floor((now.getTime() - when) / 86_400_000);
128
- checks.push(days > 7
129
- ? { id: 'sleep', status: 'warn', detail: `last sleep ${days} days ago`, fix: 'hippo sleep, and check the session-end hook is installed' }
130
- : { id: 'sleep', status: 'pass', detail: `last sleep ${days === 0 ? 'today' : `${days} day${days === 1 ? '' : 's'} ago`}` });
131
- }
132
- }
133
- catch {
134
- checks.push({ id: 'sleep', status: 'info', detail: 'sleep history unavailable' });
135
- }
136
147
  }
137
148
  const claudeDir = path.join(home, '.claude');
138
149
  if (fs.existsSync(claudeDir)) {
@@ -41,8 +41,8 @@ export interface RejectFlowResult {
41
41
  * normalized digest matches (kind-aware), one aggregate `reject_value`
42
42
  * audit, COMMIT. Then post-commit (mirrors the existing purge+reaper
43
43
  * pattern verbatim from api.archiveRaw, api.ts:1913-1938): best-effort
44
- * mirror purge per removed id, `mirror_cleaned_at` stamps for raw ids, one
45
- * index mirror rewrite.
44
+ * mirror purge per removed id, `mirror_cleaned_at` stamps for raw ids.
45
+ * index.json itself is only refreshed by `rebuildIndex()`.
46
46
  */
47
47
  export declare function rejectValue(opts: RejectFlowOpts): RejectFlowResult;
48
48
  export type UnrejectOutcome = {
@@ -15,7 +15,7 @@ import { openHippoDb, closeHippoDb } from './db.js';
15
15
  import { appendAuditEvent } from './audit.js';
16
16
  import { archiveRawMemory } from './raw-archive.js';
17
17
  import { purgeDormantByDigest } from './dormant.js';
18
- import { initStore, deleteEntryCore, purgeMirrorBestEffort, writeIndexMirror, buildIndexFromDb, } from './store.js';
18
+ import { initStore, deleteEntryCore, purgeMirrorBestEffort, } from './store.js';
19
19
  import { rejectionDigest, normalizeValueForRejection, insertRejectedValue, deleteRejectedValue, listRejectedValues, } from './rejection.js';
20
20
  /**
21
21
  * `hippo reject` / `api.reject` core flow. ONE connection, one transaction:
@@ -23,8 +23,8 @@ import { rejectionDigest, normalizeValueForRejection, insertRejectedValue, delet
23
23
  * normalized digest matches (kind-aware), one aggregate `reject_value`
24
24
  * audit, COMMIT. Then post-commit (mirrors the existing purge+reaper
25
25
  * pattern verbatim from api.archiveRaw, api.ts:1913-1938): best-effort
26
- * mirror purge per removed id, `mirror_cleaned_at` stamps for raw ids, one
27
- * index mirror rewrite.
26
+ * mirror purge per removed id, `mirror_cleaned_at` stamps for raw ids.
27
+ * index.json itself is only refreshed by `rebuildIndex()`.
28
28
  */
29
29
  export function rejectValue(opts) {
30
30
  if (!opts.reason.trim()) {
@@ -138,7 +138,7 @@ export function rejectValue(opts) {
138
138
  }
139
139
  // Post-commit, db handle still open (same pattern as api.archiveRaw):
140
140
  // best-effort mirror purge per removed id, reaper-backstop stamp for
141
- // raw ids, one index mirror rewrite.
141
+ // raw ids.
142
142
  for (const id of removedIds) {
143
143
  // AT1 fix: purgeMirrorBestEffort retries once, then — for non-raw ids,
144
144
  // which cleanupArchivedMirrors' reaper never scans — reports the
@@ -150,9 +150,6 @@ export function rejectValue(opts) {
150
150
  db.prepare(`UPDATE raw_archive SET mirror_cleaned_at = ? WHERE memory_id = ?`).run(new Date().toISOString(), id);
151
151
  }
152
152
  }
153
- if (removedIds.length > 0) {
154
- writeIndexMirror(opts.hippoRoot, buildIndexFromDb(db));
155
- }
156
153
  return { digest, content, removedIds, removedRawIds };
157
154
  }
158
155
  finally {
@@ -38,4 +38,6 @@ export declare function detectSecret(entry: {
38
38
  * CS1 pre-compact snapshot fields — can scrub it in place instead.
39
39
  */
40
40
  export declare function redactSecrets(text: string): string;
41
+ /** Stricter redaction for text that leaves the machine: no co-occurrence guard, plus Bearer headers and JWTs. */
42
+ export declare function redactSecretsStrict(text: string): string;
41
43
  //# sourceMappingURL=secret-detect.d.ts.map
@@ -48,6 +48,11 @@ const SECRET_PATTERNS = [
48
48
  ];
49
49
  const KEYISH_CONTEXT_RE = /key|token|secret|credential|bearer|auth|password/i;
50
50
  const CO_OCCURRENCE_GUARDED = new Set(['sk-style-key', 'sk-underscore-key']);
51
+ // redactSecretsStrict-only: too noisy for whole-entry memory scanning, worth hiding once text leaves the machine.
52
+ const STRICT_ONLY_PATTERNS = [
53
+ /\b[Bb]earer\s+[A-Za-z0-9._~+/-]{16,}=*/g,
54
+ /\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]*/g,
55
+ ];
51
56
  /**
52
57
  * Scan a memory's tags + content for secret material.
53
58
  * Pure and deterministic; no filesystem or store access.
@@ -75,21 +80,28 @@ export function detectSecret(entry) {
75
80
  * CS1 pre-compact snapshot fields — can scrub it in place instead.
76
81
  */
77
82
  export function redactSecrets(text) {
83
+ return redactText(text, false);
84
+ }
85
+ /** Stricter redaction for text that leaves the machine: no co-occurrence guard, plus Bearer headers and JWTs. */
86
+ export function redactSecretsStrict(text) {
87
+ return redactText(text, true);
88
+ }
89
+ function redactText(text, strict) {
78
90
  if (!text)
79
91
  return text;
80
92
  let result = text;
81
- // PEM/OpenSSH blocks first: the pattern-table entry matches only the
82
- // BEGIN delimiter, which is fine for detectSecret's flag-or-not decision
83
- // but would leave the base64 payload behind here. Consume through the
84
- // matching END delimiter; a truncated block with no END is redacted to
85
- // the end of the text (codex round 3).
93
+ // PEM/OpenSSH blocks first: SECRET_PATTERNS only matches BEGIN; consume through END, or to the end of a truncated block.
86
94
  result = result.replace(/-----BEGIN [A-Z ]*PRIVATE KEY-----(?:[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----|[\s\S]*$)/g, '[REDACTED]');
87
95
  for (const { name, re } of SECRET_PATTERNS) {
88
- if (CO_OCCURRENCE_GUARDED.has(name) && !KEYISH_CONTEXT_RE.test(text))
96
+ if (!strict && CO_OCCURRENCE_GUARDED.has(name) && !KEYISH_CONTEXT_RE.test(text))
89
97
  continue;
90
98
  const flags = re.flags.includes('g') ? re.flags : `${re.flags}g`;
91
99
  result = result.replace(new RegExp(re.source, flags), '[REDACTED]');
92
100
  }
101
+ if (strict) {
102
+ for (const re of STRICT_ONLY_PATTERNS)
103
+ result = result.replace(re, '[REDACTED]');
104
+ }
93
105
  return result;
94
106
  }
95
107
  //# sourceMappingURL=secret-detect.js.map
package/dist/store.d.ts CHANGED
@@ -190,23 +190,13 @@ interface LegacyStats {
190
190
  total_forgotten: JsonValue;
191
191
  consolidation_runs: JsonValue;
192
192
  }
193
- /**
194
- * Derive the current `HippoIndex` (entries + last-retrieval/trace lockstep
195
- * meta) from SQLite, the source of truth. Exported (AT1) for the same
196
- * reason as `writeIndexMirror` below: `src/reject-flow.ts` needs to rebuild
197
- * the index mirror post-commit after a (possibly multi-row) reject removal,
198
- * without duplicating this query.
199
- */
193
+ /** Derive the current `HippoIndex` from SQLite. Exported for `rebuildIndex`
194
+ * (the only index.json writer) and the longmemeval benchmark. */
200
195
  export declare function buildIndexFromDb(db: ReturnType<typeof openHippoDb>): HippoIndex;
201
- /**
202
- * Write the `index.json` mirror file for a given (already-derived) index.
203
- * Exported (AT1) so `src/reject-flow.ts` can replicate `deleteEntry`'s exact
204
- * post-commit "removeEntryMirrors then rewrite the index once" sequence for
205
- * the reject verb's (possibly multi-row) removal, without duplicating
206
- * `buildIndexFromDb`'s query.
207
- */
196
+ /** Write the `index.json` mirror file for an already-derived index. Exported for
197
+ * `rebuildIndex` (the only index.json writer) and the longmemeval benchmark. */
208
198
  export declare function writeIndexMirror(hippoRoot: string, index: HippoIndex): void;
209
- /** Load the derived index from SQLite. Read-only: writers refresh index.json, so readers never race on it. */
199
+ /** Load the derived index from SQLite. Read-only: index.json is only ever written by `rebuildIndex`. */
210
200
  export declare function loadIndex(hippoRoot: string): HippoIndex;
211
201
  /**
212
202
  * Persist mutable index metadata. Entry rows themselves are derived from SQLite.
@@ -215,10 +205,8 @@ export declare function loadIndex(hippoRoot: string): HippoIndex;
215
205
  * land atomically — callers (getContext, cmdRecall) fold a freshly-written
216
206
  * trace id into `index.last_trace_id` before calling this, relying on BOTH
217
207
  * meta keys committing together. Wrapped in BEGIN/COMMIT so a crash or a
218
- * mid-write failure can never advance one key without the other. The
219
- * filesystem mirror write stays AFTER commit — the DB is the source of
220
- * truth, the mirror is best-effort (matches every other per-call-handle
221
- * site's convention).
208
+ * mid-write failure can never advance one key without the other. index.json
209
+ * is left untouched; only `rebuildIndex` writes it.
222
210
  */
223
211
  export declare function saveIndex(hippoRoot: string, index: HippoIndex): void;
224
212
  /**
@@ -277,13 +265,8 @@ export declare function writeEntryDbOnly(db: DatabaseSyncLike, entry: MemoryEntr
277
265
  actor?: string;
278
266
  afterWrite?: (db: DatabaseSyncLike, memoryId: string) => void;
279
267
  }): void;
280
- /**
281
- * Filesystem mirrors path. Caller passes `hippoRoot` + an open `db` handle
282
- * (used by `buildIndexFromDb` to derive the index from the source of truth).
283
- * MUST be invoked AFTER the outer transaction commits — a mirror write
284
- * during a tx that subsequently rolls back would leave orphan markdown.
285
- */
286
- export declare function writeEntryMirrors(hippoRoot: string, db: DatabaseSyncLike, entry: MemoryEntry): void;
268
+ /** Markdown mirror path, invoked AFTER commit (a rolled-back tx must leave no orphan markdown). */
269
+ export declare function writeEntryMirrors(hippoRoot: string, entry: MemoryEntry): void;
287
270
  /**
288
271
  * Read a memory entry by ID.
289
272
  *
package/dist/store.js CHANGED
@@ -940,6 +940,7 @@ function upsertEntryRow(db, entry, bypassRejectionGuard = false) {
940
940
  if (!bypassRejectionGuard) {
941
941
  checkRejectionGuard(db, entry.tenantId ?? 'default', entry.id, entry.content);
942
942
  }
943
+ const isNewRow = db.prepare(`SELECT 1 FROM memories WHERE id = ?`).get(entry.id) === undefined;
943
944
  db.prepare(`
944
945
  INSERT INTO memories(
945
946
  id, created, last_retrieved, retrieval_count, strength, half_life_days, layer,
@@ -996,13 +997,14 @@ function upsertEntryRow(db, entry, bypassRejectionGuard = false) {
996
997
  dag_level_3_built_at = excluded.dag_level_3_built_at,
997
998
  updated_at = datetime('now')
998
999
  `).run(entry.id, entry.created, entry.last_retrieved, entry.retrieval_count, entry.strength, entry.half_life_days, entry.layer, JSON.stringify(entry.tags ?? []), entry.emotional_valence, entry.schema_fit, entry.source, entry.outcome_score, entry.outcome_positive ?? 0, entry.outcome_negative ?? 0, JSON.stringify(entry.conflicts_with ?? []), entry.pinned ? 1 : 0, entry.confidence, entry.content, JSON.stringify(entry.parents ?? []), entry.starred ? 1 : 0, entry.trace_outcome ?? null, entry.source_session_id ?? null, entry.valid_from ?? entry.created, entry.superseded_by ?? null, entry.extracted_from ?? null, entry.dag_level ?? 0, entry.dag_parent_id ?? null, entry.kind ?? 'distilled', entry.scope ?? null, entry.owner ?? null, entry.artifact_ref ?? null, entry.tenantId ?? 'default', entry.origin_project ?? null, entry.descendant_count ?? 0, entry.earliest_at ?? null, entry.latest_at ?? null, entry.dag_level_3_built_at ?? null);
999
- syncFtsRow(db, entry);
1000
+ syncFtsRow(db, entry, isNewRow);
1000
1001
  }
1001
- function syncFtsRow(db, entry) {
1002
+ function syncFtsRow(db, entry, isNewRow = false) {
1002
1003
  if (!isFtsAvailable(db))
1003
1004
  return;
1004
1005
  try {
1005
- db.prepare(`DELETE FROM memories_fts WHERE id = ?`).run(entry.id);
1006
+ if (!isNewRow)
1007
+ db.prepare(`DELETE FROM memories_fts WHERE id = ?`).run(entry.id);
1006
1008
  db.prepare(`INSERT INTO memories_fts(id, content, tags) VALUES (?, ?, ?)`).run(entry.id, entry.content, entry.tags.join(' '));
1007
1009
  }
1008
1010
  catch {
@@ -1019,13 +1021,8 @@ function deleteFtsRow(db, id) {
1019
1021
  // Best effort.
1020
1022
  }
1021
1023
  }
1022
- /**
1023
- * Derive the current `HippoIndex` (entries + last-retrieval/trace lockstep
1024
- * meta) from SQLite, the source of truth. Exported (AT1) for the same
1025
- * reason as `writeIndexMirror` below: `src/reject-flow.ts` needs to rebuild
1026
- * the index mirror post-commit after a (possibly multi-row) reject removal,
1027
- * without duplicating this query.
1028
- */
1024
+ /** Derive the current `HippoIndex` from SQLite. Exported for `rebuildIndex`
1025
+ * (the only index.json writer) and the longmemeval benchmark. */
1029
1026
  export function buildIndexFromDb(db) {
1030
1027
  // SAFETY: rows' shape matches the seven columns named in the SELECT below.
1031
1028
  const rows = db.prepare(`SELECT id, created, last_retrieved, strength, layer, tags_json, pinned FROM memories ORDER BY created ASC, id ASC`).all();
@@ -1076,13 +1073,8 @@ function buildStatsFromDb(db) {
1076
1073
  })),
1077
1074
  };
1078
1075
  }
1079
- /**
1080
- * Write the `index.json` mirror file for a given (already-derived) index.
1081
- * Exported (AT1) so `src/reject-flow.ts` can replicate `deleteEntry`'s exact
1082
- * post-commit "removeEntryMirrors then rewrite the index once" sequence for
1083
- * the reject verb's (possibly multi-row) removal, without duplicating
1084
- * `buildIndexFromDb`'s query.
1085
- */
1076
+ /** Write the `index.json` mirror file for an already-derived index. Exported for
1077
+ * `rebuildIndex` (the only index.json writer) and the longmemeval benchmark. */
1086
1078
  export function writeIndexMirror(hippoRoot, index) {
1087
1079
  mirrorBestEffort('index.json', () => fs.writeFileSync(path.join(hippoRoot, 'index.json'), JSON.stringify(index, null, 2), 'utf8'));
1088
1080
  }
@@ -1115,10 +1107,9 @@ function syncMirrorFiles(hippoRoot, db) {
1115
1107
  ORDER BY updated_at DESC, id DESC
1116
1108
  `).all();
1117
1109
  mirrorBestEffort('conflict mirrors', () => writeConflictMirrors(hippoRoot, conflicts.map(rowToMemoryConflict)));
1118
- writeIndexMirror(hippoRoot, buildIndexFromDb(db));
1119
1110
  writeStatsMirror(hippoRoot, buildStatsFromDb(db));
1120
1111
  }
1121
- /** Load the derived index from SQLite. Read-only: writers refresh index.json, so readers never race on it. */
1112
+ /** Load the derived index from SQLite. Read-only: index.json is only ever written by `rebuildIndex`. */
1122
1113
  export function loadIndex(hippoRoot) {
1123
1114
  initStore(hippoRoot);
1124
1115
  const db = openHippoDb(hippoRoot);
@@ -1136,10 +1127,8 @@ export function loadIndex(hippoRoot) {
1136
1127
  * land atomically — callers (getContext, cmdRecall) fold a freshly-written
1137
1128
  * trace id into `index.last_trace_id` before calling this, relying on BOTH
1138
1129
  * meta keys committing together. Wrapped in BEGIN/COMMIT so a crash or a
1139
- * mid-write failure can never advance one key without the other. The
1140
- * filesystem mirror write stays AFTER commit — the DB is the source of
1141
- * truth, the mirror is best-effort (matches every other per-call-handle
1142
- * site's convention).
1130
+ * mid-write failure can never advance one key without the other. index.json
1131
+ * is left untouched; only `rebuildIndex` writes it.
1143
1132
  */
1144
1133
  export function saveIndex(hippoRoot, index) {
1145
1134
  initStore(hippoRoot);
@@ -1158,7 +1147,6 @@ export function saveIndex(hippoRoot, index) {
1158
1147
  catch { /* already rolled back; keep the original error */ }
1159
1148
  throw error;
1160
1149
  }
1161
- writeIndexMirror(hippoRoot, buildIndexFromDb(db));
1162
1150
  }
1163
1151
  finally {
1164
1152
  closeHippoDb(db);
@@ -1222,7 +1210,7 @@ export function writeEntry(hippoRoot, entry, opts) {
1222
1210
  try {
1223
1211
  writeEntryDbOnly(db, stamped, opts);
1224
1212
  opts?.afterCommit?.();
1225
- writeEntryMirrors(hippoRoot, db, stamped);
1213
+ writeEntryMirrors(hippoRoot, stamped);
1226
1214
  }
1227
1215
  catch (error) {
1228
1216
  // AT1 (plan §3): writeEntryDbOnly's own SAVEPOINT has already unwound by
@@ -1286,15 +1274,9 @@ export function writeEntryDbOnly(db, entry, opts) {
1286
1274
  throw e;
1287
1275
  }
1288
1276
  }
1289
- /**
1290
- * Filesystem mirrors path. Caller passes `hippoRoot` + an open `db` handle
1291
- * (used by `buildIndexFromDb` to derive the index from the source of truth).
1292
- * MUST be invoked AFTER the outer transaction commits — a mirror write
1293
- * during a tx that subsequently rolls back would leave orphan markdown.
1294
- */
1295
- export function writeEntryMirrors(hippoRoot, db, entry) {
1277
+ /** Markdown mirror path, invoked AFTER commit (a rolled-back tx must leave no orphan markdown). */
1278
+ export function writeEntryMirrors(hippoRoot, entry) {
1296
1279
  mirrorBestEffort(`${entry.id}.md`, () => writeMarkdownMirror(hippoRoot, entry));
1297
- writeIndexMirror(hippoRoot, buildIndexFromDb(db));
1298
1280
  }
1299
1281
  /**
1300
1282
  * Read a memory entry by ID.
@@ -1611,7 +1593,6 @@ export function deleteEntry(hippoRoot, id, opts) {
1611
1593
  if (!result)
1612
1594
  return false;
1613
1595
  purgeMirrorBestEffort(hippoRoot, id, false, 'deleteEntry');
1614
- writeIndexMirror(hippoRoot, buildIndexFromDb(db));
1615
1596
  return true;
1616
1597
  }
1617
1598
  finally {
@@ -1786,7 +1767,6 @@ export function batchWriteAndDelete(hippoRoot, toWrite, toDeleteIds, opts) {
1786
1767
  });
1787
1768
  for (const id of removedIds)
1788
1769
  purgeMirrorBestEffort(hippoRoot, id, false, 'batchWriteAndDelete');
1789
- writeIndexMirror(hippoRoot, buildIndexFromDb(db));
1790
1770
  return removedIds;
1791
1771
  }
1792
1772
  catch (error) {
@@ -1978,7 +1958,9 @@ export function rebuildIndex(hippoRoot) {
1978
1958
  }
1979
1959
  }
1980
1960
  syncMirrorFiles(hippoRoot, db);
1981
- return buildIndexFromDb(db);
1961
+ const index = buildIndexFromDb(db);
1962
+ writeIndexMirror(hippoRoot, index);
1963
+ return index;
1982
1964
  }
1983
1965
  finally {
1984
1966
  closeHippoDb(db);
@@ -0,0 +1,12 @@
1
+ import { type DoctorOpts } from './doctor.js';
2
+ import type { JsonObject } from './working-memory.js';
3
+ export interface SupportBundleOpts extends DoctorOpts {
4
+ readonly cwd: string;
5
+ readonly home: string;
6
+ readonly now: Date;
7
+ readonly includeLogs: boolean;
8
+ }
9
+ export declare const TAIL_MAX_LINES = 200;
10
+ /** Read-only: builds one redacted support-ticket snapshot. Never reads a memory content column. */
11
+ export declare function buildSupportBundle(opts: SupportBundleOpts): JsonObject;
12
+ //# sourceMappingURL=support-bundle.d.ts.map
@@ -0,0 +1,292 @@
1
+ /** `hippo support-bundle`: one redacted JSON snapshot for a support ticket. Read-only; never touches memory content. */
2
+ import * as fs from 'fs';
3
+ import * as os from 'os';
4
+ import * as path from 'path';
5
+ import { findHippoStoreDir, isGlobalStoreRoot, realpathOrResolve } from './project-identity.js';
6
+ import { getGlobalRoot } from './shared.js';
7
+ import { isInitialized } from './store.js';
8
+ import { openHippoDbReadOnly, closeHippoDb, getSchemaVersion, getMeta, countTableRows } from './db.js';
9
+ import { runDoctor } from './doctor.js';
10
+ import { loadConfig } from './config.js';
11
+ import { redactSecretsStrict } from './secret-detect.js';
12
+ const TAIL_MAX_BYTES = 256 * 1024;
13
+ export const TAIL_MAX_LINES = 200;
14
+ const TAIL_MAX_LINE_CHARS = 2000;
15
+ // Greedy on purpose: complete key blocks are redacted first, so any END left is an orphan and what precedes it may be key body.
16
+ const ORPHAN_KEY_END_RE = /^[\s\S]*-----END [A-Z ]*PRIVATE KEY-----/;
17
+ // The other env vars hippo reads outside the HIPPO_ prefix (src/embedding-provider.ts, connectors/*).
18
+ const OTHER_ENV_NAMES = [
19
+ 'ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'VOYAGE_API_KEY', 'COHERE_API_KEY', 'TYPESAFE_API_KEY',
20
+ 'GITHUB_TOKEN', 'GITHUB_WEBHOOK_SECRET', 'GITHUB_WEBHOOK_SECRET_PREVIOUS',
21
+ 'GITHUB_ALLOW_UNKNOWN_INSTALLATION_FALLBACK', 'SLACK_BOT_TOKEN', 'SLACK_TEAM_ID',
22
+ 'SLACK_SIGNING_SECRET', 'SLACK_SIGNING_SECRET_PREVIOUS', 'SLACK_ALLOW_UNKNOWN_TEAM_FALLBACK',
23
+ 'XDG_DATA_HOME', 'MCP_SSE_HEARTBEAT_MS', 'MCP_SSE_MAX_AGE_SEC',
24
+ ];
25
+ const CONFIG_SECRET_KEY_RE = /key|token|secret|passw|credential|auth|cookie|bearer|signature|private/i;
26
+ function isJsonObject(v) {
27
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
28
+ }
29
+ function isJsonString(v) {
30
+ return typeof v === 'string';
31
+ }
32
+ function buildRuntime() {
33
+ return {
34
+ node: process.versions.node,
35
+ sqlite: process.versions.sqlite ?? null,
36
+ platform: process.platform,
37
+ arch: process.arch,
38
+ osRelease: os.release(),
39
+ };
40
+ }
41
+ function listTableNames(db) {
42
+ // SAFETY: each row's shape matches the single `name` column named in the SELECT above.
43
+ const rows = db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND sql NOT LIKE 'CREATE VIRTUAL TABLE%' ORDER BY name`).all();
44
+ return rows.map((r) => r.name);
45
+ }
46
+ function countTables(db) {
47
+ const tables = {};
48
+ for (const name of listTableNames(db))
49
+ tables[name] = countTableRows(db, name);
50
+ return tables;
51
+ }
52
+ function redactConfigValue(value) {
53
+ if (Array.isArray(value))
54
+ return value.map(redactConfigValue);
55
+ if (isJsonObject(value)) {
56
+ const out = {};
57
+ for (const [key, v] of Object.entries(value)) {
58
+ out[key] = CONFIG_SECRET_KEY_RE.test(key) ? '[REDACTED]' : redactConfigValue(v);
59
+ }
60
+ return out;
61
+ }
62
+ return value;
63
+ }
64
+ function readStoreConfig(storeDir) {
65
+ const configPath = path.join(storeDir, 'config.json');
66
+ let configFile = 'absent';
67
+ if (fs.existsSync(configPath)) {
68
+ try {
69
+ JSON.parse(fs.readFileSync(configPath, 'utf8'));
70
+ configFile = 'present';
71
+ }
72
+ catch {
73
+ configFile = 'invalid';
74
+ }
75
+ }
76
+ // loadConfig always returns the effective config: its own defaults on a missing or broken file.
77
+ const effective = JSON.parse(JSON.stringify(loadConfig(storeDir)));
78
+ return { configFile, config: redactConfigValue(effective) };
79
+ }
80
+ function buildStoreEntry(kind, storeDir) {
81
+ const dbPath = path.join(storeDir, 'hippo.db');
82
+ if (!fs.existsSync(dbPath)) {
83
+ return { kind, path: storeDir, error: 'no hippo.db here (hippo init has not run)' };
84
+ }
85
+ // Stat before opening: a read-only open leaves an empty -wal behind it.
86
+ const files = { 'hippo.db': fs.statSync(dbPath).size };
87
+ const walPath = `${dbPath}-wal`;
88
+ if (fs.existsSync(walPath))
89
+ files['hippo.db-wal'] = fs.statSync(walPath).size;
90
+ let db = null;
91
+ try {
92
+ db = openHippoDbReadOnly(storeDir);
93
+ const schemaVersion = getSchemaVersion(db);
94
+ const minCompatibleBinary = getMeta(db, 'min_compatible_binary', '') || null;
95
+ const tables = countTables(db);
96
+ const { configFile, config } = readStoreConfig(storeDir);
97
+ return { kind, path: storeDir, schemaVersion, minCompatibleBinary, files, tables, configFile, config };
98
+ }
99
+ catch (err) {
100
+ return { kind, path: storeDir, error: err instanceof Error ? err.message : String(err) };
101
+ }
102
+ finally {
103
+ if (db !== null)
104
+ closeHippoDb(db);
105
+ }
106
+ }
107
+ function buildStores(opts) {
108
+ const stores = [];
109
+ const projectDir = findHippoStoreDir(opts.cwd, { homeDir: opts.home });
110
+ if (projectDir !== null)
111
+ stores.push(buildStoreEntry('project', projectDir));
112
+ const globalDir = getGlobalRoot();
113
+ const alreadyListed = projectDir !== null && isGlobalStoreRoot(projectDir);
114
+ if (isInitialized(globalDir) && !alreadyListed)
115
+ stores.push(buildStoreEntry('global', globalDir));
116
+ return stores;
117
+ }
118
+ function listSetEnvNames() {
119
+ const names = new Set();
120
+ for (const key of Object.keys(process.env)) {
121
+ if (key.startsWith('HIPPO_'))
122
+ names.add(key);
123
+ }
124
+ for (const key of OTHER_ENV_NAMES) {
125
+ if (process.env[key] !== undefined)
126
+ names.add(key);
127
+ }
128
+ return [...names].sort();
129
+ }
130
+ function listLogFileNames(dir) {
131
+ if (!fs.existsSync(dir))
132
+ return [];
133
+ return fs.readdirSync(dir, { withFileTypes: true })
134
+ .filter((d) => d.isFile())
135
+ .map((d) => d.name)
136
+ .sort();
137
+ }
138
+ function tailLogFile(file) {
139
+ const size = fs.statSync(file).size;
140
+ const readSize = Math.min(size, TAIL_MAX_BYTES);
141
+ const startedMidFile = size > TAIL_MAX_BYTES;
142
+ const fd = fs.openSync(file, 'r');
143
+ let text;
144
+ try {
145
+ const buf = Buffer.alloc(readSize);
146
+ fs.readSync(fd, buf, 0, readSize, size - readSize);
147
+ text = buf.toString('utf8');
148
+ }
149
+ finally {
150
+ fs.closeSync(fd);
151
+ }
152
+ let lines = text.split(/\r?\n/);
153
+ // A tail read starting mid-file cuts its first piece off mid-line.
154
+ if (startedMidFile)
155
+ lines = lines.slice(1);
156
+ if (lines.length > 0 && lines[lines.length - 1] === '')
157
+ lines = lines.slice(0, -1);
158
+ // Redact before splitting: a private key spans lines. An END still left has lost its BEGIN to the window cut.
159
+ const redacted = redactSecretsStrict(lines.join('\n')).replace(ORPHAN_KEY_END_RE, '[REDACTED]');
160
+ return redacted.split('\n').slice(-TAIL_MAX_LINES).map((line) => line.length > TAIL_MAX_LINE_CHARS ? `${line.slice(0, TAIL_MAX_LINE_CHARS)} [truncated]` : line);
161
+ }
162
+ function buildLogsSection(opts) {
163
+ const dir = path.join(opts.home, '.hippo', 'logs');
164
+ const names = listLogFileNames(dir);
165
+ const files = names.map((name) => {
166
+ const stat = fs.statSync(path.join(dir, name));
167
+ return { name, bytes: stat.size, modified: stat.mtime.toISOString() };
168
+ });
169
+ const logs = { dir: '~/.hippo/logs', files };
170
+ if (opts.includeLogs) {
171
+ const tails = {};
172
+ for (const name of names)
173
+ tails[name] = tailLogFile(path.join(dir, name));
174
+ logs['tails'] = tails;
175
+ }
176
+ return logs;
177
+ }
178
+ function escapeRegExp(s) {
179
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
180
+ }
181
+ // The native realpath is the only form that exposes a short-name (8.3) or symlinked alias for what it is.
182
+ function nativeRealPathKey(p) {
183
+ try {
184
+ const real = fs.realpathSync.native(p);
185
+ return process.platform === 'win32' ? real.toLowerCase() : real;
186
+ }
187
+ catch {
188
+ // A path that does not exist has no alias to find.
189
+ return null;
190
+ }
191
+ }
192
+ // Longest first: where one home form nests in another (a test home under the real one), swap the longer.
193
+ function collectHomeForms(home, probes) {
194
+ const forms = new Set();
195
+ const add = (s) => { if (s.length >= 3)
196
+ forms.add(s); };
197
+ for (const base of [home, realpathOrResolve(home), os.homedir(), realpathOrResolve(os.homedir())]) {
198
+ add(base);
199
+ add(path.resolve(base));
200
+ if (process.platform === 'win32') {
201
+ add(base.replace(/\\/g, '/'));
202
+ add(path.resolve(base).replace(/\\/g, '/'));
203
+ }
204
+ }
205
+ // The alias (e.g. a short-name temp root) can sit above the probe itself, so walk ancestors and
206
+ // compare native realpaths rather than checking each probe alone.
207
+ const homeKeys = new Set([nativeRealPathKey(home), nativeRealPathKey(os.homedir())].filter((k) => k !== null));
208
+ for (const probe of probes) {
209
+ let dir = path.resolve(probe);
210
+ for (;;) {
211
+ const key = nativeRealPathKey(dir);
212
+ if (key !== null && homeKeys.has(key)) {
213
+ add(dir);
214
+ if (process.platform === 'win32')
215
+ add(dir.replace(/\\/g, '/'));
216
+ }
217
+ const parent = path.dirname(dir);
218
+ if (parent === dir)
219
+ break;
220
+ dir = parent;
221
+ }
222
+ }
223
+ const seen = new Set();
224
+ const deduped = [];
225
+ for (const f of [...forms].sort((a, b) => b.length - a.length)) {
226
+ const key = process.platform === 'win32' ? f.toLowerCase() : f;
227
+ if (seen.has(key))
228
+ continue;
229
+ seen.add(key);
230
+ deduped.push(f);
231
+ }
232
+ return deduped;
233
+ }
234
+ function replaceHomeForms(text, forms) {
235
+ if (forms.length === 0)
236
+ return text;
237
+ // Stop only before another name character: "<home> now" and "<home>." swap, a longer user name ("<home>ty") does not.
238
+ const re = new RegExp(`(?:${forms.map(escapeRegExp).join('|')})(?![\\p{L}\\p{N}_])`, process.platform === 'win32' ? 'giu' : 'gu');
239
+ return text.replace(re, '~');
240
+ }
241
+ const URL_RE = /\b[a-z][a-z0-9+.-]*:\/\/[^\s"'<>]+/gi;
242
+ function redactUrlsInString(text) {
243
+ return text.replace(URL_RE, (match) => {
244
+ const scheme = match.slice(0, match.indexOf('://'));
245
+ try {
246
+ const url = new URL(match);
247
+ url.username = '';
248
+ url.password = '';
249
+ url.search = '';
250
+ url.hash = '';
251
+ return url.toString();
252
+ }
253
+ catch {
254
+ return `${scheme}://[REDACTED]`;
255
+ }
256
+ });
257
+ }
258
+ function redactString(text, homeForms) {
259
+ return replaceHomeForms(redactSecretsStrict(redactUrlsInString(text)), homeForms);
260
+ }
261
+ function redactStrings(value, homeForms) {
262
+ if (isJsonString(value))
263
+ return redactString(value, homeForms);
264
+ if (Array.isArray(value))
265
+ return value.map((v) => redactStrings(v, homeForms));
266
+ if (isJsonObject(value)) {
267
+ const out = {};
268
+ for (const [k, v] of Object.entries(value))
269
+ out[k] = redactStrings(v, homeForms);
270
+ return out;
271
+ }
272
+ return value;
273
+ }
274
+ /** Read-only: builds one redacted support-ticket snapshot. Never reads a memory content column. */
275
+ export function buildSupportBundle(opts) {
276
+ const bundle = {
277
+ format: 'hippo-support-bundle/1',
278
+ createdAt: opts.now.toISOString(),
279
+ hippo: opts.version,
280
+ runtime: buildRuntime(),
281
+ doctor: JSON.parse(JSON.stringify(runDoctor(opts))),
282
+ stores: buildStores(opts),
283
+ env: listSetEnvNames(),
284
+ logs: buildLogsSection(opts),
285
+ };
286
+ // Store paths found from the working folder arrive resolved; the global root arrives as HIPPO_HOME was typed, and log lines
287
+ // often quote the temp folder, which usually sits under the home.
288
+ const redacted = redactStrings(bundle, collectHomeForms(opts.home, [getGlobalRoot(), os.tmpdir()]));
289
+ // SAFETY: the value built above is always a JsonObject; redactStrings preserves object shape.
290
+ return redacted;
291
+ }
292
+ //# sourceMappingURL=support-bundle.js.map
package/dist/version.d.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  * an ESM `import` can resolve cleanly, and a hardcoded constant survives
17
17
  * any packager that drops .json files.
18
18
  */
19
- export declare const PACKAGE_VERSION = "1.47.0";
19
+ export declare const PACKAGE_VERSION = "1.48.0";
20
20
  /** Compares plain x.y.z versions, positive if a > b; tags throw so the rollback guard never misfires silently. */
21
21
  export declare function compareSemver(a: string, b: string): number;
22
22
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -16,7 +16,7 @@
16
16
  * an ESM `import` can resolve cleanly, and a hardcoded constant survives
17
17
  * any packager that drops .json files.
18
18
  */
19
- export const PACKAGE_VERSION = '1.47.0';
19
+ export const PACKAGE_VERSION = '1.48.0';
20
20
  /** Compares plain x.y.z versions, positive if a > b; tags throw so the rollback guard never misfires silently. */
21
21
  export function compareSemver(a, b) {
22
22
  const parse = (v) => {
@@ -2,7 +2,7 @@
2
2
  "id": "hippo-memory",
3
3
  "name": "Hippo Memory",
4
4
  "description": "Biologically-inspired memory for AI agents. Decay by default, retrieval strengthening, sleep consolidation.",
5
- "version": "1.47.0",
5
+ "version": "1.48.0",
6
6
 
7
7
  "configSchema": {
8
8
  "type": "object",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hippo-memory",
3
- "version": "1.47.0",
3
+ "version": "1.48.0",
4
4
  "type": "module",
5
5
  "description": "Hippo Memory plugin for OpenClaw - biologically-inspired agent memory",
6
6
  "main": "index.ts",
@@ -2,7 +2,7 @@
2
2
  "id": "hippo-memory",
3
3
  "name": "Hippo Memory",
4
4
  "description": "Biologically-inspired memory for AI agents. Decay by default, retrieval strengthening, sleep consolidation.",
5
- "version": "1.47.0",
5
+ "version": "1.48.0",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hippo-memory",
3
- "version": "1.47.0",
3
+ "version": "1.48.0",
4
4
  "description": "Biologically-inspired memory for AI agents. Zero runtime deps, SQLite, MCP server, and an opt-in hosted TypeSafe Jev reranker. Decay, retrieval strengthening, consolidation.",
5
5
  "mcpName": "io.github.kitfunso/hippo-memory",
6
6
  "type": "module",
@@ -34,6 +34,7 @@
34
34
  "test": "vitest run",
35
35
  "test:watch": "vitest",
36
36
  "lint": "oxlint",
37
+ "sbom": "node scripts/sbom.mjs",
37
38
  "audit:security": "npm audit --audit-level=high && npm --prefix ui audit --audit-level=high",
38
39
  "postinstall": "node scripts/postinstall.cjs",
39
40
  "smoke:pack": "node scripts/smoke-pack.mjs",