@psiclawops/hypermem 0.8.5 → 0.9.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 (87) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/INSTALL.md +132 -9
  3. package/README.md +119 -272
  4. package/bench/README.md +42 -0
  5. package/bench/data-access-bench.mjs +380 -0
  6. package/bin/hypermem-bench.mjs +2 -0
  7. package/bin/hypermem-doctor.mjs +412 -0
  8. package/bin/hypermem-model-audit.mjs +339 -0
  9. package/bin/hypermem-status.mjs +491 -70
  10. package/dist/adaptive-lifecycle.d.ts +81 -0
  11. package/dist/adaptive-lifecycle.d.ts.map +1 -0
  12. package/dist/adaptive-lifecycle.js +190 -0
  13. package/dist/budget-policy.d.ts +1 -1
  14. package/dist/budget-policy.d.ts.map +1 -1
  15. package/dist/budget-policy.js +10 -5
  16. package/dist/cache.d.ts +1 -0
  17. package/dist/cache.d.ts.map +1 -1
  18. package/dist/cache.js +2 -0
  19. package/dist/composition-snapshot-integrity.d.ts +36 -0
  20. package/dist/composition-snapshot-integrity.d.ts.map +1 -0
  21. package/dist/composition-snapshot-integrity.js +131 -0
  22. package/dist/composition-snapshot-runtime.d.ts +59 -0
  23. package/dist/composition-snapshot-runtime.d.ts.map +1 -0
  24. package/dist/composition-snapshot-runtime.js +250 -0
  25. package/dist/composition-snapshot-store.d.ts +44 -0
  26. package/dist/composition-snapshot-store.d.ts.map +1 -0
  27. package/dist/composition-snapshot-store.js +117 -0
  28. package/dist/compositor.d.ts +125 -1
  29. package/dist/compositor.d.ts.map +1 -1
  30. package/dist/compositor.js +692 -44
  31. package/dist/doc-chunk-store.d.ts +19 -0
  32. package/dist/doc-chunk-store.d.ts.map +1 -1
  33. package/dist/doc-chunk-store.js +56 -6
  34. package/dist/hybrid-retrieval.d.ts +38 -0
  35. package/dist/hybrid-retrieval.d.ts.map +1 -1
  36. package/dist/hybrid-retrieval.js +86 -1
  37. package/dist/index.d.ts +12 -3
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +28 -2
  40. package/dist/knowledge-store.d.ts +4 -1
  41. package/dist/knowledge-store.d.ts.map +1 -1
  42. package/dist/knowledge-store.js +27 -4
  43. package/dist/library-schema.d.ts +12 -8
  44. package/dist/library-schema.d.ts.map +1 -1
  45. package/dist/library-schema.js +22 -8
  46. package/dist/message-store.d.ts.map +1 -1
  47. package/dist/message-store.js +7 -3
  48. package/dist/metrics-dashboard.d.ts +18 -1
  49. package/dist/metrics-dashboard.d.ts.map +1 -1
  50. package/dist/metrics-dashboard.js +52 -14
  51. package/dist/reranker.d.ts +1 -1
  52. package/dist/reranker.js +2 -2
  53. package/dist/schema.d.ts +1 -1
  54. package/dist/schema.d.ts.map +1 -1
  55. package/dist/schema.js +28 -1
  56. package/dist/seed.d.ts.map +1 -1
  57. package/dist/seed.js +2 -0
  58. package/dist/topic-synthesizer.d.ts +20 -0
  59. package/dist/topic-synthesizer.d.ts.map +1 -1
  60. package/dist/topic-synthesizer.js +113 -3
  61. package/dist/trigger-registry.d.ts.map +1 -1
  62. package/dist/trigger-registry.js +10 -2
  63. package/dist/types.d.ts +271 -1
  64. package/dist/types.d.ts.map +1 -1
  65. package/dist/version.d.ts +7 -7
  66. package/dist/version.d.ts.map +1 -1
  67. package/dist/version.js +17 -7
  68. package/docs/DIAGNOSTICS.md +205 -0
  69. package/docs/INTEGRATION_VALIDATION.md +186 -0
  70. package/docs/MIGRATION.md +9 -6
  71. package/docs/MIGRATION_GUIDE.md +125 -101
  72. package/docs/ROADMAP.md +238 -20
  73. package/docs/TUNING.md +19 -5
  74. package/install.sh +152 -401
  75. package/memory-plugin/LICENSE +190 -0
  76. package/memory-plugin/README.md +20 -0
  77. package/memory-plugin/dist/index.js +50 -0
  78. package/memory-plugin/package.json +2 -2
  79. package/package.json +18 -4
  80. package/plugin/LICENSE +190 -0
  81. package/plugin/README.md +20 -0
  82. package/plugin/dist/index.d.ts +29 -0
  83. package/plugin/dist/index.d.ts.map +1 -1
  84. package/plugin/dist/index.js +288 -23
  85. package/plugin/dist/index.js.map +1 -1
  86. package/plugin/package.json +2 -2
  87. package/scripts/install-runtime.mjs +12 -1
@@ -3,27 +3,41 @@
3
3
  * hypermem status — health check and metrics dashboard CLI
4
4
  *
5
5
  * Usage:
