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.
- package/README.md +57 -15
- package/dist/ablation.d.ts +10 -1
- package/dist/ablation.js +17 -1
- package/dist/api.d.ts +60 -1
- package/dist/api.js +189 -7
- package/dist/audit.d.ts +1 -1
- package/dist/capture-error.d.ts +20 -0
- package/dist/capture-error.js +82 -0
- package/dist/capture.d.ts +25 -8
- package/dist/capture.js +100 -5
- package/dist/cli.d.ts +6 -1
- package/dist/cli.js +381 -48
- package/dist/config.d.ts +20 -0
- package/dist/config.js +35 -0
- package/dist/consolidate.d.ts +6 -0
- package/dist/consolidate.js +98 -13
- package/dist/db.js +57 -1
- package/dist/doctor.d.ts +34 -0
- package/dist/doctor.js +174 -0
- package/dist/dormant.d.ts +91 -0
- package/dist/dormant.js +121 -0
- package/dist/eval-stats.d.ts +123 -0
- package/dist/eval-stats.js +187 -0
- package/dist/half-life-migration.d.ts +55 -0
- package/dist/half-life-migration.js +111 -0
- package/dist/hooks.d.ts +4 -0
- package/dist/hooks.js +47 -0
- package/dist/mcp/server.d.ts +6 -0
- package/dist/mcp/server.js +70 -13
- package/dist/memory.d.ts +16 -2
- package/dist/memory.js +27 -5
- package/dist/physics-config.js +5 -1
- package/dist/recall-scope.d.ts +24 -0
- package/dist/recall-scope.js +41 -0
- package/dist/reject-flow.d.ts +3 -3
- package/dist/reject-flow.js +10 -3
- package/dist/search.d.ts +4 -4
- package/dist/search.js +23 -18
- package/dist/server.js +11 -1
- package/dist/store.d.ts +12 -1
- package/dist/store.js +58 -12
- package/dist/token-ledger.d.ts +119 -0
- package/dist/token-ledger.js +181 -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/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) {
|
package/dist/consolidate.d.ts
CHANGED
|
@@ -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;
|
package/dist/consolidate.js
CHANGED
|
@@ -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 (
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
215
|
-
|
|
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
|
|
737
|
-
for (const id of pendingDeletes) {
|
|
738
|
-
if (!
|
|
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) =>
|
|
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 =
|
|
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))
|
package/dist/doctor.d.ts
ADDED
|
@@ -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
|