context-vault 3.6.0 → 3.7.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/bin/cli.js CHANGED
@@ -396,6 +396,7 @@ ${bold('Commands:')}
396
396
  ${cyan('ingest')} <url> Fetch URL and save as vault entry
397
397
  ${cyan('ingest-project')} <path> Scan project directory and register as project entity
398
398
  ${cyan('reindex')} Rebuild search index from knowledge files
399
+ ${cyan('sync')} [dir] Index .context/ files into vault DB (use --dry-run to preview)
399
400
  ${cyan('migrate-dirs')} [--dry-run] Rename plural vault dirs to singular (post-2.18.0)
400
401
  ${cyan('archive')} Archive old ephemeral/event entries (use --dry-run to preview)
401
402
  ${cyan('restore')} <id> Restore an archived entry back into the vault
@@ -428,6 +429,9 @@ ${bold('Commands:')}
428
429
  --force Overwrite existing config without confirmation
429
430
  --skip-embeddings Skip embedding model download (FTS-only mode)
430
431
  --dry-run Show what setup would do without writing anything
432
+ --upgrade Upgrade installed agent rules to the latest bundled version
433
+ --no-rules Skip agent rules installation during setup
434
+ --no-hooks Skip recall/error hook installation during setup
431
435
  `);
432
436
  }
433
437
 
@@ -445,6 +449,106 @@ async function runSetup() {
445
449
  }
446
450
  console.log();
447
451
 
452
+ // --upgrade: only upgrade agent rules, then exit
453
+ if (flags.has('--upgrade')) {
454
+ console.log(dim(' Checking agent rules for updates...\n'));
455
+ const bundled = loadAgentRules();
456
+ if (!bundled) {
457
+ console.log(` ${yellow('!')} Agent rules file not found in package.\n`);
458
+ return;
459
+ }
460
+ const bundledVersion = extractRulesVersion(bundled);
461
+
462
+ // Check all known tool paths (not just detected tools, since a tool may have been
463
+ // uninstalled but its rules file still exists)
464
+ const allToolsWithRules = TOOLS.filter((t) => t.rulesPath);
465
+ let found = 0;
466
+ let upgraded = 0;
467
+ const upgradeable = [];
468
+
469
+ for (const tool of allToolsWithRules) {
470
+ const installed = getInstalledRulesForTool(tool);
471
+ if (!installed) continue;
472
+ found++;
473
+
474
+ const installedVersion = extractRulesVersion(installed);
475
+ if (installed.trim() === bundled.trim()) {
476
+ console.log(` ${green('✓')} ${tool.name}: up to date${bundledVersion ? ` (v${bundledVersion})` : ''}`);
477
+ continue;
478
+ }
479
+
480
+ upgradeable.push({ tool, installed, installedVersion });
481
+ console.log(
482
+ ` ${yellow('!')} ${tool.name}: ${installedVersion ? `v${installedVersion}` : 'unknown version'} → ${bundledVersion ? `v${bundledVersion}` : 'bundled'}`
483
+ );
484
+
485
+ // Show a compact diff
486
+ const installedLines = installed.split('\n');
487
+ const bundledLines = bundled.split('\n');
488
+ const maxLines = Math.max(installedLines.length, bundledLines.length);
489
+ let diffLines = 0;
490
+ for (let i = 0; i < maxLines; i++) {
491
+ const a = installedLines[i];
492
+ const b = bundledLines[i];
493
+ if (a === b) continue;
494
+ if (diffLines === 0) console.log();
495
+ if (diffLines >= 20) {
496
+ console.log(dim(` ... and more changes`));
497
+ break;
498
+ }
499
+ if (a === undefined) {
500
+ console.log(` ${green('+')} ${b}`);
501
+ } else if (b === undefined) {
502
+ console.log(` ${red('-')} ${a}`);
503
+ } else {
504
+ console.log(` ${red('-')} ${a}`);
505
+ console.log(` ${green('+')} ${b}`);
506
+ }
507
+ diffLines++;
508
+ }
509
+ console.log();
510
+ }
511
+
512
+ if (found === 0) {
513
+ console.log(` ${yellow('!')} No installed rules found. Run ${cyan('context-vault rules install')} first.\n`);
514
+ return;
515
+ }
516
+
517
+ if (upgradeable.length === 0) {
518
+ console.log(`\n ${green('✓')} All rules are up to date.\n`);
519
+ return;
520
+ }
521
+
522
+ if (!isDryRun) {
523
+ const answer = isNonInteractive
524
+ ? 'Y'
525
+ : await prompt(` Upgrade ${upgradeable.length} rules file(s)? (Y/n):`, 'Y');
526
+ if (answer.toLowerCase() === 'n') {
527
+ console.log(dim(' Skipped.\n'));
528
+ return;
529
+ }
530
+
531
+ for (const { tool } of upgradeable) {
532
+ try {
533
+ installAgentRulesForTool(tool, bundled);
534
+ console.log(` ${green('+')} ${tool.name} — upgraded`);
535
+ upgraded++;
536
+ } catch (e) {
537
+ console.log(` ${red('x')} ${tool.name} — ${e.message}`);
538
+ }
539
+ }
540
+ } else {
541
+ console.log(dim(` [dry-run] Would upgrade ${upgradeable.length} rules file(s).`));
542
+ }
543
+
544
+ console.log();
545
+ if (upgraded > 0) {
546
+ console.log(dim(' Restart your AI tools to apply the updated rules.'));
547
+ console.log();
548
+ }
549
+ return;
550
+ }
551
+
448
552
  // Check for existing installation
449
553
  const existingConfig = join(HOME, '.context-mcp', 'config.json');
450
554
  if (existsSync(existingConfig) && !isNonInteractive && !isDryRun) {
@@ -1032,7 +1136,7 @@ async function runSetup() {
1032
1136
 
1033
1137
  if (claudeConfigured) {
1034
1138
  if (isDryRun) {
1035
- console.log(` ${yellow('[dry-run]')} Would install Claude Code hooks (memory recall, session capture, auto-capture)`);
1139
+ console.log(` ${yellow('[dry-run]')} Would install Claude Code hooks (memory recall, session capture, auto-capture, vault recall, error recall)`);
1036
1140
  console.log(` ${yellow('[dry-run]')} Would install Claude Code skills (compile-context, vault-setup)`);
1037
1141
  } else {
1038
1142
  // Bundled hooks prompt: one Y/n for all three hooks
@@ -1063,6 +1167,20 @@ async function runSetup() {
1063
1167
  } catch (e) {
1064
1168
  console.log(` ${red('x')} Auto-capture hook failed: ${e.message}`);
1065
1169
  }
1170
+ if (!flags.has('--no-hooks')) {
1171
+ try {
1172
+ const recallInstalled = installRecallHook();
1173
+ if (recallInstalled) console.log(` ${green('+')} Vault recall hook installed`);
1174
+ } catch (e) {
1175
+ console.log(` ${red('x')} Recall hook failed: ${e.message}`);
1176
+ }
1177
+ try {
1178
+ const errorInstalled = installErrorHook();
1179
+ if (errorInstalled) console.log(` ${green('+')} Vault error hook installed`);
1180
+ } catch (e) {
1181
+ console.log(` ${red('x')} Error hook failed: ${e.message}`);
1182
+ }
1183
+ }
1066
1184
  } else {
1067
1185
  console.log(dim(` Hooks skipped. Install later: context-vault hooks install`));
1068
1186
  }
@@ -2040,6 +2158,269 @@ async function runReindex() {
2040
2158
  }
2041
2159
  }
2042
2160
 
2161
+ async function runSync() {
2162
+ const dryRun = flags.has('--dry-run');
2163
+ const positional = args.slice(1).find((a) => !a.startsWith('--'));
2164
+ const scanDir = positional ? resolve(positional) : process.cwd();
2165
+
2166
+ const contextDir = join(scanDir, '.context');
2167
+ if (!existsSync(contextDir)) {
2168
+ console.error(red(`No .context/ directory found in ${scanDir}`));
2169
+ console.error(dim('The .context/ directory is created automatically when save_context is called from a workspace.'));
2170
+ process.exit(1);
2171
+ }
2172
+
2173
+ console.log(dim(dryRun ? 'Scanning .context/ (dry run)...' : 'Syncing .context/ to vault...'));
2174
+
2175
+ const { resolveConfig } = await import('@context-vault/core/config');
2176
+ const { initDatabase, prepareStatements, insertVec, deleteVec } =
2177
+ await import('@context-vault/core/db');
2178
+ const { embed } = await import('@context-vault/core/embed');
2179
+ const { parseFrontmatter, parseEntryFromMarkdown } = await import('@context-vault/core/frontmatter');
2180
+ const { categoryFor, defaultTierFor } = await import('@context-vault/core/categories');
2181
+ const { dirToKind, walkDir } = await import('@context-vault/core/files');
2182
+ const { shouldIndex } = await import('@context-vault/core/indexing');
2183
+ const { DEFAULT_INDEXING } = await import('@context-vault/core/constants');
2184
+
2185
+ const config = resolveConfig();
2186
+ if (!config.vaultDirExists) {
2187
+ console.error(red(`Vault directory not found: ${config.vaultDir}`));
2188
+ console.error('Run ' + cyan('context-vault setup') + ' to configure.');
2189
+ process.exit(1);
2190
+ }
2191
+
2192
+ const db = await initDatabase(config.dbPath);
2193
+ const stmts = prepareStatements(db);
2194
+ const ixConfig = config.indexing ?? DEFAULT_INDEXING;
2195
+
2196
+ let synced = 0;
2197
+ let alreadyIndexed = 0;
2198
+ let updated = 0;
2199
+ let errors = 0;
2200
+ let skippedIndexing = 0;
2201
+
2202
+ // Discover kind directories inside .context/
2203
+ let kindDirs;
2204
+ try {
2205
+ kindDirs = readdirSync(contextDir, { withFileTypes: true })
2206
+ .filter((d) => d.isDirectory() && !d.name.startsWith('.') && !d.name.startsWith('_'));
2207
+ } catch (e) {
2208
+ console.error(red(`Failed to read .context/: ${e.message}`));
2209
+ db.close();
2210
+ process.exit(1);
2211
+ }
2212
+
2213
+ const pendingEmbeds = [];
2214
+
2215
+ if (!dryRun) db.exec('BEGIN');
2216
+ try {
2217
+ for (const kindEntry of kindDirs) {
2218
+ const kind = dirToKind(kindEntry.name);
2219
+ const kindDir = join(contextDir, kindEntry.name);
2220
+ const mdFiles = walkDir(kindDir).filter((f) => f.filePath.endsWith('.md'));
2221
+
2222
+ for (const { filePath, relDir } of mdFiles) {
2223
+ let raw;
2224
+ try {
2225
+ raw = readFileSync(filePath, 'utf-8');
2226
+ } catch (e) {
2227
+ console.error(dim(` skip: could not read ${filePath}: ${e.message}`));
2228
+ errors++;
2229
+ continue;
2230
+ }
2231
+
2232
+ if (!raw.startsWith('---\n')) {
2233
+ console.error(dim(` skip (no frontmatter): ${filePath}`));
2234
+ errors++;
2235
+ continue;
2236
+ }
2237
+
2238
+ const { meta: fmMeta, body: rawBody } = parseFrontmatter(raw);
2239
+ const entryId = fmMeta.id;
2240
+ if (!entryId) {
2241
+ console.error(dim(` skip (no id in frontmatter): ${filePath}`));
2242
+ errors++;
2243
+ continue;
2244
+ }
2245
+
2246
+ const parsed = parseEntryFromMarkdown(kind, rawBody, fmMeta);
2247
+ const category = categoryFor(kind);
2248
+
2249
+ // Check if entry exists in DB
2250
+ const existing = stmts.getEntryById.get(entryId);
2251
+
2252
+ if (existing) {
2253
+ // Check if content differs
2254
+ const bodyChanged = existing.body !== parsed.body;
2255
+ const titleChanged = (parsed.title || null) !== (existing.title || null);
2256
+
2257
+ if (!bodyChanged && !titleChanged) {
2258
+ alreadyIndexed++;
2259
+ continue;
2260
+ }
2261
+
2262
+ if (dryRun) {
2263
+ console.log(` ${yellow('~')} would update: ${entryId} (${parsed.title || '(untitled)'})`);
2264
+ updated++;
2265
+ continue;
2266
+ }
2267
+
2268
+ // Update existing entry
2269
+ const tagsJson = fmMeta.tags ? JSON.stringify(fmMeta.tags) : null;
2270
+ const meta = { ...(parsed.meta || {}) };
2271
+ if (relDir) meta.folder = relDir;
2272
+ const metaJson = Object.keys(meta).length ? JSON.stringify(meta) : null;
2273
+ const identity_key = fmMeta.identity_key || null;
2274
+ const expires_at = fmMeta.expires_at || null;
2275
+
2276
+ stmts.updateEntry.run(
2277
+ parsed.title || null,
2278
+ parsed.body,
2279
+ metaJson,
2280
+ tagsJson,
2281
+ fmMeta.source || 'file',
2282
+ category,
2283
+ identity_key,
2284
+ expires_at,
2285
+ existing.file_path
2286
+ );
2287
+
2288
+ const entryIndexed = shouldIndex(
2289
+ { kind, category, bodyLength: parsed.body.length },
2290
+ ixConfig
2291
+ );
2292
+
2293
+ if (entryIndexed && category !== 'event') {
2294
+ const rowidResult = stmts.getRowid.get(entryId);
2295
+ if (rowidResult?.rowid) {
2296
+ const embeddingText = [parsed.title, parsed.body].filter(Boolean).join(' ');
2297
+ pendingEmbeds.push({ rowid: rowidResult.rowid, text: embeddingText });
2298
+ }
2299
+ }
2300
+
2301
+ updated++;
2302
+ continue;
2303
+ }
2304
+
2305
+ // Entry not in DB: index it
2306
+ const entryIndexed = shouldIndex(
2307
+ { kind, category, bodyLength: parsed.body.length },
2308
+ ixConfig
2309
+ );
2310
+
2311
+ if (dryRun) {
2312
+ if (entryIndexed) {
2313
+ console.log(` ${green('+')} would sync: ${entryId} (${parsed.title || '(untitled)'})`);
2314
+ synced++;
2315
+ } else {
2316
+ console.log(` ${dim('o')} would skip indexing: ${entryId}`);
2317
+ skippedIndexing++;
2318
+ }
2319
+ continue;
2320
+ }
2321
+
2322
+ const tagsJson = fmMeta.tags ? JSON.stringify(fmMeta.tags) : null;
2323
+ const meta = { ...(parsed.meta || {}) };
2324
+ if (relDir) meta.folder = relDir;
2325
+ const metaJson = Object.keys(meta).length ? JSON.stringify(meta) : null;
2326
+ const created = fmMeta.created || new Date().toISOString();
2327
+ const identity_key = fmMeta.identity_key || null;
2328
+ const expires_at = fmMeta.expires_at || null;
2329
+ const effectiveTier = fmMeta.tier || defaultTierFor(kind);
2330
+
2331
+ // The entry should point to the vault file path (if it exists there), else use the .context path
2332
+ const vaultFilePath = existing?.file_path || fmMeta.file_path || filePath;
2333
+
2334
+ try {
2335
+ const upsertEntry = db.prepare(
2336
+ `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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
2337
+ );
2338
+ const result = upsertEntry.run(
2339
+ entryId,
2340
+ kind,
2341
+ category,
2342
+ parsed.title || null,
2343
+ parsed.body,
2344
+ metaJson,
2345
+ tagsJson,
2346
+ fmMeta.source || 'file',
2347
+ vaultFilePath,
2348
+ identity_key,
2349
+ expires_at,
2350
+ created,
2351
+ fmMeta.updated || created,
2352
+ effectiveTier,
2353
+ entryIndexed ? 1 : 0
2354
+ );
2355
+
2356
+ if (result.changes > 0) {
2357
+ if (entryIndexed && category !== 'event') {
2358
+ const rowidResult = stmts.getRowid.get(entryId);
2359
+ if (rowidResult?.rowid) {
2360
+ const embeddingText = [parsed.title, parsed.body].filter(Boolean).join(' ');
2361
+ pendingEmbeds.push({ rowid: rowidResult.rowid, text: embeddingText });
2362
+ }
2363
+ }
2364
+ if (!entryIndexed) skippedIndexing++;
2365
+ synced++;
2366
+ } else {
2367
+ alreadyIndexed++;
2368
+ }
2369
+ } catch (e) {
2370
+ console.error(dim(` error indexing ${entryId}: ${e.message}`));
2371
+ errors++;
2372
+ }
2373
+ }
2374
+ }
2375
+
2376
+ // Generate embeddings in batch
2377
+ if (!dryRun && pendingEmbeds.length > 0) {
2378
+ const { embedBatch: batchEmbed } = await import('@context-vault/core/embed');
2379
+ const BATCH_SIZE = 32;
2380
+ for (let i = 0; i < pendingEmbeds.length; i += BATCH_SIZE) {
2381
+ const batch = pendingEmbeds.slice(i, i + BATCH_SIZE);
2382
+ const texts = batch.map((b) => b.text);
2383
+ try {
2384
+ const embeddings = await batchEmbed(texts);
2385
+ for (let j = 0; j < batch.length; j++) {
2386
+ if (embeddings[j]) {
2387
+ try { deleteVec(stmts, batch[j].rowid); } catch {}
2388
+ insertVec(stmts, batch[j].rowid, embeddings[j]);
2389
+ }
2390
+ }
2391
+ } catch (e) {
2392
+ console.warn(dim(` embedding batch failed: ${e.message}`));
2393
+ }
2394
+ }
2395
+ }
2396
+
2397
+ if (!dryRun) db.exec('COMMIT');
2398
+ } catch (e) {
2399
+ if (!dryRun) {
2400
+ try { db.exec('ROLLBACK'); } catch {}
2401
+ }
2402
+ throw e;
2403
+ }
2404
+
2405
+ db.close();
2406
+
2407
+ if (dryRun) {
2408
+ console.log(yellow('Dry run results (no changes made):'));
2409
+ console.log(` Would sync: ${synced}`);
2410
+ console.log(` Would update: ${updated}`);
2411
+ console.log(` Already indexed: ${alreadyIndexed}`);
2412
+ if (skippedIndexing) console.log(` Would skip indexing: ${skippedIndexing}`);
2413
+ if (errors) console.log(` ${red('Errors:')} ${errors}`);
2414
+ } else {
2415
+ console.log(green('Sync complete'));
2416
+ console.log(` ${green('+')} ${synced} synced`);
2417
+ if (updated) console.log(` ${yellow('~')} ${updated} updated`);
2418
+ console.log(` ${dim('.')} ${alreadyIndexed} already indexed`);
2419
+ if (skippedIndexing) console.log(` ${dim('o')} ${skippedIndexing} skipped indexing`);
2420
+ if (errors) console.log(` ${red('!')} ${errors} errors`);
2421
+ }
2422
+ }
2423
+
2043
2424
  async function runMigrateDirs() {
2044
2425
  const dryRun = flags.has('--dry-run');
2045
2426
 
@@ -2542,7 +2923,9 @@ async function runUninstall() {
2542
2923
  const captureRemoved = removeSessionCaptureHook();
2543
2924
  const flushRemoved = removeSessionEndHook();
2544
2925
  const autoCaptureRemoved = removePostToolCallHook();
2545
- if (recallRemoved || captureRemoved || flushRemoved || autoCaptureRemoved) {
2926
+ const recallHookRemoved = removeRecallHook();
2927
+ const errorHookRemoved = removeErrorHook();
2928
+ if (recallRemoved || captureRemoved || flushRemoved || autoCaptureRemoved || recallHookRemoved || errorHookRemoved) {
2546
2929
  console.log(` ${green('+')} Removed Claude Code hooks`);
2547
2930
  } else {
2548
2931
  console.log(` ${dim('-')} No Claude Code hooks to remove`);
@@ -4280,6 +4663,35 @@ function loadAgentRules() {
4280
4663
  return readFileSync(rulesPath, 'utf-8');
4281
4664
  }
4282
4665
 
4666
+ /**
4667
+ * Extract the version string from a rules file content.
4668
+ * Looks for <!-- context-vault-rules vX.Y --> comment on the first line.
4669
+ * Returns the version string (e.g. "1.0") or null if not found.
4670
+ */
4671
+ function extractRulesVersion(content) {
4672
+ if (!content) return null;
4673
+ const match = content.match(/<!--\s*context-vault-rules\s+v([\d.]+)\s*-->/);
4674
+ return match ? match[1] : null;
4675
+ }
4676
+
4677
+ /**
4678
+ * Get the installed rules content for a tool, handling both write and append methods.
4679
+ * For append-based tools (Windsurf), extracts only the delimited section.
4680
+ * Returns the rules content or null if not installed.
4681
+ */
4682
+ function getInstalledRulesForTool(tool) {
4683
+ const rulesPath = tool.rulesPath;
4684
+ if (!rulesPath || !existsSync(rulesPath)) return null;
4685
+ const content = readFileSync(rulesPath, 'utf-8');
4686
+ if (tool.rulesMethod === 'append') {
4687
+ const match = content.match(
4688
+ new RegExp(`${RULES_DELIMITER_START}\\n([\\s\\S]*?)\\n${RULES_DELIMITER_END}`)
4689
+ );
4690
+ return match ? match[1] : null;
4691
+ }
4692
+ return content;
4693
+ }
4694
+
4283
4695
  /**
4284
4696
  * Return the path where agent rules are/would be installed for a given tool.
4285
4697
  * Returns null for tools with no rules install path.
@@ -4656,6 +5068,159 @@ function removePostToolCallHook() {
4656
5068
  return true;
4657
5069
  }
4658
5070
 
5071
+ /**
5072
+ * Install the vault-recall-hook.mjs into ~/.claude/hooks/ and register it
5073
+ * as a UserPromptSubmit hook in ~/.claude/settings.json.
5074
+ * Returns true if installed, false if already present.
5075
+ */
5076
+ function installRecallHook() {
5077
+ const srcPath = join(ROOT, 'assets', 'vault-recall-hook.mjs');
5078
+ if (!existsSync(srcPath)) return false;
5079
+
5080
+ const hooksDir = join(HOME, '.claude', 'hooks');
5081
+ mkdirSync(hooksDir, { recursive: true });
5082
+ const destPath = join(hooksDir, 'vault-recall-hook.mjs');
5083
+ copyFileSync(srcPath, destPath);
5084
+
5085
+ const settingsPath = claudeSettingsPath();
5086
+ let settings = {};
5087
+ if (existsSync(settingsPath)) {
5088
+ try {
5089
+ settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
5090
+ } catch {
5091
+ const bak = settingsPath + '.bak';
5092
+ copyFileSync(settingsPath, bak);
5093
+ }
5094
+ }
5095
+
5096
+ if (!settings.hooks) settings.hooks = {};
5097
+ if (!settings.hooks.UserPromptSubmit) settings.hooks.UserPromptSubmit = [];
5098
+
5099
+ const hookCmd = `node ${destPath}`;
5100
+ const alreadyInstalled = settings.hooks.UserPromptSubmit.some((h) =>
5101
+ h.hooks?.some((hh) => hh.command?.includes('vault-recall-hook'))
5102
+ );
5103
+ if (alreadyInstalled) return false;
5104
+
5105
+ settings.hooks.UserPromptSubmit.push({
5106
+ hooks: [
5107
+ {
5108
+ type: 'command',
5109
+ command: hookCmd,
5110
+ timeout: 5,
5111
+ },
5112
+ ],
5113
+ });
5114
+
5115
+ mkdirSync(dirname(settingsPath), { recursive: true });
5116
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
5117
+ return true;
5118
+ }
5119
+
5120
+ /**
5121
+ * Remove the vault-recall-hook UserPromptSubmit hook from settings.json.
5122
+ * Returns true if removed, false if not found.
5123
+ */
5124
+ function removeRecallHook() {
5125
+ const settingsPath = claudeSettingsPath();
5126
+ if (!existsSync(settingsPath)) return false;
5127
+
5128
+ let settings;
5129
+ try {
5130
+ settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
5131
+ } catch {
5132
+ return false;
5133
+ }
5134
+
5135
+ if (!settings.hooks?.UserPromptSubmit) return false;
5136
+
5137
+ const before = settings.hooks.UserPromptSubmit.length;
5138
+ settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit.filter(
5139
+ (h) => !h.hooks?.some((hh) => hh.command?.includes('vault-recall-hook'))
5140
+ );
5141
+
5142
+ if (settings.hooks.UserPromptSubmit.length === before) return false;
5143
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
5144
+ return true;
5145
+ }
5146
+
5147
+ /**
5148
+ * Install the vault-error-hook.mjs into ~/.claude/hooks/ and register it
5149
+ * as a PostToolUse hook (matcher: Bash) in ~/.claude/settings.json.
5150
+ * Returns true if installed, false if already present.
5151
+ */
5152
+ function installErrorHook() {
5153
+ const srcPath = join(ROOT, 'assets', 'vault-error-hook.mjs');
5154
+ if (!existsSync(srcPath)) return false;
5155
+
5156
+ const hooksDir = join(HOME, '.claude', 'hooks');
5157
+ mkdirSync(hooksDir, { recursive: true });
5158
+ const destPath = join(hooksDir, 'vault-error-hook.mjs');
5159
+ copyFileSync(srcPath, destPath);
5160
+
5161
+ const settingsPath = claudeSettingsPath();
5162
+ let settings = {};
5163
+ if (existsSync(settingsPath)) {
5164
+ try {
5165
+ settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
5166
+ } catch {
5167
+ const bak = settingsPath + '.bak';
5168
+ copyFileSync(settingsPath, bak);
5169
+ }
5170
+ }
5171
+
5172
+ if (!settings.hooks) settings.hooks = {};
5173
+ if (!settings.hooks.PostToolUse) settings.hooks.PostToolUse = [];
5174
+
5175
+ const hookCmd = `node ${destPath}`;
5176
+ const alreadyInstalled = settings.hooks.PostToolUse.some((h) =>
5177
+ h.hooks?.some((hh) => hh.command?.includes('vault-error-hook'))
5178
+ );
5179
+ if (alreadyInstalled) return false;
5180
+
5181
+ settings.hooks.PostToolUse.push({
5182
+ matcher: 'Bash',
5183
+ hooks: [
5184
+ {
5185
+ type: 'command',
5186
+ command: hookCmd,
5187
+ timeout: 5,
5188
+ },
5189
+ ],
5190
+ });
5191
+
5192
+ mkdirSync(dirname(settingsPath), { recursive: true });
5193
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
5194
+ return true;
5195
+ }
5196
+
5197
+ /**
5198
+ * Remove the vault-error-hook PostToolUse hook from settings.json.
5199
+ * Returns true if removed, false if not found.
5200
+ */
5201
+ function removeErrorHook() {
5202
+ const settingsPath = claudeSettingsPath();
5203
+ if (!existsSync(settingsPath)) return false;
5204
+
5205
+ let settings;
5206
+ try {
5207
+ settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
5208
+ } catch {
5209
+ return false;
5210
+ }
5211
+
5212
+ if (!settings.hooks?.PostToolUse) return false;
5213
+
5214
+ const before = settings.hooks.PostToolUse.length;
5215
+ settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(
5216
+ (h) => !h.hooks?.some((hh) => hh.command?.includes('vault-error-hook'))
5217
+ );
5218
+
5219
+ if (settings.hooks.PostToolUse.length === before) return false;
5220
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
5221
+ return true;
5222
+ }
5223
+
4659
5224
  async function runSkills() {
4660
5225
  const sub = args[1];
4661
5226
 
@@ -4953,6 +5518,25 @@ async function runHooksInstall() {
4953
5518
  }
4954
5519
  console.log();
4955
5520
  }
5521
+
5522
+ // Proactive surfacing hooks (vault recall + error recall)
5523
+ try {
5524
+ const recallInstalled = installRecallHook();
5525
+ if (recallInstalled) {
5526
+ console.log(` ${green('✓')} Vault recall hook installed (proactive surfacing on prompts)`);
5527
+ }
5528
+ } catch (e) {
5529
+ console.error(` ${red('x')} Vault recall hook failed: ${e.message}`);
5530
+ }
5531
+ try {
5532
+ const errorInstalled = installErrorHook();
5533
+ if (errorInstalled) {
5534
+ console.log(` ${green('✓')} Vault error hook installed (surfaces past errors on Bash failures)`);
5535
+ }
5536
+ } catch (e) {
5537
+ console.error(` ${red('x')} Vault error hook failed: ${e.message}`);
5538
+ }
5539
+ console.log();
4956
5540
  }
4957
5541
 
4958
5542
  async function runHooksUninstall() {
@@ -4994,6 +5578,24 @@ async function runHooksUninstall() {
4994
5578
  } catch (e) {
4995
5579
  console.error(`\n ${red('x')} Failed to remove auto-capture hook: ${e.message}\n`);
4996
5580
  }
5581
+
5582
+ try {
5583
+ const recallHookRemoved = removeRecallHook();
5584
+ if (recallHookRemoved) {
5585
+ console.log(`\n ${green('✓')} Vault recall hook removed.\n`);
5586
+ }
5587
+ } catch (e) {
5588
+ console.error(`\n ${red('x')} Failed to remove recall hook: ${e.message}\n`);
5589
+ }
5590
+
5591
+ try {
5592
+ const errorHookRemoved = removeErrorHook();
5593
+ if (errorHookRemoved) {
5594
+ console.log(`\n ${green('✓')} Vault error hook removed.\n`);
5595
+ }
5596
+ } catch (e) {
5597
+ console.error(`\n ${red('x')} Failed to remove error hook: ${e.message}\n`);
5598
+ }
4997
5599
  }
4998
5600
 
4999
5601
  async function runHooks() {
@@ -6402,6 +7004,9 @@ async function main() {
6402
7004
  case 'reindex':
6403
7005
  await runReindex();
6404
7006
  break;
7007
+ case 'sync':
7008
+ await runSync();
7009
+ break;
6405
7010
  case 'migrate-dirs':
6406
7011
  await runMigrateDirs();
6407
7012
  break;
@@ -1 +1 @@
1
- {"version":3,"file":"register-tools.d.ts","sourceRoot":"","sources":["../src/register-tools.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,QAAQ,EAAyB,MAAM,YAAY,CAAC;AA+ClE,wBAAgB,aAAa,CAAC,MAAM,EAAE,GAAG,EAAE,GAAG,EAAE,QAAQ,GAAG,IAAI,CAwI9D"}
1
+ {"version":3,"file":"register-tools.d.ts","sourceRoot":"","sources":["../src/register-tools.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,QAAQ,EAAyB,MAAM,YAAY,CAAC;AAiDlE,wBAAgB,aAAa,CAAC,MAAM,EAAE,GAAG,EAAE,GAAG,EAAE,QAAQ,GAAG,IAAI,CAwI9D"}