6
- * node bin/hypermem-status.mjs # full dashboard
7
- * node bin/hypermem-status.mjs --agent forge # scoped to one agent
8
- * node bin/hypermem-status.mjs --json # machine-readable output
9
- * node bin/hypermem-status.mjs --health # health checks only (exit 1 on failure)
6
+ * hypermem-status # metrics dashboard
7
+ * hypermem-status --master # concise operator health surface
8
+ * hypermem-status --agent forge --master # scoped operator health surface
9
+ * hypermem-status --json # machine-readable output
10
+ * hypermem-status --health # health checks only (exit 1 on failure)
10
11
  *
11
12
  * Requires: compiled dist/ (run `npm run build` first)
12
13
  */
13
14
 
14
- import { existsSync } from 'node:fs';
15
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
15
16
  import { resolve, join, dirname } from 'node:path';
16
17
  import { fileURLToPath } from 'node:url';
17
18
  import os from 'node:os';
19
+ import { DatabaseSync } from 'node:sqlite';
18
20
 
19
21
  const __dir = dirname(fileURLToPath(import.meta.url));
20
22
  const root = resolve(__dir, '..');
23
+ const homeDir = process.env.HOME || os.homedir();
24
+
25
+ const DEFAULT_EMBEDDING = {
26
+ provider: 'ollama',
27
+ model: 'nomic-embed-text',
28
+ dimensions: 768,
29
+ batchSize: null,
30
+ openaiBaseUrl: null,
31
+ ollamaUrl: null,
32
+ geminiBaseUrl: null,
33
+ };
21
34
 
22
35
  // ── Arg parsing ──────────────────────────────────────────────────
23
36
  const args = process.argv.slice(2);
24
37
  const flags = {
25
38
  json: args.includes('--json'),
26
39
  health: args.includes('--health'),
40
+ master: args.includes('--master') || args.includes('--planks'),
27
41
  help: args.includes('--help') || args.includes('-h'),
28
42
  agent: null,
29
43
  };
