context-vault 3.6.0 → 3.8.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 (37) hide show
  1. package/assets/vault-error-hook.mjs +106 -0
  2. package/assets/vault-recall-hook.mjs +67 -0
  3. package/bin/cli.js +734 -5
  4. package/dist/register-tools.d.ts.map +1 -1
  5. package/dist/register-tools.js +2 -0
  6. package/dist/register-tools.js.map +1 -1
  7. package/dist/stats/recall.d.ts +33 -0
  8. package/dist/stats/recall.d.ts.map +1 -0
  9. package/dist/stats/recall.js +86 -0
  10. package/dist/stats/recall.js.map +1 -0
  11. package/dist/tools/clear-context.d.ts +7 -3
  12. package/dist/tools/clear-context.d.ts.map +1 -1
  13. package/dist/tools/clear-context.js +157 -8
  14. package/dist/tools/clear-context.js.map +1 -1
  15. package/dist/tools/context-status.d.ts.map +1 -1
  16. package/dist/tools/context-status.js +21 -0
  17. package/dist/tools/context-status.js.map +1 -1
  18. package/dist/tools/get-context.d.ts.map +1 -1
  19. package/dist/tools/get-context.js +50 -1
  20. package/dist/tools/get-context.js.map +1 -1
  21. package/dist/tools/recall.d.ts +25 -0
  22. package/dist/tools/recall.d.ts.map +1 -0
  23. package/dist/tools/recall.js +257 -0
  24. package/dist/tools/recall.js.map +1 -0
  25. package/dist/tools/session-start.d.ts +2 -1
  26. package/dist/tools/session-start.d.ts.map +1 -1
  27. package/dist/tools/session-start.js +16 -5
  28. package/dist/tools/session-start.js.map +1 -1
  29. package/node_modules/@context-vault/core/package.json +1 -1
  30. package/package.json +2 -2
  31. package/src/register-tools.ts +2 -0
  32. package/src/stats/recall.ts +139 -0
  33. package/src/tools/clear-context.ts +195 -10
  34. package/src/tools/context-status.ts +21 -0
  35. package/src/tools/get-context.ts +64 -1
  36. package/src/tools/recall.ts +307 -0
  37. package/src/tools/session-start.ts +18 -5
package/bin/cli.js CHANGED
@@ -301,12 +301,24 @@ const TOOLS = [
301
301
  {
302
302
  id: 'antigravity',
303
303
  name: 'Antigravity (Gemini CLI)',
304
- detect: () => anyDirExists(join(HOME, '.gemini', 'antigravity'), join(HOME, '.gemini')),
304
+ detect: async () =>
305
+ anyDirExists(join(HOME, '.gemini', 'antigravity'), join(HOME, '.gemini')) ||
306
+ (await commandExistsAsync('gemini')),
305
307
  configType: 'json',
306
308
  configPath: join(HOME, '.gemini', 'antigravity', 'mcp_config.json'),
307
309
  configKey: 'mcpServers',
308
- rulesPath: null,
309
- rulesMethod: null,
310
+ rulesPath: join(HOME, '.gemini', 'antigravity', 'rules', 'context-vault.md'),
311
+ rulesMethod: 'write',
312
+ },
313
+ {
314
+ id: 'google-ai',
315
+ name: 'Google AI / Gemini CLI',
316
+ detect: () => existsSync(join(HOME, '.gemini', 'mcp_config.json')),
317
+ configType: 'json',
318
+ configPath: join(HOME, '.gemini', 'mcp_config.json'),
319
+ configKey: 'mcpServers',
320
+ rulesPath: join(HOME, '.gemini', 'rules', 'context-vault.md'),
321
+ rulesMethod: 'write',
310
322
  },
311
323
  {
312
324
  id: 'cline',
@@ -396,10 +408,12 @@ ${bold('Commands:')}
396
408
  ${cyan('ingest')} <url> Fetch URL and save as vault entry
397
409
  ${cyan('ingest-project')} <path> Scan project directory and register as project entity
398
410
  ${cyan('reindex')} Rebuild search index from knowledge files
411
+ ${cyan('sync')} [dir] Index .context/ files into vault DB (use --dry-run to preview)
399
412
  ${cyan('migrate-dirs')} [--dry-run] Rename plural vault dirs to singular (post-2.18.0)
400
413
  ${cyan('archive')} Archive old ephemeral/event entries (use --dry-run to preview)
401
414
  ${cyan('restore')} <id> Restore an archived entry back into the vault
402
415
  ${cyan('prune')} Remove expired entries (use --dry-run to preview)
416
+ ${cyan('stats')} recall|co-retrieval Measure recall ratio and co-retrieval graph
403
417
  ${cyan('update')} Check for and install updates
404
418
  ${cyan('uninstall')} Remove MCP configs and optionally data
405
419
  `);
@@ -428,6 +442,9 @@ ${bold('Commands:')}
428
442
  --force Overwrite existing config without confirmation
429
443
  --skip-embeddings Skip embedding model download (FTS-only mode)
430
444
  --dry-run Show what setup would do without writing anything
445
+ --upgrade Upgrade installed agent rules to the latest bundled version
446
+ --no-rules Skip agent rules installation during setup
447
+ --no-hooks Skip recall/error hook installation during setup
431
448
  `);
432
449
  }
