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/README.md +2 -1
- package/dist/api.d.ts +10 -0
- package/dist/api.js +55 -10
- package/dist/audit.d.ts +1 -1
- package/dist/auth.d.ts +10 -0
- package/dist/auth.js +20 -1
- package/dist/cli.js +96 -4
- package/dist/consolidate.d.ts +2 -0
- package/dist/consolidate.js +17 -3
- package/dist/dag.js +15 -6
- package/dist/db.d.ts +7 -0
- package/dist/db.js +54 -2
- package/dist/dedupe.js +5 -2
- package/dist/doctor.js +52 -41
- package/dist/extract.js +1 -0
- package/dist/mcp/server.d.ts +2 -0
- package/dist/mcp/server.js +6 -17
- package/dist/project-briefs.js +4 -2
- package/dist/recall-scope.d.ts +22 -10
- package/dist/recall-scope.js +41 -26
- package/dist/reject-flow.d.ts +2 -2
- package/dist/reject-flow.js +4 -7
- package/dist/secret-detect.d.ts +2 -0
- package/dist/secret-detect.js +18 -6
- package/dist/server.js +10 -9
- package/dist/store.d.ts +9 -26
- package/dist/store.js +18 -36
- package/dist/support-bundle.d.ts +12 -0
- package/dist/support-bundle.js +292 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
- package/extensions/openclaw-plugin/package.json +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -1
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
|
-
//
|
|
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
|
|
1202
|
-
|
|
1203
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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:
|
|
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.
|
|
219
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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:
|
|
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.
|
|
1140
|
-
*
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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) => {
|
package/openclaw.plugin.json
CHANGED
|
@@ -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.
|
|
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.
|
|
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",
|