@@ -38,20 +52,94 @@ if (flags.help) {
38
52
  hypermem status — health check and metrics dashboard
39
53
 
40
54
  Usage:
41
- hypermem-status.mjs [options]
55
+ hypermem-status [options]
42
56
 
43
57
  Options:
44
- --agent <id> Scope metrics to a specific agent
45
- --json Output raw JSON instead of formatted summary
46
- --health Health checks only (exits 1 if any check fails)
47
- -h, --help Show this help
58
+ --master Concise master health status for main HyperMem planks
59
+ --agent <id> Scope metrics to a specific agent
60
+ --json Output raw JSON instead of formatted summary
61
+ --health Health checks only (exits 1 if any check fails)
62
+ -h, --help Show this help
48
63
  `);
49
64
  process.exit(0);
50
65
  }
51
66
 
52
- // ── Resolve data directory ───────────────────────────────────────
53
- const dataDir = process.env.HYPERMEM_DATA_DIR
54
- || join(process.env.HOME || os.homedir(), '.openclaw', 'hypermem');
67
+ // ── Resolve config and data directory ────────────────────────────
68
+ function readJsonIfExists(filePath) {
69
+ if (!existsSync(filePath)) return null;
70
+ try {
71
+ return JSON.parse(readFileSync(filePath, 'utf8'));
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ function stripSecretFields(obj) {
78
+ if (!obj || typeof obj !== 'object') return obj;
79
+ const out = Array.isArray(obj) ? [] : {};
80
+ for (const [key, value] of Object.entries(obj)) {
81
+ if (/apiKey|token|secret|password|authorization/i.test(key)) {
82
+ out[key] = value ? '[redacted]' : value;
83
+ } else if (value && typeof value === 'object') {
84
+ out[key] = stripSecretFields(value);
85
+ } else {
86
+ out[key] = value;
87
+ }
88
+ }
89
+ return out;
90
+ }
91
+
92
+ function readOpenClawPluginConfig(defaultDataDir) {
93
+ const openclawConfigPath = process.env.OPENCLAW_CONFIG
94
+ || join(homeDir, '.openclaw', 'openclaw.json');
95
+ const parsed = readJsonIfExists(openclawConfigPath);
96
+ const config = parsed?.plugins?.entries?.hypercompositor?.config
97
+ ?? parsed?.plugins?.entries?.hypermem?.config
98
+ ?? null;
99
+ if (!config || typeof config !== 'object') return { config: {}, source: null, dataDir: defaultDataDir };
100
+ return {
101
+ config,
102
+ source: openclawConfigPath,
103
+ dataDir: config.dataDir || defaultDataDir,
104
+ };
105
+ }
106
+
107
+ function resolveRuntimeConfig() {
108
+ const defaultDataDir = process.env.HYPERMEM_DATA_DIR || join(homeDir, '.openclaw', 'hypermem');
109
+ const central = readOpenClawPluginConfig(defaultDataDir);
110
+ const dataDir = process.env.HYPERMEM_DATA_DIR || central.dataDir || defaultDataDir;
111
+ const legacyConfigPath = join(dataDir, 'config.json');
112
+ const fileConfig = readJsonIfExists(legacyConfigPath) ?? {};
113
+ const pluginConfig = central.config ?? {};
114
+
115
+ // Mirrors plugin loadUserConfig(): config.json fallback, plugin config wins.
116
+ const merged = { ...fileConfig };
117
+ for (const key of [
118
+ 'contextWindowSize', 'contextWindowReserve', 'deferToolPruning',
119
+ 'verboseLogging', 'warmCacheReplayThresholdMs', 'subagentWarming',
120
+ ]) {
121
+ if (pluginConfig[key] != null) merged[key] = pluginConfig[key];
122
+ }
123
+ for (const key of ['contextWindowOverrides', 'compositor', 'eviction', 'embedding', 'reranker']) {
124
+ if (pluginConfig[key]) merged[key] = { ...(merged[key] ?? {}), ...pluginConfig[key] };
125
+ }
126
+
127
+ const embedding = { ...DEFAULT_EMBEDDING, ...(merged.embedding ?? {}) };
128
+ const sources = [];
129
+ if (existsSync(legacyConfigPath)) sources.push(legacyConfigPath);
130
+ if (central.source && Object.keys(pluginConfig).length > 0) sources.push(`${central.source}:plugins.entries.hypercompositor.config`);
131
+
132
+ return {
133
+ dataDir,
134
+ config: merged,
135
+ embedding,
136
+ configSources: sources.length > 0 ? sources : ['defaults'],
137
+ redactedConfig: stripSecretFields(merged),
138
+ };
139
+ }
140
+
141
+ const runtime = resolveRuntimeConfig();
142
+ const dataDir = runtime.dataDir;
55
143
 
56
144
  if (!existsSync(dataDir)) {
57
145
  console.error(`Error: data directory not found: ${dataDir}`);
@@ -59,13 +147,14 @@ if (!existsSync(dataDir)) {
59
147
  process.exit(1);
60
148
  }
61
149
 
62
- // ── Open DBs ─────────────────────────────────────────────────────
63
- import { DatabaseSync } from 'node:sqlite';
64
-
65
- function openDb(filePath, label) {
150
+ // ── DB helpers ───────────────────────────────────────────────────
151
+ function openDb(filePath, label, required = true) {
66
152
  if (!existsSync(filePath)) {
67
- console.error(`Error: ${label} not found: ${filePath}`);
68
- process.exit(1);
153
+ if (required) {
154
+ console.error(`Error: ${label} not found: ${filePath}`);
155
+ process.exit(1);
156
+ }
157
+ return null;
69
158
  }
70
159
  try {
71
160
  const db = new DatabaseSync(filePath, { open: true });
@@ -73,42 +162,360 @@ function openDb(filePath, label) {
73
162
  db.exec('PRAGMA busy_timeout = 3000');
74
163
  return db;
75
164
  } catch (err) {
76
- console.error(`Error opening ${label}: ${err.message}`);
77
- process.exit(1);
165
+ if (required) {
166
+ console.error(`Error opening ${label}: ${err.message}`);
167
+ process.exit(1);
168
+ }
169
+ return null;
78
170
  }
79
171
  }
80
172
 
81
- // Main DB: pick any agent's messages.db for composition stats, or fall back
82
- // For fleet-wide, we need at least one agent's message db.
83
- // The metrics dashboard expects a "main" db — find one.
84
- let mainDbPath;
173
+ function safeGet(db, sql, params = []) {
174
+ try { return db.prepare(sql).get(...params); } catch { return null; }
175
+ }
176
+
177
+ function safeAll(db, sql, params = []) {
178
+ try { return db.prepare(sql).all(...params); } catch { return []; }
179
+ }
180
+
181
+ function fileInfo(filePath) {
182
+ if (!existsSync(filePath)) return { exists: false, bytes: 0, mb: 0 };
183
+ const bytes = statSync(filePath).size;
184
+ return { exists: true, bytes, mb: Math.round((bytes / 1024 / 1024) * 10) / 10 };
185
+ }
85
186
 
86
- if (flags.agent) {
87
- mainDbPath = join(dataDir, 'agents', flags.agent, 'messages.db');
88
- } else {
89
- // Find first available agent messages.db
187
+ function pct(indexed, total) {
188
+ if (!total) return null;
189
+ return Math.round((indexed / total) * 1000) / 10;
190
+ }
191
+
192
+ function statusForCoverage(percent, minimum) {
193
+ if (percent === null) return 'n/a';
194
+ return percent >= minimum ? 'ok' : 'warn';
195
+ }
196
+
197
+ function icon(status) {
198
+ if (status === 'ok') return '✅';
199
+ if (status === 'warn') return '⚠️';
200
+ if (status === 'fail') return '❌';
201
+ return '•';
202
+ }
203
+
204
+ function findMessageDb(agentId) {
205
+ if (agentId) return join(dataDir, 'agents', agentId, 'messages.db');
90
206
  const agentsDir = join(dataDir, 'agents');
91
- if (existsSync(agentsDir)) {
92
- const { readdirSync } = await import('node:fs');
93
- const agents = readdirSync(agentsDir, { withFileTypes: true })
94
- .filter(d => d.isDirectory())
95
- .map(d => d.name);
96
- for (const a of agents) {
97
- const candidate = join(agentsDir, a, 'messages.db');
98
- if (existsSync(candidate)) {
99
- mainDbPath = candidate;
100
- break;
101
- }
207
+ if (!existsSync(agentsDir)) return null;
208
+ const agents = readdirSync(agentsDir, { withFileTypes: true })
209
+ .filter(d => d.isDirectory())
210
+ .map(d => d.name)
211
+ .sort();
212
+ for (const agent of agents) {
213
+ const candidate = join(agentsDir, agent, 'messages.db');
214
+ if (existsSync(candidate)) return candidate;
215
+ }
216
+ return null;
217
+ }
218
+
219
+ function collectMessageStats(agentId) {
220
+ const agentsDir = join(dataDir, 'agents');
221
+ if (!existsSync(agentsDir)) return { agentsWithMessages: 0, totalMessages: 0, newestMessageAt: null };
222
+ const agents = agentId ? [agentId] : readdirSync(agentsDir, { withFileTypes: true })
223
+ .filter(d => d.isDirectory())
224
+ .map(d => d.name);
225
+ let agentsWithMessages = 0;
226
+ let totalMessages = 0;
227
+ let newestMessageAt = null;
228
+ for (const agent of agents) {
229
+ const dbPath = join(agentsDir, agent, 'messages.db');
230
+ if (!existsSync(dbPath)) continue;
231
+ const db = openDb(dbPath, `${agent} messages.db`, false);
232
+ if (!db) continue;
233
+ agentsWithMessages += 1;
234
+ const count = safeGet(db, 'SELECT COUNT(*) AS count FROM messages')?.count ?? 0;
235
+ const newest = safeGet(db, 'SELECT MAX(created_at) AS newest FROM messages')?.newest ?? null;
236
+ totalMessages += count;
237
+ if (newest && (!newestMessageAt || newest > newestMessageAt)) newestMessageAt = newest;
238
+ try { db.close(); } catch {}
239
+ }
240
+ return { agentsWithMessages, totalMessages, newestMessageAt };
241
+ }
242
+
243
+ function getVectorDimensions(vectorDb, tableName) {
244
+ if (!vectorDb) return null;
245
+ const row = safeGet(vectorDb, 'SELECT sql FROM sqlite_master WHERE type = ? AND name = ?', ['table', tableName]);
246
+ const match = row?.sql?.match(/float\[(\d+)\]/i);
247
+ return match ? Number(match[1]) : null;
248
+ }
249
+
250
+ function getQuickCheck(db) {
251
+ const row = safeGet(db, 'PRAGMA quick_check');
252
+ return row ? Object.values(row)[0] : 'unknown';
253
+ }
254
+
255
+ function collectMasterHealth(libraryDb, vectorDb, mainDb) {
256
+ const agentClause = flags.agent ? 'AND agent_id = ?' : '';
257
+ const params = flags.agent ? [flags.agent] : [];
258
+
259
+ const factsTotal = safeGet(libraryDb, `SELECT COUNT(*) AS count FROM facts WHERE 1=1 ${agentClause}`, params)?.count ?? 0;
260
+ const factsActive = safeGet(libraryDb, `SELECT COUNT(*) AS count FROM facts WHERE superseded_by IS NULL AND decay_score < 0.8 ${agentClause}`, params)?.count ?? 0;
261
+ const episodesTotal = safeGet(libraryDb, `SELECT COUNT(*) AS count FROM episodes WHERE 1=1 ${agentClause}`, params)?.count ?? 0;
262
+ const episodesEligible = safeGet(libraryDb, `SELECT COUNT(*) AS count FROM episodes WHERE significance >= 0.5 ${agentClause}`, params)?.count ?? 0;
263
+ const knowledgeActive = safeGet(libraryDb, `SELECT COUNT(*) AS count FROM knowledge WHERE superseded_by IS NULL ${agentClause}`, params)?.count ?? 0;
264
+ const docChunks = safeGet(libraryDb, `SELECT COUNT(*) AS count FROM doc_chunks WHERE 1=1 ${agentClause}`, params)?.count ?? 0;
265
+
266
+ let factsIndexed = 0;
267
+ let episodesIndexed = 0;
268
+ let knowledgeIndexed = 0;
269
+ let totalVectors = 0;
270
+ let vectorByTable = {};
271
+ let oldestIndexedAt = null;
272
+ let newestIndexedAt = null;
273
+ let vectorDimensions = { facts: null, episodes: null, knowledge: null };
274
+
275
+ if (vectorDb) {
276
+ totalVectors = safeGet(vectorDb, 'SELECT COUNT(*) AS count FROM vec_index_map')?.count ?? 0;
277
+ vectorByTable = Object.fromEntries(safeAll(vectorDb, 'SELECT source_table, COUNT(*) AS count FROM vec_index_map GROUP BY source_table').map(r => [r.source_table, r.count]));
278
+ oldestIndexedAt = safeGet(vectorDb, 'SELECT MIN(indexed_at) AS value FROM vec_index_map')?.value ?? null;
279
+ newestIndexedAt = safeGet(vectorDb, 'SELECT MAX(indexed_at) AS value FROM vec_index_map')?.value ?? null;
280
+ vectorDimensions = {
281
+ facts: getVectorDimensions(vectorDb, 'vec_facts'),
282
+ episodes: getVectorDimensions(vectorDb, 'vec_episodes'),
283
+ knowledge: getVectorDimensions(vectorDb, 'vec_knowledge'),
284
+ };
285
+
286
+ if (flags.agent) {
287
+ factsIndexed = safeGet(vectorDb, `
288
+ SELECT COUNT(*) AS count
289
+ FROM vec_index_map m
290
+ JOIN library.facts f ON f.id = m.source_id
291
+ WHERE m.source_table = 'facts'
292
+ AND f.superseded_by IS NULL
293
+ AND f.decay_score < 0.8
294
+ AND f.agent_id = ?`, params)?.count ?? 0;
295
+ episodesIndexed = safeGet(vectorDb, `
296
+ SELECT COUNT(*) AS count
297
+ FROM vec_index_map m
298
+ JOIN library.episodes e ON e.id = m.source_id
299
+ WHERE m.source_table = 'episodes'
300
+ AND e.significance >= 0.5
301
+ AND e.agent_id = ?`, params)?.count ?? 0;
302
+ knowledgeIndexed = safeGet(vectorDb, `
303
+ SELECT COUNT(*) AS count
304
+ FROM vec_index_map m
305
+ JOIN library.knowledge k ON k.id = m.source_id
306
+ WHERE m.source_table = 'knowledge'
307
+ AND k.superseded_by IS NULL
308
+ AND k.agent_id = ?`, params)?.count ?? 0;
309
+ const rawFactsIndexed = safeGet(vectorDb, `
310
+ SELECT COUNT(*) AS count
311
+ FROM vec_index_map m
312
+ JOIN library.facts f ON f.id = m.source_id
313
+ WHERE m.source_table = 'facts'
314
+ AND f.agent_id = ?`, params)?.count ?? 0;
315
+ const rawEpisodesIndexed = safeGet(vectorDb, `
316
+ SELECT COUNT(*) AS count
317
+ FROM vec_index_map m
318
+ JOIN library.episodes e ON e.id = m.source_id
319
+ WHERE m.source_table = 'episodes'
320
+ AND e.agent_id = ?`, params)?.count ?? 0;
321
+ const rawKnowledgeIndexed = safeGet(vectorDb, `
322
+ SELECT COUNT(*) AS count
323
+ FROM vec_index_map m
324
+ JOIN library.knowledge k ON k.id = m.source_id
325
+ WHERE m.source_table = 'knowledge'
326
+ AND k.agent_id = ?`, params)?.count ?? 0;
327
+ vectorByTable = {
328
+ ...(rawEpisodesIndexed ? { episodes: rawEpisodesIndexed } : {}),
329
+ ...(rawFactsIndexed ? { facts: rawFactsIndexed } : {}),
330
+ ...(rawKnowledgeIndexed ? { knowledge: rawKnowledgeIndexed } : {}),
331
+ };
332
+ totalVectors = rawFactsIndexed + rawEpisodesIndexed + rawKnowledgeIndexed;
333
+ } else {
334
+ factsIndexed = safeGet(vectorDb, `
335
+ SELECT COUNT(*) AS count
336
+ FROM vec_index_map m
337
+ JOIN library.facts f ON f.id = m.source_id
338
+ WHERE m.source_table = 'facts'
339
+ AND f.superseded_by IS NULL
340
+ AND f.decay_score < 0.8`)?.count ?? 0;
341
+ episodesIndexed = safeGet(vectorDb, `
342
+ SELECT COUNT(*) AS count
343
+ FROM vec_index_map m
344
+ JOIN library.episodes e ON e.id = m.source_id
345
+ WHERE m.source_table = 'episodes'
346
+ AND e.significance >= 0.5`)?.count ?? 0;
347
+ knowledgeIndexed = safeGet(vectorDb, `
348
+ SELECT COUNT(*) AS count
349
+ FROM vec_index_map m
350
+ JOIN library.knowledge k ON k.id = m.source_id
351
+ WHERE m.source_table = 'knowledge'
352
+ AND k.superseded_by IS NULL`)?.count ?? 0;
102
353
  }
103
354
  }
355
+
356
+ const factCoverage = pct(factsIndexed, factsActive);
357
+ const episodeCoverage = pct(episodesIndexed, episodesEligible);
358
+ const knowledgeCoverage = pct(knowledgeIndexed, knowledgeActive);
359
+ const factStatus = statusForCoverage(factCoverage, 80);
360
+ const episodeStatus = statusForCoverage(episodeCoverage, 80);
361
+ const knowledgeStatus = knowledgeActive === 0 ? 'n/a' : statusForCoverage(knowledgeCoverage, 80);
362
+ const configuredDimensions = Number(runtime.embedding.dimensions) || null;
363
+ const dimensionMatches = !configuredDimensions
364
+ ? 'n/a'
365
+ : [vectorDimensions.facts, vectorDimensions.episodes, vectorDimensions.knowledge]
366
+ .filter(v => v != null)
367
+ .every(v => v === configuredDimensions)
368
+ ? 'ok'
369
+ : 'fail';
370
+
371
+ const libraryQuickCheck = getQuickCheck(libraryDb);
372
+ const vectorQuickCheck = vectorDb ? getQuickCheck(vectorDb) : 'missing';
373
+ const mainQuickCheck = mainDb ? getQuickCheck(mainDb) : 'missing';
374
+ const dbStatus = libraryQuickCheck === 'ok' && (vectorQuickCheck === 'ok' || vectorQuickCheck === 'missing') && (mainQuickCheck === 'ok' || mainQuickCheck === 'missing') ? 'ok' : 'fail';
375
+
376
+ const messageStats = collectMessageStats(flags.agent);
377
+ const totalTurns = safeGet(libraryDb, `SELECT COUNT(*) AS count FROM output_metrics WHERE 1=1 ${agentClause}`, params)?.count ?? 0;
378
+ const avgLatency = safeGet(libraryDb, `SELECT AVG(latency_ms) AS value FROM output_metrics WHERE latency_ms IS NOT NULL ${agentClause}`, params)?.value ?? null;
379
+ const avgInput = safeGet(libraryDb, `SELECT AVG(input_tokens) AS value FROM output_metrics WHERE input_tokens IS NOT NULL ${agentClause}`, params)?.value ?? null;
380
+ const avgOutput = safeGet(libraryDb, `SELECT AVG(output_tokens) AS value FROM output_metrics WHERE output_tokens IS NOT NULL ${agentClause}`, params)?.value ?? null;
381
+
382
+ const issues = [];
383
+ if (dbStatus === 'fail') issues.push('database quick_check failed');
384
+ if (dimensionMatches === 'fail') issues.push('vector table dimensions do not match configured embedding dimensions');
385
+ if (factStatus === 'warn') issues.push(`fact vector coverage low (${factCoverage}%)`);
386
+ if (episodeStatus === 'warn') issues.push(`episode vector coverage low (${episodeCoverage}%)`);
387
+ if (knowledgeStatus === 'warn') issues.push(`knowledge vector coverage low (${knowledgeCoverage}%)`);
388
+ if (!vectorDb) issues.push('shared vectors.db missing');
389
+
390
+ const overall = issues.length === 0 ? 'healthy' : (dbStatus === 'fail' || dimensionMatches === 'fail' ? 'degraded' : 'attention');
391
+
392
+ return {
393
+ snapshotAt: new Date().toISOString(),
394
+ scope: flags.agent ? { agent: flags.agent } : { fleet: true },
395
+ overall,
396
+ issues,
397
+ config: {
398
+ dataDir,
399
+ sources: runtime.configSources,
400
+ embedding: {
401
+ provider: runtime.embedding.provider ?? null,
402
+ model: runtime.embedding.model ?? null,
403
+ dimensions: configuredDimensions,
404
+ batchSize: runtime.embedding.batchSize ?? null,
405
+ baseUrl: runtime.embedding.openaiBaseUrl ?? runtime.embedding.ollamaUrl ?? runtime.embedding.geminiBaseUrl ?? null,
406
+ },
407
+ reranker: runtime.config.reranker ? stripSecretFields(runtime.config.reranker) : null,
408
+ },
409
+ databases: {
410
+ library: { path: libraryDbPath, ...fileInfo(libraryDbPath), quickCheck: libraryQuickCheck },
411
+ vectors: { path: vectorDbPath, ...fileInfo(vectorDbPath), quickCheck: vectorQuickCheck, dimensions: vectorDimensions },
412
+ messages: { sampledPath: mainDbPath, ...(mainDbPath ? fileInfo(mainDbPath) : { exists: false, bytes: 0, mb: 0 }), quickCheck: mainQuickCheck },
413
+ },
414
+ memory: {
415
+ facts: { total: factsTotal, active: factsActive },
416
+ episodes: { total: episodesTotal, eligible: episodesEligible },
417
+ knowledge: { active: knowledgeActive },
418
+ docChunks: { total: docChunks },
419
+ messages: messageStats,
420
+ },
421
+ vectors: {
422
+ total: totalVectors,
423
+ byTable: vectorByTable,
424
+ coverage: {
425
+ facts: { indexed: factsIndexed, total: factsActive, percent: factCoverage, status: factStatus },
426
+ episodes: { indexed: episodesIndexed, total: episodesEligible, percent: episodeCoverage, status: episodeStatus },
427
+ knowledge: { indexed: knowledgeIndexed, total: knowledgeActive, percent: knowledgeCoverage, status: knowledgeStatus },
428
+ },
429
+ oldestIndexedAt,
430
+ newestIndexedAt,
431
+ dimensionStatus: dimensionMatches,
432
+ },
433
+ composition: {
434
+ turns: totalTurns,
435
+ avgLatencyMs: avgLatency == null ? null : Math.round(avgLatency),
436
+ avgInputTokens: avgInput == null ? null : Math.round(avgInput),
437
+ avgOutputTokens: avgOutput == null ? null : Math.round(avgOutput),
438
+ semanticDiagnosticsPersisted: false,
439
+ },
440
+ };
441
+ }
442
+
443
+ function formatCoverage(label, item) {
444
+ const percent = item.percent == null ? 'n/a' : `${item.percent}%`;
445
+ return ` ${icon(item.status)} ${label.padEnd(9)} ${item.indexed.toLocaleString()} / ${item.total.toLocaleString()} (${percent})`;
446
+ }
447
+
448
+ function formatMasterHealth(h) {
449
+ const lines = [];
450
+ lines.push(`hypermem master health — ${h.snapshotAt}`);
451
+ lines.push(`scope: ${h.scope.agent ? `agent=${h.scope.agent}` : 'fleet'}`);
452
+ lines.push(`overall: ${h.overall === 'healthy' ? '✅ healthy' : h.overall === 'degraded' ? '❌ degraded' : '⚠️ attention'}`);
453
+ if (h.issues.length > 0) {
454
+ lines.push(`issues: ${h.issues.join('; ')}`);
455
+ }
456
+
457
+ lines.push('');
458
+ lines.push('## Configuration');
459
+ lines.push(` dataDir: ${h.config.dataDir}`);
460
+ lines.push(` source: ${h.config.sources.join(' + ')}`);
461
+ lines.push(` embed: ${h.config.embedding.provider ?? 'unknown'} / ${h.config.embedding.model ?? 'unknown'}${h.config.embedding.dimensions ? ` (${h.config.embedding.dimensions}d)` : ''}${h.config.embedding.batchSize ? ` batch=${h.config.embedding.batchSize}` : ''}`);
462
+ if (h.config.embedding.baseUrl) lines.push(` base: ${h.config.embedding.baseUrl}`);
463
+ lines.push(` rerank: ${h.config.reranker?.provider ?? 'none'}`);
464
+
465
+ lines.push('');
466
+ lines.push('## Databases');
467
+ lines.push(` ${icon(h.databases.library.quickCheck === 'ok' ? 'ok' : 'fail')} library.db ${h.databases.library.mb} MB quick_check=${h.databases.library.quickCheck}`);
468
+ lines.push(` ${icon(h.databases.vectors.quickCheck === 'ok' ? 'ok' : h.databases.vectors.exists ? 'fail' : 'warn')} vectors.db ${h.databases.vectors.exists ? `${h.databases.vectors.mb} MB` : 'missing'} quick_check=${h.databases.vectors.quickCheck}`);
469
+ lines.push(` ${icon(h.databases.messages.quickCheck === 'ok' ? 'ok' : h.databases.messages.exists ? 'fail' : 'warn')} messages sample ${h.databases.messages.exists ? `${h.databases.messages.mb} MB` : 'missing'} quick_check=${h.databases.messages.quickCheck}`);
470
+ lines.push(` ${icon(h.vectors.dimensionStatus)} vector dims facts=${h.databases.vectors.dimensions.facts ?? 'n/a'} episodes=${h.databases.vectors.dimensions.episodes ?? 'n/a'} knowledge=${h.databases.vectors.dimensions.knowledge ?? 'n/a'}`);
471
+
472
+ lines.push('');
473
+ lines.push('## Memory data');
474
+ lines.push(` facts: ${h.memory.facts.active.toLocaleString()} retrieval-eligible / ${h.memory.facts.total.toLocaleString()} total`);
475
+ lines.push(` episodes: ${h.memory.episodes.eligible.toLocaleString()} significant / ${h.memory.episodes.total.toLocaleString()} total`);
476
+ lines.push(` knowledge:${String(h.memory.knowledge.active.toLocaleString()).padStart(7)} active`);
477
+ lines.push(` chunks: ${h.memory.docChunks.total.toLocaleString()}`);
478
+ lines.push(` messages: ${h.memory.messages.totalMessages.toLocaleString()} across ${h.memory.messages.agentsWithMessages} agent DBs${h.memory.messages.newestMessageAt ? `, newest ${h.memory.messages.newestMessageAt}` : ''}`);
479
+
480
+ lines.push('');
481
+ lines.push('## Vector coverage');
482
+ lines.push(` total vectors: ${h.vectors.total.toLocaleString()}`);
483
+ if (Object.keys(h.vectors.byTable).length > 0) {
484
+ const byTable = Object.entries(h.vectors.byTable)
485
+ .map(([table, count]) => `${table}=${Number(count).toLocaleString()}`)
486
+ .join(' ');
487
+ lines.push(` by table: ${byTable}`);
488
+ }
489
+ lines.push(formatCoverage('facts', h.vectors.coverage.facts));
490
+ lines.push(formatCoverage('episodes', h.vectors.coverage.episodes));
491
+ lines.push(formatCoverage('knowledge', h.vectors.coverage.knowledge));
492
+ lines.push(` indexed window: ${h.vectors.oldestIndexedAt ?? 'n/a'} → ${h.vectors.newestIndexedAt ?? 'n/a'}`);
493
+
494
+ lines.push('');
495
+ lines.push('## Composition');
496
+ if (h.composition.turns > 0) {
497
+ lines.push(` turns: ${h.composition.turns.toLocaleString()}`);
498
+ if (h.composition.avgLatencyMs != null) lines.push(` avg latency: ${h.composition.avgLatencyMs}ms`);
499
+ if (h.composition.avgInputTokens != null) lines.push(` avg input: ${h.composition.avgInputTokens.toLocaleString()} tokens`);
500
+ if (h.composition.avgOutputTokens != null) lines.push(` avg output: ${h.composition.avgOutputTokens.toLocaleString()} tokens`);
501
+ } else {
502
+ lines.push(' no persisted output_metrics rows');
503
+ }
504
+ lines.push(' semantic recall counts: runtime log only, not persisted yet');
505
+
506
+ return lines.join('\n');
104
507
  }
105
508
 
509
+ // ── Resolve DB paths ─────────────────────────────────────────────
510
+ const mainDbPath = findMessageDb(flags.agent);
511
+ const libraryDbPath = join(dataDir, 'library.db');
512
+ const vectorDbPath = join(dataDir, 'vectors.db');
513
+
106
514
  if (!mainDbPath || !existsSync(mainDbPath)) {
107
- if (args.includes('--health') || args.includes('--json')) {
108
- const result = { status: 'no_sessions', message: 'Installed but no agent sessions ingested yet. Send a message to any agent, then re-run.' };
109
- if (args.includes('--json')) {
110
- console.log(JSON.stringify(result, null, 2));
111
- } else {
515
+ if (flags.health || flags.json || flags.master) {
516
+ const result = { status: 'no_sessions', message: 'Installed but no agent sessions ingested yet. Send a message to any agent, then re-run.', dataDir };
517
+ if (flags.json) console.log(JSON.stringify(result, null, 2));
518
+ else {
112
519
  console.log('Status: installed, no sessions ingested yet.');
113
520
  console.log('Send a message to any agent, then re-run this health check.');
114
521
  }
@@ -118,31 +525,48 @@ if (!mainDbPath || !existsSync(mainDbPath)) {
118
525
  process.exit(1);
119
526
  }
120
527
 
121
- const libraryDbPath = join(dataDir, 'library.db');
122
-
123
528
  const mainDb = openDb(mainDbPath, 'messages.db');
124
529
  const libraryDb = openDb(libraryDbPath, 'library.db');
125
-
126
- // ── Import metrics functions ─────────────────────────────────────
127
- const distPath = join(root, 'dist', 'metrics-dashboard.js');
128
- if (!existsSync(distPath)) {
129
- console.error('Error: dist/metrics-dashboard.js not found. Run `npm run build` first.');
130
- process.exit(1);
530
+ const vectorDb = openDb(vectorDbPath, 'vectors.db', false);
531
+ if (vectorDb) {
532
+ try {
533
+ vectorDb.exec(`ATTACH DATABASE '${libraryDbPath.replaceAll("'", "''")}' AS library`);
534
+ } catch {
535
+ // Coverage joins degrade to zero via safeGet if the live library cannot be attached.
536
+ }
131
537
  }
