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.
@@ -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.46.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.46.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.46.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.46.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.46.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.46.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",