433
450
 
@@ -445,6 +462,106 @@ async function runSetup() {
445
462
  }
446
463
  console.log();
447
464
 
465
+ // --upgrade: only upgrade agent rules, then exit
466
+ if (flags.has('--upgrade')) {
467
+ console.log(dim(' Checking agent rules for updates...\n'));
468
+ const bundled = loadAgentRules();
469
+ if (!bundled) {
470
+ console.log(` ${yellow('!')} Agent rules file not found in package.\n`);
471
+ return;
472
+ }
473
+ const bundledVersion = extractRulesVersion(bundled);
474
+
475
+ // Check all known tool paths (not just detected tools, since a tool may have been
476
+ // uninstalled but its rules file still exists)
477
+ const allToolsWithRules = TOOLS.filter((t) => t.rulesPath);
478
+ let found = 0;
479
+ let upgraded = 0;
480
+ const upgradeable = [];
481
+
482
+ for (const tool of allToolsWithRules) {
483
+ const installed = getInstalledRulesForTool(tool);
484
+ if (!installed) continue;
485
+ found++;
486
+
487
+ const installedVersion = extractRulesVersion(installed);
488
+ if (installed.trim() === bundled.trim()) {
489
+ console.log(` ${green('✓')} ${tool.name}: up to date${bundledVersion ? ` (v${bundledVersion})` : ''}`);
490
+ continue;
491
+ }
492
+
493
+ upgradeable.push({ tool, installed, installedVersion });
494
+ console.log(
495
+ ` ${yellow('!')} ${tool.name}: ${installedVersion ? `v${installedVersion}` : 'unknown version'} → ${bundledVersion ? `v${bundledVersion}` : 'bundled'}`
496
+ );
497
+
498
+ // Show a compact diff
499
+ const installedLines = installed.split('\n');
500
+ const bundledLines = bundled.split('\n');
501
+ const maxLines = Math.max(installedLines.length, bundledLines.length);
502
+ let diffLines = 0;
503
+ for (let i = 0; i < maxLines; i++) {
504
+ const a = installedLines[i];
505
+ const b = bundledLines[i];
506
+ if (a === b) continue;
507
+ if (diffLines === 0) console.log();
508
+ if (diffLines >= 20) {
509
+ console.log(dim(` ... and more changes`));
510
+ break;
511
+ }
512
+ if (a === undefined) {
513
+ console.log(` ${green('+')} ${b}`);
514
+ } else if (b === undefined) {
515
+ console.log(` ${red('-')} ${a}`);
516
+ } else {
517
+ console.log(` ${red('-')} ${a}`);
518
+ console.log(` ${green('+')} ${b}`);
519
+ }
520
+ diffLines++;
521
+ }
522
+ console.log();
523
+ }
524
+
525
+ if (found === 0) {
526
+ console.log(` ${yellow('!')} No installed rules found. Run ${cyan('context-vault rules install')} first.\n`);
527
+ return;
528
+ }
529
+
530
+ if (upgradeable.length === 0) {
531
+ console.log(`\n ${green('✓')} All rules are up to date.\n`);
532
+ return;
533
+ }
534
+
535
+ if (!isDryRun) {
536
+ const answer = isNonInteractive
537
+ ? 'Y'
538
+ : await prompt(` Upgrade ${upgradeable.length} rules file(s)? (Y/n):`, 'Y');
539
+ if (answer.toLowerCase() === 'n') {
540
+ console.log(dim(' Skipped.\n'));
541
+ return;
542
+ }
543
+
544
+ for (const { tool } of upgradeable) {
545
+ try {
546
+ installAgentRulesForTool(tool, bundled);
547
+ console.log(` ${green('+')} ${tool.name} — upgraded`);
548
+ upgraded++;
549
+ } catch (e) {
550
+ console.log(` ${red('x')} ${tool.name} — ${e.message}`);
551
+ }
552
+ }
553
+ } else {
554
+ console.log(dim(` [dry-run] Would upgrade ${upgradeable.length} rules file(s).`));
555
+ }
556
+
557
+ console.log();
558
+ if (upgraded > 0) {
559
+ console.log(dim(' Restart your AI tools to apply the updated rules.'));
560
+ console.log();
561
+ }
562
+ return;
563
+ }
564
+
448
565
  // Check for existing installation
449
566
  const existingConfig = join(HOME, '.context-mcp', 'config.json');