132
538
 
133
- const { collectMetrics, formatMetricsSummary } = await import(distPath);
539
+ try {
540
+ if (flags.master) {
541
+ const master = collectMasterHealth(libraryDb, vectorDb, mainDb);
542
+ if (flags.json) console.log(JSON.stringify(master, null, 2));
543
+ else console.log(formatMasterHealth(master));
544
+ process.exit(master.overall === 'degraded' ? 1 : 0);
545
+ }
134
546
 
135
- // ── Collect and output ───────────────────────────────────────────
136
- const opts = {};
137
- if (flags.agent) {
138
- opts.agentIds = [flags.agent];
139
- }
547
+ // ── Import metrics functions ───────────────────────────────────
548
+ const distPath = join(root, 'dist', 'metrics-dashboard.js');
549
+ if (!existsSync(distPath)) {
550
+ console.error('Error: dist/metrics-dashboard.js not found. Run `npm run build` first.');
551
+ process.exit(1);
552
+ }
140
553
 
141
- try {
142
- const metrics = await collectMetrics(mainDb, libraryDb, opts);
554
+ const { collectMetrics, formatMetricsSummary } = await import(distPath);
555
+ const opts = {};
556
+ if (flags.agent) opts.agentIds = [flags.agent];
557
+
558
+ const metrics = await collectMetrics(
559
+ mainDb,
560
+ libraryDb,
561
+ {
562
+ ...opts,
563
+ embeddingProvider: runtime.embedding.provider,
564
+ embeddingModel: runtime.embedding.model,
565
+ },
566
+ vectorDb,
567
+ );
143
568
 
144
569
  if (flags.health) {
145
- // Health-only mode: check and exit
146
570
  const h = metrics.health;
147
571
  const ok = h.mainDbOk && h.libraryDbOk && (h.cacheOk === null || h.cacheOk);
148
572
 
@@ -150,22 +574,18 @@ try {
150
574
  console.log(JSON.stringify(h, null, 2));
151
575
  } else {
152
576
  console.log(`hypermem ${h.packageVersion} health check`);
577
+ console.log(` embedding: provider=${h.embeddingProvider ?? 'unknown'}${h.embeddingModel ? ` model=${h.embeddingModel}` : ''}`);
153
578
  console.log(` main db: ${h.mainDbOk ? '✅' : '❌'}${h.mainSchemaVersion !== null ? ` (schema v${h.mainSchemaVersion})` : ''}`);
154
579
  console.log(` library db: ${h.libraryDbOk ? '✅' : '❌'}${h.librarySchemaVersion !== null ? ` (schema v${h.librarySchemaVersion})` : ''}`);
155
- if (h.cacheOk !== null) {
156
- console.log(` cache: ${h.cacheOk ? '✅' : '❌'}`);
157
- }
580
+ if (h.cacheOk !== null) console.log(` cache: ${h.cacheOk ? '✅' : '❌'}`);
158
581
  console.log(` status: ${ok ? '✅ healthy' : '❌ degraded'}`);
159
582
  }
160
583
 
161
584
  process.exit(ok ? 0 : 1);
162
585
  }
163
586
 
164
- if (flags.json) {
165
- console.log(JSON.stringify(metrics, null, 2));
166
- } else {
167
- console.log(formatMetricsSummary(metrics));
168
- }
587
+ if (flags.json) console.log(JSON.stringify(metrics, null, 2));
588
+ else console.log(formatMetricsSummary(metrics));
169
589
  } catch (err) {
170
590
  console.error(`Error collecting metrics: ${err.message}`);
171
591
  if (err.stack) console.error(err.stack);
@@ -173,4 +593,5 @@ try {
173
593
  } finally {
174
594
  try { mainDb.close(); } catch {}
175
595
  try { libraryDb.close(); } catch {}
596
+ try { vectorDb?.close(); } catch {}
176
597
  }