hippo-memory 1.45.0 → 1.46.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.
Files changed (49) hide show
  1. package/README.md +57 -15
  2. package/dist/ablation.d.ts +10 -1
  3. package/dist/ablation.js +17 -1
  4. package/dist/api.d.ts +60 -1
  5. package/dist/api.js +189 -7
  6. package/dist/audit.d.ts +1 -1
  7. package/dist/capture-error.d.ts +20 -0
  8. package/dist/capture-error.js +82 -0
  9. package/dist/capture.d.ts +25 -8
  10. package/dist/capture.js +100 -5
  11. package/dist/cli.d.ts +6 -1
  12. package/dist/cli.js +381 -48
  13. package/dist/config.d.ts +20 -0
  14. package/dist/config.js +35 -0
  15. package/dist/consolidate.d.ts +6 -0
  16. package/dist/consolidate.js +98 -13
  17. package/dist/db.js +57 -1
  18. package/dist/doctor.d.ts +34 -0
  19. package/dist/doctor.js +174 -0
  20. package/dist/dormant.d.ts +91 -0
  21. package/dist/dormant.js +121 -0
  22. package/dist/eval-stats.d.ts +123 -0
  23. package/dist/eval-stats.js +187 -0
  24. package/dist/half-life-migration.d.ts +55 -0
  25. package/dist/half-life-migration.js +111 -0
  26. package/dist/hooks.d.ts +4 -0
  27. package/dist/hooks.js +47 -0
  28. package/dist/mcp/server.d.ts +6 -0
  29. package/dist/mcp/server.js +70 -13
  30. package/dist/memory.d.ts +16 -2
  31. package/dist/memory.js +27 -5
  32. package/dist/physics-config.js +5 -1
  33. package/dist/recall-scope.d.ts +24 -0
  34. package/dist/recall-scope.js +41 -0
  35. package/dist/reject-flow.d.ts +3 -3
  36. package/dist/reject-flow.js +10 -3
  37. package/dist/search.d.ts +4 -4
  38. package/dist/search.js +23 -18
  39. package/dist/server.js +11 -1
  40. package/dist/store.d.ts +12 -1
  41. package/dist/store.js +58 -12
  42. package/dist/token-ledger.d.ts +119 -0
  43. package/dist/token-ledger.js +181 -0
  44. package/dist/version.d.ts +1 -1
  45. package/dist/version.js +1 -1
  46. package/extensions/openclaw-plugin/openclaw.plugin.json +1 -1
  47. package/extensions/openclaw-plugin/package.json +1 -1
  48. package/openclaw.plugin.json +1 -1
  49. package/package.json +2 -1
package/dist/config.d.ts CHANGED
@@ -62,6 +62,13 @@ export interface HippoConfig {
62
62
  pinnedInject: {
63
63
  enabled: boolean;
64
64
  budget: number;
65
+ /** Skip a block identical to the one already injected this session
66
+ * (ROADMAP TE2). Default true. Needs a session id from the hook payload. */
67
+ skipUnchanged: boolean;
68
+ /** Resend an unchanged block after this many consecutive skips, so long
69
+ * sessions still see pinned rules near the latest turn. Default 10; 0
70
+ * never resends an unchanged block. */
71
+ refreshTurns: number;
65
72
  };
66
73
  /** Memory scope isolation (v39): when true (default), ambient context
67
74
  * (`hippo context`, the UserPromptSubmit hook, /v1/context, MCP
@@ -96,6 +103,19 @@ export interface HippoConfig {
96
103
  memoryValue: {
97
104
  enabled: boolean;
98
105
  };
106
+ /** Dormant memories (src/dormant.ts): when enabled (the default), the
107
+ * sleep decay pass moves a memory that faded below the threshold into the
108
+ * dormant store instead of deleting it. A dormant memory leaves recall and
109
+ * context like a deleted one, but `hippo dormant restore <id>` brings it
110
+ * back and `hippo dormant forget <id>` deletes it for good. A faded memory
111
+ * the secret detector flags is always deleted, never kept dormant.
112
+ * `{"enabled": false}` restores the old delete-on-fade behaviour. */
113
+ dormant: {
114
+ enabled: boolean;
115
+ /** Days a dormant memory is kept before sleep deletes it for good.
116
+ * Default 180. 0 keeps dormant memories forever. */
117
+ retentionDays: number;
118
+ };
99
119
  }
100
120
  export declare function loadConfig(hippoRoot: string): HippoConfig;
101
121
  export declare function saveConfig(hippoRoot: string, config: HippoConfig): void;
