hippo-memory 1.47.0 → 1.49.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/dist/server.js CHANGED
@@ -116,6 +116,8 @@ const VALID_AUDIT_OPS = new Set([
116
116
  'conflict_resolve', // AT1 — emitted by resolveConflict on every resolution path; lockstep
117
117
  'half_life_migrate', // Decay default change — emitted by migrateDefaultHalfLife; lockstep with AuditOp union
118
118
  'dormant_restore', // Dormant memories — emitted by api.restoreDormant; lockstep with AuditOp union + cli.ts VALID_AUDIT_OPS
119
+ 'auth_grant', // EI2: emitted by api.authGrant; lockstep with AuditOp union + cli.ts VALID_AUDIT_OPS
120
+ 'auth_ungrant', // EI2: emitted by api.authUngrant; lockstep with AuditOp union + cli.ts VALID_AUDIT_OPS
119
121
  ]);
120
122
  // Cap on GET /v1/audit?limit=. Matches docs/api.md (when written) and is large
121
123
  // enough to dump a small deployment's full audit log without paginating, but
@@ -469,6 +471,7 @@ function buildContextWithAuth(req, hippoRoot) {
469
471
  actor: {
470
472
  subject: `api_key:${result.keyId}`,
471
473
  role: result.role,
474
+ scopes: result.scopes,
472
475
  },
473
476
  };
474
477
  }
@@ -1191,16 +1194,13 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
1191
1194
  limit = parsed;
1192
1195
  }
1193
1196
  const ctx = buildContextWithAuth(req, opts.hippoRoot);
1194
- // D2 v1.12.10: optional ?tenant=<t> param. Defaults to ctx.tenantId
1195
- // (caller's own tenant). Lets admins query the '__host__' synthetic
1196
- // tenant where host-wide ops like 'consolidate' are recorded. Today
1197
- // the audit route inherits the auth gate on /v1/audit (Bearer auth);
1198
- // a future per-tenant authz check should ensure non-admin Bearers
1199
- // can only pass their own tenant_id here.
1197
+ // ?tenant=<t> reads another tenant (e.g. '__host__' for consolidate rows); admin only.
1200
1198
  const tenantOverride = query.get('tenant');
1201
- const effectiveCtx = tenantOverride !== null && tenantOverride !== ''
1202
- ? { ...ctx, tenantId: tenantOverride }
1203
- : ctx;
1199
+ const crossTenant = tenantOverride !== null && tenantOverride !== '' && tenantOverride !== ctx.tenantId;
1200
+ if (crossTenant && ctx.actor.role !== 'admin') {
1201
+ throw new HttpError(403, '/v1/audit?tenant= for another tenant requires admin role');
1202
+ }
1203
+ const effectiveCtx = crossTenant ? { ...ctx, tenantId: tenantOverride } : ctx;
1204
1204
  const result = auditList(effectiveCtx, { op, since, limit });
1205
1205
  sendJson(res, 200, result);
1206
1206
  return;
@@ -3021,6 +3021,7 @@ async function handleRequest(req, res, opts, startedAt, limiter) {
3021
3021
  actor: ctx.actor.subject,
3022
3022
  // The caller's real role: MCP tools must not run a member key as admin.
3023
3023
  role: ctx.actor.role,
3024
+ scopes: ctx.actor.scopes,
3024
3025
  clientKey: buildMcpClientKey(req),
3025
3026
  });
3026
3027
  }
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.49.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.49.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.49.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.49.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.49.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.49.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",