450
567
  if (existsSync(existingConfig) && !isNonInteractive && !isDryRun) {
@@ -653,6 +770,7 @@ async function runSetup() {
653
770
  if (userLevel === 'beginner') {
654
771
  console.log(' Install an AI tool first:');
655
772
  console.log(dim(' Claude Code: https://docs.anthropic.com/en/docs/claude-code'));
773
+ console.log(dim(' Gemini CLI: https://github.com/google-gemini/gemini-cli'));
656
774
  console.log(dim(' Cursor: https://cursor.com'));
657
775
  console.log(dim(' Windsurf: https://codeium.com/windsurf'));
658
776
  console.log();
@@ -1032,7 +1150,7 @@ async function runSetup() {
1032
1150
 
1033
1151
  if (claudeConfigured) {
1034
1152
  if (isDryRun) {
1035
- console.log(` ${yellow('[dry-run]')} Would install Claude Code hooks (memory recall, session capture, auto-capture)`);
1153
+ console.log(` ${yellow('[dry-run]')} Would install Claude Code hooks (memory recall, session capture, auto-capture, vault recall, error recall)`);
1036
1154
  console.log(` ${yellow('[dry-run]')} Would install Claude Code skills (compile-context, vault-setup)`);
1037
1155
  } else {
1038
1156
  // Bundled hooks prompt: one Y/n for all three hooks
@@ -1063,6 +1181,20 @@ async function runSetup() {
1063
1181
  } catch (e) {
1064
1182
  console.log(` ${red('x')} Auto-capture hook failed: ${e.message}`);
1065
1183
  }
1184
+ if (!flags.has('--no-hooks')) {
1185
+ try {
1186
+ const recallInstalled = installRecallHook();
1187
+ if (recallInstalled) console.log(` ${green('+')} Vault recall hook installed`);
1188
+ } catch (e) {
1189
+ console.log(` ${red('x')} Recall hook failed: ${e.message}`);
1190
+ }
1191
+ try {
1192
+ const errorInstalled = installErrorHook();
1193
+ if (errorInstalled) console.log(` ${green('+')} Vault error hook installed`);
1194
+ } catch (e) {
1195
+ console.log(` ${red('x')} Error hook failed: ${e.message}`);
1196
+ }
1197
+ }
1066
1198
  } else {
1067
1199
  console.log(dim(` Hooks skipped. Install later: context-vault hooks install`));
1068
1200
  }
@@ -2040,6 +2172,269 @@ async function runReindex() {
2040
2172
  }
2041
2173
  }
2042
2174
 
2175
+ async function runSync() {
2176
+ const dryRun = flags.has('--dry-run');
2177
+ const positional = args.slice(1).find((a) => !a.startsWith('--'));
2178
+ const scanDir = positional ? resolve(positional) : process.cwd();
2179
+
2180
+ const contextDir = join(scanDir, '.context');
2181
+ if (!existsSync(contextDir)) {
2182
+ console.error(red(`No .context/ directory found in ${scanDir}`));
2183
+ console.error(dim('The .context/ directory is created automatically when save_context is called from a workspace.'));
2184
+ process.exit(1);
2185
+ }
2186
+
2187
+ console.log(dim(dryRun ? 'Scanning .context/ (dry run)...' : 'Syncing .context/ to vault...'));
2188
+
2189
+ const { resolveConfig } = await import('@context-vault/core/config');
2190
+ const { initDatabase, prepareStatements, insertVec, deleteVec } =
2191
+ await import('@context-vault/core/db');
2192
+ const { embed } = await import('@context-vault/core/embed');
2193
+ const { parseFrontmatter, parseEntryFromMarkdown } = await import('@context-vault/core/frontmatter');
2194
+ const { categoryFor, defaultTierFor } = await import('@context-vault/core/categories');
2195
+ const { dirToKind, walkDir } = await import('@context-vault/core/files');
2196
+ const { shouldIndex } = await import('@context-vault/core/indexing');
2197
+ const { DEFAULT_INDEXING } = await import('@context-vault/core/constants');
2198
+
2199
+ const config = resolveConfig();
2200
+ if (!config.vaultDirExists) {
2201
+ console.error(red(`Vault directory not found: ${config.vaultDir}`));
2202
+ console.error('Run ' + cyan('context-vault setup') + ' to configure.');
2203
+ process.exit(1);
2204
+ }
2205
+
2206
+ const db = await initDatabase(config.dbPath);
2207
+ const stmts = prepareStatements(db);
2208
+ const ixConfig = config.indexing ?? DEFAULT_INDEXING;
2209
+
2210
+ let synced = 0;
2211
+ let alreadyIndexed = 0;
2212
+ let updated = 0;
2213
+ let errors = 0;
2214
+ let skippedIndexing = 0;
2215
+
2216
+ // Discover kind directories inside .context/
2217
+ let kindDirs;
2218
+ try {
2219
+ kindDirs = readdirSync(contextDir, { withFileTypes: true })
2220
+ .filter((d) => d.isDirectory() && !d.name.startsWith('.') && !d.name.startsWith('_'));
2221
+ } catch (e) {
2222
+ console.error(red(`Failed to read .context/: ${e.message}`));
2223
+ db.close();
2224
+ process.exit(1);
2225
+ }
2226
+
2227
+ const pendingEmbeds = [];
2228
+
2229
+ if (!dryRun) db.exec('BEGIN');
2230
+ try {
2231
+ for (const kindEntry of kindDirs) {
2232
+ const kind = dirToKind(kindEntry.name);
2233
+ const kindDir = join(contextDir, kindEntry.name);
2234
+ const mdFiles = walkDir(kindDir).filter((f) => f.filePath.endsWith('.md'));
2235
+
2236
+ for (const { filePath, relDir } of mdFiles) {
2237
+ let raw;
2238
+ try {
2239
+ raw = readFileSync(filePath, 'utf-8');
2240
+ } catch (e) {
2241
+ console.error(dim(` skip: could not read ${filePath}: ${e.message}`));
2242
+ errors++;
2243
+ continue;
2244
+ }
2245
+
2246
+ if (!raw.startsWith('---\n')) {
2247
+ console.error(dim(` skip (no frontmatter): ${filePath}`));
2248
+ errors++;
2249
+ continue;
2250
+ }
2251
+
2252
+ const { meta: fmMeta, body: rawBody } = parseFrontmatter(raw);
2253
+ const entryId = fmMeta.id;
2254
+ if (!entryId) {
2255
+ console.error(dim(` skip (no id in frontmatter): ${filePath}`));
2256
+ errors++;
2257
+ continue;
2258
+ }
2259
+
2260
+ const parsed = parseEntryFromMarkdown(kind, rawBody, fmMeta);
2261
+ const category = categoryFor(kind);
2262
+
2263
+ // Check if entry exists in DB
2264
+ const existing = stmts.getEntryById.get(entryId);
2265
+
2266
+ if (existing) {
2267
+ // Check if content differs
2268
+ const bodyChanged = existing.body !== parsed.body;
2269
+ const titleChanged = (parsed.title || null) !== (existing.title || null);
2270
+
2271
+ if (!bodyChanged && !titleChanged) {
2272
+ alreadyIndexed++;
2273
+ continue;
2274
+ }
2275
+
2276
+ if (dryRun) {
2277
+ console.log(` ${yellow('~')} would update: ${entryId} (${parsed.title || '(untitled)'})`);
2278
+ updated++;
2279
+ continue;
2280
+ }
2281
+
2282
+ // Update existing entry
2283
+ const tagsJson = fmMeta.tags ? JSON.stringify(fmMeta.tags) : null;
2284
+ const meta = { ...(parsed.meta || {}) };
2285
+ if (relDir) meta.folder = relDir;
2286
+ const metaJson = Object.keys(meta).length ? JSON.stringify(meta) : null;
2287
+ const identity_key = fmMeta.identity_key || null;
2288
+ const expires_at = fmMeta.expires_at || null;
2289
+
2290
+ stmts.updateEntry.run(
2291
+ parsed.title || null,
2292
+ parsed.body,
2293
+ metaJson,
2294
+ tagsJson,
2295
+ fmMeta.source || 'file',
2296
+ category,
2297
+ identity_key,
2298
+ expires_at,
2299
+ existing.file_path
2300
+ );
2301
+
2302
+ const entryIndexed = shouldIndex(
2303
+ { kind, category, bodyLength: parsed.body.length },
2304
+ ixConfig
2305
+ );
2306
+
2307
+ if (entryIndexed && category !== 'event') {
2308
+ const rowidResult = stmts.getRowid.get(entryId);
2309
+ if (rowidResult?.rowid) {
2310
+ const embeddingText = [parsed.title, parsed.body].filter(Boolean).join(' ');
2311
+ pendingEmbeds.push({ rowid: rowidResult.rowid, text: embeddingText });
2312
+ }
2313
+ }
2314
+
2315
+ updated++;
2316
+ continue;
2317
+ }
2318
+
2319
+ // Entry not in DB: index it
2320
+ const entryIndexed = shouldIndex(
2321
+ { kind, category, bodyLength: parsed.body.length },
2322
+ ixConfig
2323
+ );
2324
+
2325
+ if (dryRun) {
2326
+ if (entryIndexed) {
2327
+ console.log(` ${green('+')} would sync: ${entryId} (${parsed.title || '(untitled)'})`);
2328
+ synced++;
2329
+ } else {
2330
+ console.log(` ${dim('o')} would skip indexing: ${entryId}`);
2331
+ skippedIndexing++;
2332
+ }
2333
+ continue;
2334
+ }
2335
+
2336
+ const tagsJson = fmMeta.tags ? JSON.stringify(fmMeta.tags) : null;
2337
+ const meta = { ...(parsed.meta || {}) };
2338
+ if (relDir) meta.folder = relDir;
2339
+ const metaJson = Object.keys(meta).length ? JSON.stringify(meta) : null;
2340
+ const created = fmMeta.created || new Date().toISOString();
2341
+ const identity_key = fmMeta.identity_key || null;
2342
+ const expires_at = fmMeta.expires_at || null;
2343
+ const effectiveTier = fmMeta.tier || defaultTierFor(kind);
2344
+
2345
+ // The entry should point to the vault file path (if it exists there), else use the .context path
2346
+ const vaultFilePath = existing?.file_path || fmMeta.file_path || filePath;
2347
+
2348
+ try {
2349
+ const upsertEntry = db.prepare(
2350
+ `INSERT OR IGNORE INTO vault (id, kind, category, title, body, meta, tags, source, file_path, identity_key, expires_at, created_at, updated_at, tier, indexed) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
2351
+ );
2352
+ const result = upsertEntry.run(
2353
+ entryId,
2354
+ kind,
2355
+ category,
2356
+ parsed.title || null,
2357
+ parsed.body,
2358
+ metaJson,
2359
+ tagsJson,
2360
+ fmMeta.source || 'file',
2361
+ vaultFilePath,
2362
+ identity_key,
2363
+ expires_at,
2364
+ created,
2365
+ fmMeta.updated || created,
2366
+ effectiveTier,
2367
+ entryIndexed ? 1 : 0
2368
+ );
2369
+
2370
+ if (result.changes > 0) {
2371
+ if (entryIndexed && category !== 'event') {
2372
+ const rowidResult = stmts.getRowid.get(entryId);
2373
+ if (rowidResult?.rowid) {
2374
+ const embeddingText = [parsed.title, parsed.body].filter(Boolean).join(' ');
2375
+ pendingEmbeds.push({ rowid: rowidResult.rowid, text: embeddingText });
2376
+ }
2377
+ }
2378
+ if (!entryIndexed) skippedIndexing++;
2379
+ synced++;
2380
+ } else {
2381
+ alreadyIndexed++;
2382
+ }
2383
+ } catch (e) {
2384
+ console.error(dim(` error indexing ${entryId}: ${e.message}`));
2385
+ errors++;
2386
+ }
2387
+ }
2388
+ }
2389
+
2390
+ // Generate embeddings in batch
2391
+ if (!dryRun && pendingEmbeds.length > 0) {
2392
+ const { embedBatch: batchEmbed } = await import('@context-vault/core/embed');
2393
+ const BATCH_SIZE = 32;
2394
+ for (let i = 0; i < pendingEmbeds.length; i += BATCH_SIZE) {
2395
+ const batch = pendingEmbeds.slice(i, i + BATCH_SIZE);
2396
+ const texts = batch.map((b) => b.text);
2397
+ try {
2398
+ const embeddings = await batchEmbed(texts);
2399
+ for (let j = 0; j < batch.length; j++) {
2400
+ if (embeddings[j]) {
2401
+ try { deleteVec(stmts, batch[j].rowid); } catch {}
2402
+ insertVec(stmts, batch[j].rowid, embeddings[j]);
2403
+ }
2404
+ }
2405
+ } catch (e) {
2406
+ console.warn(dim(` embedding batch failed: ${e.message}`));
2407
+ }
2408
+ }
2409
+ }
2410
+
2411
+ if (!dryRun) db.exec('COMMIT');
2412
+ } catch (e) {
2413
+ if (!dryRun) {
2414
+ try { db.exec('ROLLBACK'); } catch {}
2415
+ }
2416
+ throw e;
2417
+ }
2418
+
2419
+ db.close();
2420
+
2421
+ if (dryRun) {
2422
+ console.log(yellow('Dry run results (no changes made):'));
2423
+ console.log(` Would sync: ${synced}`);
2424
+ console.log(` Would update: ${updated}`);
2425
+ console.log(` Already indexed: ${alreadyIndexed}`);
2426
+ if (skippedIndexing) console.log(` Would skip indexing: ${skippedIndexing}`);
2427
+ if (errors) console.log(` ${red('Errors:')} ${errors}`);
2428
+ } else {
2429
+ console.log(green('Sync complete'));
2430
+ console.log(` ${green('+')} ${synced} synced`);
2431
+ if (updated) console.log(` ${yellow('~')} ${updated} updated`);
2432
+ console.log(` ${dim('.')} ${alreadyIndexed} already indexed`);
2433
+ if (skippedIndexing) console.log(` ${dim('o')} ${skippedIndexing} skipped indexing`);
2434
+ if (errors) console.log(` ${red('!')} ${errors} errors`);
2435
+ }
2436
+ }
2437
+
2043
2438
  async function runMigrateDirs() {
2044
2439
  const dryRun = flags.has('--dry-run');
2045
2440
 
@@ -2542,7 +2937,9 @@ async function runUninstall() {
2542
2937
  const captureRemoved = removeSessionCaptureHook();
2543
2938
  const flushRemoved = removeSessionEndHook();
2544
2939
  const autoCaptureRemoved = removePostToolCallHook();
2545
- if (recallRemoved || captureRemoved || flushRemoved || autoCaptureRemoved) {
2940
+ const recallHookRemoved = removeRecallHook();
2941
+ const errorHookRemoved = removeErrorHook();
2942
+ if (recallRemoved || captureRemoved || flushRemoved || autoCaptureRemoved || recallHookRemoved || errorHookRemoved) {
2546
2943
  console.log(` ${green('+')} Removed Claude Code hooks`);
2547
2944
  } else {
2548
2945
  console.log(` ${dim('-')} No Claude Code hooks to remove`);
@@ -4280,6 +4677,35 @@ function loadAgentRules() {
4280
4677
  return readFileSync(rulesPath, 'utf-8');
4281
4678
  }
4282
4679
 
4680
+ /**
4681
+ * Extract the version string from a rules file content.
4682
+ * Looks for <!-- context-vault-rules vX.Y --> comment on the first line.
4683
+ * Returns the version string (e.g. "1.0") or null if not found.
4684
+ */
4685
+ function extractRulesVersion(content) {
4686
+ if (!content) return null;
4687
+ const match = content.match(/<!--\s*context-vault-rules\s+v([\d.]+)\s*-->/);
4688
+ return match ? match[1] : null;
4689
+ }
4690
+
4691
+ /**
4692
+ * Get the installed rules content for a tool, handling both write and append methods.
4693
+ * For append-based tools (Windsurf), extracts only the delimited section.
4694
+ * Returns the rules content or null if not installed.
4695
+ */
4696
+ function getInstalledRulesForTool(tool) {
4697
+ const rulesPath = tool.rulesPath;
4698
+ if (!rulesPath || !existsSync(rulesPath)) return null;
4699
+ const content = readFileSync(rulesPath, 'utf-8');
4700
+ if (tool.rulesMethod === 'append') {
4701
+ const match = content.match(
4702
+ new RegExp(`${RULES_DELIMITER_START}\\n([\\s\\S]*?)\\n${RULES_DELIMITER_END}`)
4703
+ );
4704
+ return match ? match[1] : null;
4705
+ }
4706
+ return content;
4707
+ }
4708
+
4283
4709
  /**
4284
4710
  * Return the path where agent rules are/would be installed for a given tool.
4285
4711
  * Returns null for tools with no rules install path.
@@ -4656,6 +5082,159 @@ function removePostToolCallHook() {
4656
5082
  return true;
4657
5083
  }
4658
5084
 
5085
+ /**
5086
+ * Install the vault-recall-hook.mjs into ~/.claude/hooks/ and register it
5087
+ * as a UserPromptSubmit hook in ~/.claude/settings.json.
5088
+ * Returns true if installed, false if already present.
5089
+ */
5090
+ function installRecallHook() {
5091
+ const srcPath = join(ROOT, 'assets', 'vault-recall-hook.mjs');
5092
+ if (!existsSync(srcPath)) return false;
5093
+
5094
+ const hooksDir = join(HOME, '.claude', 'hooks');
5095
+ mkdirSync(hooksDir, { recursive: true });
5096
+ const destPath = join(hooksDir, 'vault-recall-hook.mjs');
5097
+ copyFileSync(srcPath, destPath);
5098
+
5099
+ const settingsPath = claudeSettingsPath();
5100
+ let settings = {};
5101
+ if (existsSync(settingsPath)) {
5102
+ try {
5103
+ settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
5104
+ } catch {
5105
+ const bak = settingsPath + '.bak';
5106
+ copyFileSync(settingsPath, bak);
5107
+ }
5108
+ }
5109
+
5110
+ if (!settings.hooks) settings.hooks = {};
5111
+ if (!settings.hooks.UserPromptSubmit) settings.hooks.UserPromptSubmit = [];
5112
+
5113
+ const hookCmd = `node ${destPath}`;
5114
+ const alreadyInstalled = settings.hooks.UserPromptSubmit.some((h) =>
5115
+ h.hooks?.some((hh) => hh.command?.includes('vault-recall-hook'))
5116
+ );
5117
+ if (alreadyInstalled) return false;
5118
+
5119
+ settings.hooks.UserPromptSubmit.push({
5120
+ hooks: [
5121
+ {
5122
+ type: 'command',
5123
+ command: hookCmd,
5124
+ timeout: 5,
5125
+ },
5126
+ ],
5127
+ });
5128
+
5129
+ mkdirSync(dirname(settingsPath), { recursive: true });
5130
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
5131
+ return true;
5132
+ }
5133
+
5134
+ /**
5135
+ * Remove the vault-recall-hook UserPromptSubmit hook from settings.json.
5136
+ * Returns true if removed, false if not found.
5137
+ */
5138
+ function removeRecallHook() {
5139
+ const settingsPath = claudeSettingsPath();
5140
+ if (!existsSync(settingsPath)) return false;
5141
+
5142
+ let settings;
5143
+ try {
5144
+ settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
5145
+ } catch {
5146
+ return false;
5147
+ }
5148
+
5149
+ if (!settings.hooks?.UserPromptSubmit) return false;
5150
+
5151
+ const before = settings.hooks.UserPromptSubmit.length;
5152
+ settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit.filter(
5153
+ (h) => !h.hooks?.some((hh) => hh.command?.includes('vault-recall-hook'))
5154
+ );
5155
+
5156
+ if (settings.hooks.UserPromptSubmit.length === before) return false;
5157
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
5158
+ return true;
5159
+ }
5160
+
5161
+ /**
5162
+ * Install the vault-error-hook.mjs into ~/.claude/hooks/ and register it
5163
+ * as a PostToolUse hook (matcher: Bash) in ~/.claude/settings.json.
5164
+ * Returns true if installed, false if already present.
5165
+ */
5166
+ function installErrorHook() {
5167
+ const srcPath = join(ROOT, 'assets', 'vault-error-hook.mjs');
5168
+ if (!existsSync(srcPath)) return false;
5169
+
5170
+ const hooksDir = join(HOME, '.claude', 'hooks');
5171
+ mkdirSync(hooksDir, { recursive: true });
5172
+ const destPath = join(hooksDir, 'vault-error-hook.mjs');
5173
+ copyFileSync(srcPath, destPath);
5174
+
5175
+ const settingsPath = claudeSettingsPath();
5176
+ let settings = {};
5177
+ if (existsSync(settingsPath)) {
5178
+ try {
5179
+ settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
5180
+ } catch {
5181
+ const bak = settingsPath + '.bak';
5182
+ copyFileSync(settingsPath, bak);
5183
+ }
5184
+ }
5185
+
5186
+ if (!settings.hooks) settings.hooks = {};
5187
+ if (!settings.hooks.PostToolUse) settings.hooks.PostToolUse = [];
5188
+
5189
+ const hookCmd = `node ${destPath}`;
5190
+ const alreadyInstalled = settings.hooks.PostToolUse.some((h) =>
5191
+ h.hooks?.some((hh) => hh.command?.includes('vault-error-hook'))
5192
+ );
5193
+ if (alreadyInstalled) return false;
5194
+
5195
+ settings.hooks.PostToolUse.push({
5196
+ matcher: 'Bash',
5197
+ hooks: [
5198
+ {
5199
+ type: 'command',
5200
+ command: hookCmd,
5201
+ timeout: 5,
5202
+ },
5203
+ ],
5204
+ });
5205
+
5206
+ mkdirSync(dirname(settingsPath), { recursive: true });
5207
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
5208
+ return true;
5209
+ }
5210
+
5211
+ /**
5212
+ * Remove the vault-error-hook PostToolUse hook from settings.json.
5213
+ * Returns true if removed, false if not found.
5214
+ */
5215
+ function removeErrorHook() {
5216
+ const settingsPath = claudeSettingsPath();
5217
+ if (!existsSync(settingsPath)) return false;
5218
+
5219
+ let settings;
5220
+ try {
5221
+ settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
5222
+ } catch {
5223
+ return false;
5224
+ }
5225
+
5226
+ if (!settings.hooks?.PostToolUse) return false;
5227
+
5228
+ const before = settings.hooks.PostToolUse.length;
5229
+ settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(
5230
+ (h) => !h.hooks?.some((hh) => hh.command?.includes('vault-error-hook'))
5231
+ );
5232
+
5233
+ if (settings.hooks.PostToolUse.length === before) return false;
5234
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
5235
+ return true;
5236
+ }
5237
+
4659
5238
  async function runSkills() {
4660
5239
  const sub = args[1];
4661
5240
 
@@ -4953,6 +5532,25 @@ async function runHooksInstall() {
4953
5532
  }
4954
5533
  console.log();
4955
5534
  }
5535
+
5536
+ // Proactive surfacing hooks (vault recall + error recall)
5537
+ try {
5538
+ const recallInstalled = installRecallHook();
5539
+ if (recallInstalled) {
5540
+ console.log(` ${green('✓')} Vault recall hook installed (proactive surfacing on prompts)`);
5541
+ }
5542
+ } catch (e) {
5543
+ console.error(` ${red('x')} Vault recall hook failed: ${e.message}`);
5544
+ }
5545
+ try {
5546
+ const errorInstalled = installErrorHook();
5547
+ if (errorInstalled) {
5548
+ console.log(` ${green('✓')} Vault error hook installed (surfaces past errors on Bash failures)`);
5549
+ }
5550
+ } catch (e) {
5551
+ console.error(` ${red('x')} Vault error hook failed: ${e.message}`);
5552
+ }
5553
+ console.log();
4956
5554
  }
4957
5555
 
4958
5556
  async function runHooksUninstall() {
@@ -4994,6 +5592,24 @@ async function runHooksUninstall() {
4994
5592
  } catch (e) {
4995
5593
  console.error(`\n ${red('x')} Failed to remove auto-capture hook: ${e.message}\n`);
4996
5594
  }
5595
+
5596
+ try {
5597
+ const recallHookRemoved = removeRecallHook();
5598
+ if (recallHookRemoved) {
5599
+ console.log(`\n ${green('✓')} Vault recall hook removed.\n`);
5600
+ }
5601
+ } catch (e) {
5602
+ console.error(`\n ${red('x')} Failed to remove recall hook: ${e.message}\n`);
5603
+ }
5604
+
5605
+ try {
5606
+ const errorHookRemoved = removeErrorHook();
5607
+ if (errorHookRemoved) {
5608
+ console.log(`\n ${green('✓')} Vault error hook removed.\n`);
5609
+ }
5610
+ } catch (e) {
5611
+ console.error(`\n ${red('x')} Failed to remove error hook: ${e.message}\n`);
5612
+ }
4997
5613
  }
4998
5614
 
4999
5615
  async function runHooks() {
@@ -6313,6 +6929,113 @@ ${progArgs.map(a => ` <string>${a}</string>`).join('\n')}
6313
6929
  }
6314
6930
  }
6315
6931
 
6932
+ async function runStats() {
6933
+ const { resolveConfig } = await import('@context-vault/core/config');
6934
+ const { initDatabase } = await import('@context-vault/core/db');
6935
+ const { gatherRecallSummary, gatherCoRetrievalSummary } = await import('../dist/stats/recall.js');
6936
+
6937
+ const sub = args[1];
6938
+ if (!sub || sub === 'recall') {
6939
+ await runStatsRecall({ resolveConfig, initDatabase, gatherRecallSummary });
6940
+ } else if (sub === 'co-retrieval') {
6941
+ await runStatsCoRetrieval({ resolveConfig, initDatabase, gatherCoRetrievalSummary });
6942
+ } else {
6943
+ console.error(red(` Unknown stats subcommand: ${sub}`));
6944
+ console.error(` Available: recall, co-retrieval`);
6945
+ process.exit(1);
6946
+ }
6947
+ }
6948
+
6949
+ async function runStatsRecall({ resolveConfig, initDatabase, gatherRecallSummary }) {
6950
+ const config = resolveConfig();
6951
+ let db;
6952
+ try {
6953
+ db = await initDatabase(config.dbPath);
6954
+ } catch (e) {
6955
+ console.error(red(` Database not accessible: ${e.message}`));
6956
+ process.exit(1);
6957
+ }
6958
+
6959
+ let s;
6960
+ try {
6961
+ s = gatherRecallSummary({ db, config });
6962
+ } finally {
6963
+ db.close();
6964
+ }
6965
+
6966
+ const ratioPct = Math.round(s.ratio * 100);
6967
+ const targetPct = Math.round(s.target * 100);
6968
+ const statusIcon = s.ratio >= s.target ? green('✓') : yellow('·');
6969
+ console.log();
6970
+ console.log(` ${bold('◇ context-vault stats recall')}`);
6971
+ console.log();
6972
+ console.log(` ${statusIcon} Recall ratio: ${bold(s.ratio.toFixed(2))} (target: ${s.target.toFixed(2)})`);
6973
+ console.log(` Total entries: ${s.total_entries}`);
6974
+ console.log(` Recalled (1+): ${s.recalled_entries} (${ratioPct}%)`);
6975
+ console.log(` Never recalled: ${s.never_recalled} (${100 - ratioPct}%)`);
6976
+ console.log(` Avg recall count: ${s.avg_recall_count} (among recalled entries)`);
6977
+
6978
+ if (s.top_recalled.length) {
6979
+ console.log();
6980
+ console.log(` ${bold('Top recalled:')}`);
6981
+ for (let i = 0; i < s.top_recalled.length; i++) {
6982
+ const e = s.top_recalled[i];
6983
+ const title = (e.title || '(untitled)').slice(0, 50);
6984
+ console.log(` ${i + 1}. "${title}" (recall: ${e.recall_count}, sessions: ${e.recall_sessions})`);
6985
+ }
6986
+ }
6987
+
6988
+ if (s.dead_entry_count > 0) {
6989
+ console.log();
6990
+ console.log(` ${bold('Dead entries')} ${dim('(saved >30 days ago, never recalled):')}`);
6991
+ console.log(` - ${s.dead_entry_count} entries across ${s.dead_bucket_count} buckets`);
6992
+ if (s.top_dead_buckets.length) {
6993
+ const bucketStr = s.top_dead_buckets.map((b) => `${b.bucket} (${b.count})`).join(', ');
6994
+ console.log(` - Top dead buckets: ${bucketStr}`);
6995
+ }
6996
+ }
6997
+
6998
+ console.log();
6999
+ }
7000
+
7001
+ async function runStatsCoRetrieval({ resolveConfig, initDatabase, gatherCoRetrievalSummary }) {
7002
+ const config = resolveConfig();
7003
+ let db;
7004
+ try {
7005
+ db = await initDatabase(config.dbPath);
7006
+ } catch (e) {
7007
+ console.error(red(` Database not accessible: ${e.message}`));
7008
+ process.exit(1);
7009
+ }
7010
+
7011
+ let s;
7012
+ try {
7013
+ s = gatherCoRetrievalSummary({ db, config });
7014
+ } finally {
7015
+ db.close();
7016
+ }
7017
+
7018
+ console.log();
7019
+ console.log(` ${bold('◇ context-vault stats co-retrieval')}`);
7020
+ console.log();
7021
+ console.log(` Co-retrieval pairs: ${bold(String(s.total_pairs))}`);
7022
+
7023
+ if (s.top_pairs.length) {
7024
+ console.log();
7025
+ console.log(` ${bold('Strongest pairs:')}`);
7026
+ for (let i = 0; i < s.top_pairs.length; i++) {
7027
+ const p = s.top_pairs[i];
7028
+ const titleA = (p.title_a || '(untitled)').slice(0, 40);
7029
+ const titleB = (p.title_b || '(untitled)').slice(0, 40);
7030
+ console.log(` ${i + 1}. "${titleA}" <-> "${titleB}" (weight: ${p.weight})`);
7031
+ }
7032
+ }
7033
+
7034
+ console.log();
7035
+ console.log(` Graph density: ${s.graph_density.toFixed(4)} ${dim('(sparse, expected for early usage)')}`);
7036
+ console.log();
7037
+ }
7038
+
6316
7039
  async function runServe() {
6317
7040
  await import('../dist/server.js');
6318
7041
  }
@@ -6402,6 +7125,9 @@ async function main() {
6402
7125
  case 'reindex':
6403
7126
  await runReindex();
6404
7127
  break;
7128
+ case 'sync':
7129
+ await runSync();
7130
+ break;
6405
7131
  case 'migrate-dirs':
6406
7132
  await runMigrateDirs();
6407
7133
  break;
@@ -6444,6 +7170,9 @@ async function main() {
6444
7170
  case 'debug':
6445
7171
  await runDebug();
6446
7172
  break;
7173
+ case 'stats':
7174
+ await runStats();
7175
+ break;
6447
7176
  default:
6448
7177
  console.error(red(`Unknown command: ${command}`));
6449
7178
  console.error(`Run ${cyan('context-vault --help')} for usage.`);