package/dist/config.js CHANGED
@@ -45,6 +45,8 @@ const DEFAULT_CONFIG = {
45
45
  pinnedInject: {
46
46
  enabled: true,
47
47
  budget: 1500,
48
+ skipUnchanged: true,
49
+ refreshTurns: 10,
48
50
  },
49
51
  contextProjectIsolation: true,
50
52
  extraction: {
@@ -67,10 +69,17 @@ const DEFAULT_CONFIG = {
67
69
  memoryValue: {
68
70
  enabled: false,
69
71
  },
72
+ dormant: {
73
+ enabled: true,
74
+ retentionDays: 180,
75
+ },
70
76
  };
71
77
  function isMemoryValueConfig(value) {
72
78
  return typeof value === 'object' && value !== null && !Array.isArray(value);
73
79
  }
80
+ function isDormantConfig(value) {
81
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
82
+ }
74
83
  export function loadConfig(hippoRoot) {
75
84
  const configPath = path.join(hippoRoot, 'config.json');
76
85
  if (!fs.existsSync(configPath))
@@ -93,6 +102,28 @@ export function loadConfig(hippoRoot) {
93
102
  `(got ${JSON.stringify(memoryValueRaw)}) - using defaults.`);
94
103
  }
95
104
  const memoryValueOverride = memoryValueRaw !== undefined && isMemoryValueConfig(memoryValueRaw) ? memoryValueRaw : {};
105
+ // Same rule as memoryValue above: a malformed value never silently
106
+ // changes what sleep does. Anything but a real object / boolean / number
107
+ // warns and falls back to the default, which keeps faded memories.
108
+ const dormantRaw = raw.dormant;
109
+ if (dormantRaw !== undefined && !isDormantConfig(dormantRaw)) {
110
+ console.error(`Warning: config.json's "dormant" must be an object like {"enabled": false} ` +
111
+ `(got ${JSON.stringify(dormantRaw)}) - using the default (faded memories kept dormant).`);
112
+ }
113
+ const dormantOverride = dormantRaw !== undefined && isDormantConfig(dormantRaw) ? dormantRaw : {};
114
+ // Only a real boolean counts: {"enabled": "false"} is a truthy string.
115
+ let dormantEnabled = dormantOverride.enabled ?? DEFAULT_CONFIG.dormant.enabled;
116
+ if (dormantEnabled !== true && dormantEnabled !== false) {
117
+ console.error(`Warning: config.json's "dormant.enabled" must be true or false ` +
118
+ `(got ${JSON.stringify(dormantEnabled)}) - using the default (faded memories kept dormant).`);
119
+ dormantEnabled = DEFAULT_CONFIG.dormant.enabled;
120
+ }
121
+ let dormantRetentionDays = dormantOverride.retentionDays ?? DEFAULT_CONFIG.dormant.retentionDays;
122
+ if (!Number.isFinite(dormantRetentionDays) || dormantRetentionDays < 0) {
123
+ console.error(`Warning: config.json's "dormant.retentionDays" must be a number of days, 0 or more ` +
124
+ `(got ${JSON.stringify(dormantRetentionDays)}) - using ${DEFAULT_CONFIG.dormant.retentionDays}.`);
125
+ dormantRetentionDays = DEFAULT_CONFIG.dormant.retentionDays;
126
+ }
96
127
  return {
97
128
  defaultHalfLifeDays: raw.defaultHalfLifeDays ?? DEFAULT_CONFIG.defaultHalfLifeDays,
98
129
  defaultBudget: raw.defaultBudget ?? DEFAULT_CONFIG.defaultBudget,
@@ -120,6 +151,10 @@ export function loadConfig(hippoRoot) {
120
151
  ...DEFAULT_CONFIG.memoryValue,
121
152
  ...memoryValueOverride,
122
153
  },
154
+ dormant: {
155
+ enabled: dormantEnabled,
156
+ retentionDays: dormantRetentionDays,
157
+ },
123
158
  };
124
159
  }
125
160
  catch (err) {
@@ -9,6 +9,12 @@
9
9
  export interface ConsolidationResult {
10
10
  decayed: number;
11
11
  removed: number;
12
+ /** Faded memories moved to the dormant store instead of deleted (config
13
+ * `dormant.enabled`; src/dormant.ts). Always 0 when that is off. */
14
+ dormant: number;
15
+ /** Dormant memories deleted for good this sleep because they outlived
16
+ * `dormant.retentionDays` without a restore. */
17
+ dormantExpired: number;
12
18
  merged: number;
13
19
  semanticCreated: number;
14
20
  replayed: number;
@@ -13,6 +13,8 @@ import { textOverlap, markRetrieved } from './search.js';
13
13
  import { compareEntryIdentity } from './compare.js';
14
14
  import { openHippoDb, closeHippoDb } from './db.js';
15
15
  import { rejectionDigest, findRejectedValue } from './rejection.js';
16
+ import { countExpiredDormant, purgeExpiredDormant } from './dormant.js';
17
+ import { detectSecret } from './secret-detect.js';
16
18
  import { loadPhysicsState, savePhysicsState, refreshParticleProperties } from './physics-state.js';
17
19
  import { simulate } from './physics.js';
18
20
  import { loadConfig } from './config.js';
@@ -22,6 +24,7 @@ import { resolveTenantId } from './tenant.js';
22
24
  import { rescueSet, rankNonPinnedByTenant, validateWeights } from './memory-value.js';
23
25
  import { MEMORY_VALUE_WEIGHTS, SOURCE_ARTIFACT_SHA256 } from './memory-value-weights.js';
24
26
  import { appendAuditEvent } from './audit.js';
27
+ import { migrateDefaultHalfLife } from './half-life-migration.js';
25
28
  const DECAY_THRESHOLD = 0.05;
26
29
  const MERGE_OVERLAP_THRESHOLD = 0.35; // Jaccard similarity for "related"
27
30
  const MERGE_MIN_CLUSTER = 2; // minimum cluster size to merge
@@ -59,6 +62,37 @@ const REPLAY_COUNT_DEFAULT = 5;
59
62
  function isJsonString(value) {
60
63
  return typeof value === 'string';
61
64
  }
65
+ /** Tables whose rows keep a first-class object's backing memory in `memory_id` (ON DELETE SET NULL). */
66
+ const MEMORY_BACKED_TABLES = ['predictions', 'decisions', 'processes', 'policies', 'skills', 'project_briefs', 'customer_notes'];
67
+ /**
68
+ * Ids of memories that back a first-class object (a decision, prediction,
69
+ * process, policy, skill, project brief or customer note). Sleep never
70
+ * retires these: deleting or moving one to dormant storage fires the
71
+ * object's ON DELETE SET NULL and a restore cannot repair the link. Their
72
+ * lifecycle belongs to the object. A table missing from an older schema is
73
+ * skipped.
74
+ */
75
+ function memoriesBackingObjects(hippoRoot) {
76
+ const ids = new Set();
77
+ const db = openHippoDb(hippoRoot);
78
+ try {
79
+ for (const table of MEMORY_BACKED_TABLES) {
80
+ try {
81
+ // SAFETY: SELECT of one nullable TEXT column, filtered to non-null.
82
+ const rows = db.prepare(`SELECT memory_id FROM ${table} WHERE memory_id IS NOT NULL`).all();
83
+ for (const r of rows)
84
+ ids.add(r.memory_id);
85
+ }
86
+ catch {
87
+ // Table not present in this schema version.
88
+ }
89
+ }
90
+ }
91
+ finally {
92
+ closeHippoDb(db);
93
+ }
94
+ return ids;
95
+ }
62
96
  /**
63
97
  * Run a full consolidation pass.
64
98
  */
@@ -68,6 +102,8 @@ export async function consolidate(hippoRoot, options = {}) {
68
102
  const result = {
69
103
  decayed: 0,
70
104
  removed: 0,
105
+ dormant: 0,
106
+ dormantExpired: 0,
71
107
  merged: 0,
72
108
  semanticCreated: 0,
73
109
  replayed: 0,
@@ -86,11 +122,23 @@ export async function consolidate(hippoRoot, options = {}) {
86
122
  details: [],
87
123
  physicsSimulated: 0,
88
124
  };
125
+ // A changed default half-life moves memories still on the old base first,
126
+ // so this pass decays them at the new one (src/half-life-migration.ts).
127
+ const halfLife = migrateDefaultHalfLife(hippoRoot, loadConfig(hippoRoot).defaultHalfLifeDays, { dryRun });
128
+ if (halfLife.rescaled > 0) {
129
+ result.details.push(` ⏳ ${dryRun ? 'would move' : 'moved'} ${halfLife.rescaled} memories from the ${halfLife.from}-day to the ${halfLife.to}-day half-life`);
130
+ }
89
131
  // L9: host-wide by design. Consolidation runs across all tenants in one
90
132
  // pass — per-tenant filtering would create N consolidation runs per host
91
133
  // with no cross-tenant dedup. The api.sleep audit row tags this with the
92
134
  // admin synthetic actor; see api.ts:2050 for the rationale.
93
135
  const all = loadAllEntries(hippoRoot);
136
+ if (dryRun)
137
+ for (const e of all)
138
+ e.half_life_days = halfLife.halfLives.get(e.id) ?? e.half_life_days;
139
+ const backingObjects = memoriesBackingObjects(hippoRoot);
140
+ // Retirable: auto-deletable (never pinned, never raw) and not backing a first-class object.
141
+ const retirable = (entry) => canAutoDelete(entry) && !backingObjects.has(entry.id);
94
142
  const snapshot = new Map(structuredClone(all).map((e) => [e.id, e]));
95
143
  // Load decay options from config + session context
96
144
  const config = loadConfig(hippoRoot);
@@ -103,6 +151,28 @@ export async function consolidate(hippoRoot, options = {}) {
103
151
  // Collect all writes/deletes and batch them at the end
104
152
  const pendingWrites = [];
105
153
  const pendingDeletes = [];
154
+ const pendingDormant = [];
155
+ // A faded, unpinned, unrescued memory leaves active memory one of three
156
+ // ways. A raw receipt is append-only: trg_memories_raw_append_only aborts
157
+ // a DELETE, and with it this whole cycle's batch and every later sleep,
158
+ // so it stays where it is (stored strength refreshed) but sits out the
159
+ // rest of this cycle the way a deleted row would. Anything else goes
160
+ // dormant when config.dormant is on, and is deleted otherwise.
161
+ // Only called for rows `retirable` allows (never pinned, never raw, never backing a first-class object).
162
+ const retireFaded = (entry, strength) => {
163
+ const why = `(strength ${strength.toFixed(4)} < ${DECAY_THRESHOLD})`;
164
+ // A faded secret is deleted, never kept dormant: keeping it would hold a
165
+ // credential on disk that the user reasonably expects forgetting removed.
166
+ if (config.dormant.enabled && !detectSecret(entry).flagged) {
167
+ result.dormant++;
168
+ result.details.push(` 💤 dormant ${entry.id} ${why}`);
169
+ pendingDormant.push({ entry: { ...entry, strength }, strength, reason: 'decay', dormantAt: now.toISOString() });
170
+ return;
171
+ }
172
+ result.removed++;
173
+ result.details.push(` 🗑 removed ${entry.id} ${why}`);
174
+ pendingDeletes.push(entry.id);
175
+ };
106
176
  // -------------------------------------------------------------------------
107
177
  // 1. Decay pass
108
178
  // -------------------------------------------------------------------------
@@ -132,7 +202,7 @@ export async function consolidate(hippoRoot, options = {}) {
132
202
  for (const entry of all) {
133
203
  const strength = calculateStrength(entry, now, decayOpts);
134
204
  strengthById.set(entry.id, strength);
135
- if (canAutoDelete(entry) && strength < DECAY_THRESHOLD) {
205
+ if (retirable(entry) && strength < DECAY_THRESHOLD) {
136
206
  condemned.push(entry);
137
207
  }
138
208
  }
@@ -174,7 +244,7 @@ export async function consolidate(hippoRoot, options = {}) {
174
244
  // preserves flag-off's ordering semantics exactly.)
175
245
  for (const entry of all) {
176
246
  const strength = strengthById.get(entry.id);
177
- if (canAutoDelete(entry) && strength < DECAY_THRESHOLD) {
247
+ if (retirable(entry) && strength < DECAY_THRESHOLD) {
178
248
  if (rescuedIds.has(entry.id)) {
179
249
  // Rescued (D1): standard survivor stored-strength refresh (P2-1).
180
250
  // Confidence is left alone here: it is an epistemic tier, not a
@@ -193,9 +263,7 @@ export async function consolidate(hippoRoot, options = {}) {
193
263
  result.details.push(` 🛟 ${entry.id} (strength ${strength.toFixed(4)} < ${DECAY_THRESHOLD})${rankNote}`);
194
264
  }
195
265
  else {
196
- result.removed++;
197
- result.details.push(` 🗑 removed ${entry.id} (strength ${strength.toFixed(4)} < ${DECAY_THRESHOLD})`);
198
- pendingDeletes.push(entry.id);
266
+ retireFaded(entry, strength);
199
267
  }
200
268
  }
201
269
  else {
@@ -211,10 +279,8 @@ export async function consolidate(hippoRoot, options = {}) {
211
279
  else {
212
280
  for (const entry of all) {
213
281
  const strength = calculateStrength(entry, now, decayOpts);
214
- if (canAutoDelete(entry) && strength < DECAY_THRESHOLD) {
215
- result.removed++;
216
- result.details.push(` 🗑 removed ${entry.id} (strength ${strength.toFixed(4)} < ${DECAY_THRESHOLD})`);
217
- pendingDeletes.push(entry.id);
282
+ if (retirable(entry) && strength < DECAY_THRESHOLD) {
283
+ retireFaded(entry, strength);
218
284
  }
219
285
  else {
220
286
  // Only strength is a cached computation; confidence stays as stored.
@@ -732,14 +798,33 @@ export async function consolidate(hippoRoot, options = {}) {
732
798
  }
733
799
  result.removedIds = pendingDeletes;
734
800
  // One transaction; the snapshot keeps what the DAG passes and other writers changed while sleep ran.
801
+ // Dormant moves ride in the same transaction (src/dormant.ts).
735
802
  if (!dryRun) {
736
- const deleted = new Set(batchWriteAndDelete(hippoRoot, pendingWrites, pendingDeletes, { snapshot }));
737
- for (const id of pendingDeletes) {
738
- if (!deleted.has(id))
803
+ const left = new Set(batchWriteAndDelete(hippoRoot, pendingWrites, pendingDeletes, { snapshot, dormant: pendingDormant }));
804
+ for (const id of [...pendingDeletes, ...pendingDormant.map((m) => m.entry.id)]) {
805
+ if (!left.has(id))
739
806
  result.details.push(` ↩ ${id} not removed: pinned or already gone before sleep saved`);
740
807
  }
741
- result.removedIds = pendingDeletes.filter((id) => deleted.has(id));
808
+ result.removedIds = pendingDeletes.filter((id) => left.has(id));
742
809
  result.removed = result.removedIds.length;
810
+ result.dormant = pendingDormant.filter((m) => left.has(m.entry.id)).length;
811
+ }
812
+ // Dormant retention: a dormant memory nobody restored within
813
+ // dormant.retentionDays is deleted for good (0 keeps them forever). Runs
814
+ // even when dormant.enabled is off, so turning it off still ages out what
815
+ // earlier sleeps kept.
816
+ if (config.dormant.retentionDays > 0) {
817
+ const cutoff = new Date(now.getTime() - config.dormant.retentionDays * 24 * 60 * 60 * 1000).toISOString();
818
+ const db = openHippoDb(hippoRoot);
819
+ try {
820
+ result.dormantExpired = dryRun ? countExpiredDormant(db, cutoff) : purgeExpiredDormant(db, cutoff);
821
+ }
822
+ finally {
823
+ closeHippoDb(db);
824
+ }
825
+ if (result.dormantExpired > 0) {
826
+ result.details.push(` ⌛ ${dryRun ? 'would expire' : 'expired'} ${result.dormantExpired} dormant memor${result.dormantExpired === 1 ? 'y' : 'ies'} older than ${config.dormant.retentionDays} days`);
827
+ }
743
828
  }
744
829
  // -------------------------------------------------------------------------
745
830
  // 4. Log run
package/dist/db.js CHANGED
@@ -11,7 +11,7 @@ const require = createRequire(import.meta.url);
11
11
  // runtime (Node's built-in synchronous SQLite module); there are no bundled
12
12
  // types for it here, so this require + cast is the module's documented boundary.
13
13
  const { DatabaseSync } = require('node:sqlite');
14
- const CURRENT_SCHEMA_VERSION = 43;
14
+ const CURRENT_SCHEMA_VERSION = 45;
15
15
  const MIGRATIONS = [
16
16
  {
17
17
  version: 1,
@@ -2367,6 +2367,62 @@ const MIGRATIONS = [
2367
2367
  `);
2368
2368
  },
2369
2369
  },
2370
+ {
2371
+ version: 44,
2372
+ up: (db) => {
2373
+ // Dormant memories (src/dormant.ts): with `dormant.enabled`, the sleep
2374
+ // decay pass moves a faded memory here instead of deleting it. The row
2375
+ // leaves `memories` in the same transaction, so recall, context and
2376
+ // every sleep pass stop seeing it; entry_json is the full MemoryEntry
2377
+ // snapshot `hippo dormant restore` writes back. No FK to memories (the
2378
+ // memories row is gone by design). Additive only, v41 precedent: no
2379
+ // min_compatible_binary bump. An older binary ignores the table and
2380
+ // keeps deleting faded memories as it always did.
2381
+ db.exec(`
2382
+ CREATE TABLE IF NOT EXISTS dormant_memories (
2383
+ tenant_id TEXT NOT NULL DEFAULT 'default',
2384
+ id TEXT NOT NULL,
2385
+ content TEXT NOT NULL,
2386
+ entry_json TEXT NOT NULL,
2387
+ reason TEXT NOT NULL,
2388
+ strength REAL NOT NULL,
2389
+ dormant_at TEXT NOT NULL,
2390
+ PRIMARY KEY (tenant_id, id)
2391
+ ) WITHOUT ROWID;
2392
+ CREATE INDEX IF NOT EXISTS idx_dormant_memories_tenant_time
2393
+ ON dormant_memories(tenant_id, dormant_at DESC);
2394
+ `);
2395
+ },
2396
+ },
2397
+ {
2398
+ version: 45,
2399
+ up: (db) => {
2400
+ // Token ledger (src/token-ledger.ts, ROADMAP TE0): one row per block of
2401
+ // memory text hippo hands an agent (hook, CLI, MCP, HTTP). `event` is
2402
+ // 'inject' (sent), 'skip' (unchanged since the session's last inject,
2403
+ // not sent) or 'reset' (compaction dropped earlier injections, so the
2404
+ // next one must be sent). block_hash lets the per-prompt hook skip an
2405
+ // unchanged block. Rows older than the retention window are pruned on
2406
+ // write. Additive only: no min_compatible_binary bump.
2407
+ db.exec(`
2408
+ CREATE TABLE IF NOT EXISTS token_ledger (
2409
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
2410
+ ts TEXT NOT NULL,
2411
+ tenant_id TEXT NOT NULL DEFAULT 'default',
2412
+ session_id TEXT,
2413
+ surface TEXT NOT NULL,
2414
+ event TEXT NOT NULL,
2415
+ items INTEGER NOT NULL DEFAULT 0,
2416
+ tokens INTEGER NOT NULL DEFAULT 0,
2417
+ block_hash TEXT
2418
+ );
2419
+ CREATE INDEX IF NOT EXISTS idx_token_ledger_session
2420
+ ON token_ledger(tenant_id, session_id, surface, id DESC);
2421
+ CREATE INDEX IF NOT EXISTS idx_token_ledger_ts
2422
+ ON token_ledger(ts);
2423
+ `);
2424
+ },
2425
+ },
2370
2426
  ];
2371
2427
  function tableHasColumn(db, tableName, columnName) {
2372
2428
  if (!/^[a-z_]+$/i.test(tableName))
@@ -0,0 +1,34 @@
1
+ /** Outcome of one check. `fail` makes `hippo doctor` exit 1. */
2
+ export type DoctorStatus = 'pass' | 'warn' | 'fail' | 'info';
3
+ /** One check in a {@link DoctorReport}. */
4
+ export interface DoctorCheck {
5
+ id: string;
6
+ status: DoctorStatus;
7
+ detail: string;
8
+ /** Command or step that resolves a warn or fail. */
9
+ fix?: string;
10
+ }
11
+ /** Result of {@link runDoctor}. */
12
+ export interface DoctorReport {
13
+ ok: boolean;
14
+ version: string;
15
+ /** The store the checks ran against, or null when none exists. */
16
+ store: string | null;
17
+ checks: DoctorCheck[];
18
+ }
19
+ /** Inputs for {@link runDoctor}; defaults come from the process. */
20
+ export interface DoctorOpts {
21
+ cwd?: string;
22
+ /** Home directory used to find agent configuration (~/.claude). */
23
+ home?: string;
24
+ version: string;
25
+ nodeVersion?: string;
26
+ now?: Date;
27
+ }
28
+ /** Minimum Node.js version hippo supports (package.json engines). */
29
+ export declare const MIN_NODE = "22.16.0";
30
+ /** Run every check. Never throws for a broken install; broken parts become failed checks. */
31
+ export declare function runDoctor(opts: DoctorOpts): DoctorReport;
32
+ /** Human-readable rendering of a {@link DoctorReport}. */
33
+ export declare function formatDoctor(report: DoctorReport): string;
34
+ //# sourceMappingURL=doctor.d.ts.map
package/dist/doctor.js ADDED
@@ -0,0 +1,174 @@
1
+ /**
2
+ * `hippo doctor`: a health check people and agents can run after installing
3
+ * hippo, or when something seems off. Read-only: it never creates a store,
4
+ * installs a hook or migrates a database. Every non-passing check names the
5
+ * command that fixes it, so an agent can act on the `--json` output.
6
+ */
7
+ import * as fs from 'node:fs';
8
+ import * as os from 'node:os';
9
+ import * as path from 'node:path';
10
+ import { findHippoStoreDir } from './project-identity.js';
11
+ import { getGlobalRoot } from './shared.js';
12
+ import { isInitialized, loadStats } from './store.js';
13
+ import { openHippoDb, closeHippoDb, getSchemaVersion, getCurrentSchemaVersion } from './db.js';
14
+ import { isEmbeddingAvailable } from './embeddings.js';
15
+ /** Minimum Node.js version hippo supports (package.json engines). */
16
+ export const MIN_NODE = '22.16.0';
17
+ function versionAtLeast(actual, min) {
18
+ const a = actual.replace(/^v/, '').split('.').map(Number);
19
+ const b = min.split('.').map(Number);
20
+ for (let i = 0; i < 3; i++) {
21
+ const x = a[i] ?? 0;
22
+ const y = b[i] ?? 0;
23
+ if (x !== y)
24
+ return x > y;
25
+ }
26
+ return true;
27
+ }
28
+ function readJson(file) {
29
+ try {
30
+ // SAFETY: JSON.parse returns a JSON value by definition.
31
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
37
+ function countRows(db, table) {
38
+ 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
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
47
+ /** Run every check. Never throws for a broken install; broken parts become failed checks. */
48
+ export function runDoctor(opts) {
49
+ const cwd = opts.cwd ?? process.cwd();
50
+ const home = opts.home ?? os.homedir();
51
+ const now = opts.now ?? new Date();
52
+ const checks = [];
53
+ const node = opts.nodeVersion ?? process.versions.node;
54
+ checks.push(versionAtLeast(node, MIN_NODE)
55
+ ? { id: 'node', status: 'pass', detail: `Node.js ${node}` }
56
+ : { id: 'node', status: 'fail', detail: `Node.js ${node} is older than ${MIN_NODE}`, fix: `Install Node.js ${MIN_NODE} or newer` });
57
+ const local = findHippoStoreDir(cwd);
58
+ const globalRoot = getGlobalRoot();
59
+ const hasGlobal = isInitialized(globalRoot);
60
+ let store = null;
61
+ if (local !== null) {
62
+ store = local;
63
+ checks.push({ id: 'store', status: 'pass', detail: `project store at ${local}${hasGlobal ? ` (global store at ${globalRoot} too)` : ''}` });
64
+ }
65
+ else if (hasGlobal) {
66
+ store = globalRoot;
67
+ checks.push({ id: 'store', status: 'warn', detail: `no project store here; using the global store at ${globalRoot}`, fix: 'hippo init (in the project root)' });
68
+ }
69
+ else {
70
+ checks.push({ id: 'store', status: 'fail', detail: 'no hippo store found (project or global)', fix: 'hippo init (in the project root), or hippo init --global' });
71
+ }
72
+ if (store !== null) {
73
+ let db = null;
74
+ try {
75
+ db = openHippoDb(store);
76
+ const have = getSchemaVersion(db);
77
+ const want = getCurrentSchemaVersion();
78
+ checks.push(have === want
79
+ ? { id: 'schema', status: 'pass', detail: `database schema v${have}` }
80
+ : have > want
81
+ ? { id: 'schema', status: 'fail', detail: `database schema v${have} is newer than this hippo (v${want})`, fix: 'npm install -g hippo-memory@latest' }
82
+ : { 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');
85
+ const memoryCheck = { id: 'memories', status: 'info', detail: `${memories ?? '?'} memories${dormant !== null ? `, ${dormant} dormant` : ''}` };
86
+ if (memories === 0) {
87
+ memoryCheck.status = 'warn';
88
+ memoryCheck.fix = 'hippo learn --git (seed lessons from git history)';
89
+ }
90
+ checks.push(memoryCheck);
91
+ const since = new Date(now.getTime() - 7 * 86_400_000).toISOString();
92
+ try {
93
+ // SAFETY: COUNT/SUM aggregate row.
94
+ const row = db.prepare(`SELECT COUNT(*) AS n, COALESCE(SUM(tokens), 0) AS t FROM token_ledger WHERE ts >= ? AND event = 'inject'`).get(since);
95
+ checks.push({ id: 'tokens', status: 'info', detail: `${Number(row?.n ?? 0)} memory blocks sent to agents in 7 days, about ${Number(row?.t ?? 0)} tokens (hippo tokens for detail)` });
96
+ }
97
+ catch {
98
+ checks.push({ id: 'tokens', status: 'info', detail: 'no token ledger yet (created on the next write)' });
99
+ }
100
+ }
101
+ catch (err) {
102
+ 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' });
103
+ }
104
+ finally {
105
+ if (db !== null)
106
+ closeHippoDb(db);
107
+ }
108
+ try {
109
+ const runs = loadStats(store)['consolidation_runs'];
110
+ const last = Array.isArray(runs) && runs.length > 0 ? runs[runs.length - 1] : null;
111
+ // SAFETY: the constructor check above narrows `last` to a plain JSON object.
112
+ const ts = last !== null && last !== undefined && !Array.isArray(last) && last.constructor === Object ? last.timestamp : undefined;
113
+ const when = ts !== undefined && ts !== null ? Date.parse(String(ts)) : Number.NaN;
114
+ if (Number.isNaN(when)) {
115
+ 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)' });
116
+ }
117
+ else {
118
+ const days = Math.floor((now.getTime() - when) / 86_400_000);
119
+ checks.push(days > 7
120
+ ? { id: 'sleep', status: 'warn', detail: `last sleep ${days} days ago`, fix: 'hippo sleep, and check the session-end hook is installed' }
121
+ : { id: 'sleep', status: 'pass', detail: `last sleep ${days === 0 ? 'today' : `${days} day${days === 1 ? '' : 's'} ago`}` });
122
+ }
123
+ }
124
+ catch {
125
+ checks.push({ id: 'sleep', status: 'info', detail: 'sleep history unavailable' });
126
+ }
127
+ }
128
+ const claudeDir = path.join(home, '.claude');
129
+ if (fs.existsSync(claudeDir)) {
130
+ const settings = readJson(path.join(claudeDir, 'settings.json'));
131
+ const text = settings === null ? '' : JSON.stringify(settings);
132
+ const viaPlugin = /hippo-memory@/.test(text);
133
+ // Each hook and what it does, so a partial install says what is missing.
134
+ const hooks = [
135
+ ['hippo context --pinned-only', 'per-prompt memory'],
136
+ ['hippo session-end', 'session-end capture and sleep'],
137
+ ['hippo pre-compact', 'compaction snapshot and capture'],
138
+ ['hippo compact-resume', 'resume after compaction'],
139
+ ['hippo post-compact', 'the message after compaction'],
140
+ ['hippo capture-error', 'failed-tool capture'],
141
+ ];
142
+ const missing = hooks.filter(([marker]) => !text.includes(marker)).map(([, what]) => what);
143
+ if (viaPlugin) {
144
+ checks.push({ id: 'claude-code', status: 'pass', detail: 'Claude Code: hippo plugin enabled (all hooks, including compaction and failed-tool capture)' });
145
+ }
146
+ else if (missing.length === 0) {
147
+ checks.push({ id: 'claude-code', status: 'pass', detail: 'Claude Code: all hippo hooks installed, including compaction and failed-tool capture' });
148
+ }
149
+ else if (missing.length === hooks.length) {
150
+ checks.push({ id: 'claude-code', status: 'warn', detail: 'Claude Code found, but no hippo hooks are installed', fix: 'hippo hook install claude-code' });
151
+ }
152
+ else {
153
+ checks.push({ id: 'claude-code', status: 'warn', detail: `Claude Code: hippo hooks missing for ${missing.join(', ')}`, fix: 'hippo hook install claude-code (adds only what is missing)' });
154
+ }
155
+ }
156
+ else {
157
+ checks.push({ id: 'claude-code', status: 'info', detail: 'Claude Code not found; other agents can use hippo over MCP (hippo mcp)' });
158
+ }
159
+ checks.push({ id: 'embeddings', status: 'info', detail: isEmbeddingAvailable() ? 'local embeddings available (hybrid search)' : 'embeddings not installed; recall uses BM25 (optional: hippo embed --help)' });
160
+ return { ok: !checks.some((c) => c.status === 'fail'), version: opts.version, store, checks };
161
+ }
162
+ /** Human-readable rendering of a {@link DoctorReport}. */
163
+ export function formatDoctor(report) {
164
+ const mark = { pass: 'ok ', warn: 'warn', fail: 'FAIL', info: 'info' };
165
+ const lines = [`hippo ${report.version} doctor`, ''];
166
+ for (const c of report.checks) {
167
+ lines.push(` [${mark[c.status]}] ${c.id.padEnd(11)} ${c.detail}`);
168
+ if (c.fix && (c.status === 'warn' || c.status === 'fail'))
169
+ lines.push(` ${''.padEnd(11)} fix: ${c.fix}`);
170
+ }
171
+ lines.push('', report.ok ? 'No failures.' : 'Some checks failed; run the fix commands above.');
172
+ return lines.join('\n');
173
+ }
174
+ //# sourceMappingURL=doctor.js.map