context-vault 2.12.0 → 2.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -40,19 +40,63 @@ Entries are organized by `kind` (insight, decision, pattern, reference, contact,
40
40
 
41
41
  ## CLI
42
42
 
43
- | Command | Description |
44
- | ----------------------------- | --------------------------------------------------------- |
45
- | `context-vault setup` | Interactive installer — detects tools, writes MCP configs |
46
- | `context-vault connect --key` | Connect AI tools to hosted vault |
47
- | `context-vault switch` | Switch between local and hosted MCP modes |
48
- | `context-vault serve` | Start the MCP server (used by AI clients) |
49
- | `context-vault status` | Vault health, paths, entry counts |
50
- | `context-vault reindex` | Rebuild search index |
51
- | `context-vault import <path>` | Import .md, .csv, .json, .txt |
52
- | `context-vault export` | Export to JSON or CSV |
53
- | `context-vault ingest <url>` | Fetch URL and save as vault entry |
54
- | `context-vault update` | Check for updates |
55
- | `context-vault uninstall` | Remove MCP configs |
43
+ | Command | Description |
44
+ | ----------------------------- | ---------------------------------------------------------- |
45
+ | `context-vault setup` | Interactive installer — detects tools, writes MCP configs |
46
+ | `context-vault connect --key` | Connect AI tools to hosted vault |
47
+ | `context-vault switch` | Switch between local and hosted MCP modes |
48
+ | `context-vault serve` | Start the MCP server (used by AI clients) |
49
+ | `context-vault status` | Vault health, paths, entry counts |
50
+ | `context-vault flush` | Confirm DB is accessible; prints entry count and last save |
51
+ | `context-vault hooks install` | Install Claude Code memory and optional session flush hook |
52
+ | `context-vault hooks remove` | Remove the recall and session flush hooks |
53
+ | `context-vault reindex` | Rebuild search index |
54
+ | `context-vault import <path>` | Import .md, .csv, .json, .txt |
55
+ | `context-vault export` | Export to JSON or CSV |
56
+ | `context-vault ingest <url>` | Fetch URL and save as vault entry |
57
+ | `context-vault update` | Check for updates |
58
+ | `context-vault uninstall` | Remove MCP configs |
59
+
60
+ ## Claude Code Lifecycle Hooks
61
+
62
+ Claude Code exposes shell hooks that fire on session events. context-vault integrates with two of them:
63
+
64
+ **UserPromptSubmit** — runs `context-vault recall` on every prompt, injecting relevant vault entries as context (installed via `hooks install`).
65
+
66
+ **SessionEnd** — runs `context-vault flush` when a session ends, confirming the vault is healthy and logging the current entry count. Install it when prompted by `hooks install`, or add it manually to `~/.claude/settings.json`:
67
+
68
+ ```json
69
+ {
70
+ "hooks": {
71
+ "SessionEnd": [
72
+ {
73
+ "hooks": [
74
+ {
75
+ "type": "command",
76
+ "command": "npx context-vault flush",
77
+ "timeout": 10
78
+ }
79
+ ]
80
+ }
81
+ ]
82
+ }
83
+ }
84
+ ```
85
+
86
+ The `flush` command reads the DB, prints a one-line status (`context-vault ok — N entries, last save: <timestamp>`), and exits 0. It is intentionally a no-op write — its purpose is to confirm reachability at session boundaries.
87
+
88
+ To install both hooks at once:
89
+
90
+ ```bash
91
+ context-vault hooks install
92
+ # Follow the second prompt: "Install session auto-flush hook? (y/N)"
93
+ ```
94
+
95
+ To remove both hooks:
96
+
97
+ ```bash
98
+ context-vault hooks remove
99
+ ```
56
100
 
57
101
  ## Manual MCP Config
58
102
 
@@ -73,6 +117,8 @@ If you prefer manual setup over `context-vault setup`:
73
117
 
74
118
  No Node.js required — sign up at [app.context-vault.com](https://app.context-vault.com), get an API key, connect in 2 minutes.
75
119
 
120
+ Full setup instructions for Claude Code, Cursor, and GPT Actions: [docs/distribution/connect-in-2-minutes.md](../../docs/distribution/connect-in-2-minutes.md)
121
+
76
122
  ## Troubleshooting
77
123
 
78
124
  **Install fails (native modules):**
@@ -0,0 +1,45 @@
1
+ ---
2
+ name: compile-context
3
+ description: >
4
+ Compiles scattered vault entries on a topic into a single authoritative brief
5
+ for isolated retrieval in a fresh context window. Use when starting a new work
6
+ session on a project, preparing a handoff, or loading focused context without
7
+ noise. Also audits for stale or contradicting entries.
8
+ Triggers: "compile context", "create a brief", "context snapshot", "context bucket",
9
+ "make a brief for X", "load context for X".
10
+ ---
11
+
12
+ # compile-context skill
13
+
14
+ When the user asks to compile context or create a brief for a topic, call `create_snapshot` to synthesize a context brief from the vault.
15
+
16
+ ## Step 1 — Identify the topic
17
+
18
+ If the user provided a topic or project name, use it. If not, ask:
19
+
20
+ > "What topic or project should I compile context for?"
21
+
22
+ Derive a slug: lowercase, hyphens, no spaces (e.g. `neonode`, `context-vault`, `klarhimmel-infra`).
23
+
24
+ ## Step 2 — Call create_snapshot
25
+
26
+ Call `create_snapshot` with:
27
+
28
+ - `topic`: the topic name the user provided
29
+ - `identity_key`: `snapshot-<slug>` (e.g. `snapshot-context-vault`)
30
+ - `tags` (optional): any relevant tags the user mentions
31
+ - `kinds` (optional): restrict to specific entry kinds if the user requests it
32
+
33
+ The tool handles retrieval, deduplication, LLM synthesis, and saving automatically.
34
+
35
+ ## Step 3 — Report
36
+
37
+ After the tool returns, tell the user:
38
+
39
+ - The ULID of the saved brief
40
+ - How many entries were synthesized
41
+ - The exact call to retrieve it in a future session:
42
+ ```
43
+ get_context(identity_key: "snapshot-<slug>")
44
+ ```
45
+ - Suggest pinning the identity key in the relevant CLAUDE.md or MEMORY.md for zero-cost retrieval in fresh windows.
package/bin/cli.js CHANGED
@@ -235,11 +235,15 @@ ${bold("Commands:")}
235
235
  ${cyan("connect")} --key cv_... Connect AI tools to hosted vault
236
236
  ${cyan("switch")} local|hosted Switch between local and hosted MCP modes
237
237
  ${cyan("serve")} Start the MCP server (used by AI clients)
238
- ${cyan("hooks")} install|remove Install or remove Claude Code memory hook
238
+ ${cyan("hooks")} install|uninstall Install or remove Claude Code memory hook
239
+ ${cyan("claude")} install|uninstall Alias for hooks install|uninstall
240
+ ${cyan("skills")} install Install bundled Claude Code skills
241
+ ${cyan("flush")} Check vault health and confirm DB is accessible
239
242
  ${cyan("recall")} Search vault from a Claude Code hook (reads stdin)
240
243
  ${cyan("reindex")} Rebuild search index from knowledge files
241
244
  ${cyan("prune")} Remove expired entries (use --dry-run to preview)
242
245
  ${cyan("status")} Show vault diagnostics
246
+ ${cyan("doctor")} Diagnose and repair common issues
243
247
  ${cyan("update")} Check for and install updates
244
248
  ${cyan("uninstall")} Remove MCP configs and optionally data
245
249
  ${cyan("import")} <path> Import entries from file or directory
@@ -729,6 +733,37 @@ async function runSetup() {
729
733
  }
730
734
  }
731
735
 
736
+ // Claude Code skills (opt-in)
737
+ if (claudeConfigured && !isNonInteractive) {
738
+ console.log();
739
+ console.log(dim(" Install Claude Code skills? (recommended)"));
740
+ console.log(
741
+ dim(" compile-context — compile vault entries into a project brief"),
742
+ );
743
+ console.log();
744
+ const skillAnswer = await prompt(
745
+ " Install Claude Code skills? (Y/n):",
746
+ "Y",
747
+ );
748
+ const installSkillsFlag = skillAnswer.toLowerCase() !== "n";
749
+ if (installSkillsFlag) {
750
+ try {
751
+ const names = installSkills();
752
+ if (names.length > 0) {
753
+ for (const name of names) {
754
+ console.log(`\n ${green("+")} ${name} skill installed`);
755
+ }
756
+ }
757
+ } catch (e) {
758
+ console.log(`\n ${red("x")} Skills install failed: ${e.message}`);
759
+ }
760
+ } else {
761
+ console.log(
762
+ dim(` Skipped — install later: context-vault skills install`),
763
+ );
764
+ }
765
+ }
766
+
732
767
  // Seed entry
733
768
  const seeded = createSeedEntries(resolvedVaultDir);
734
769
  if (seeded > 0) {
@@ -1484,6 +1519,8 @@ async function runStatus() {
1484
1519
  const { resolveConfig } = await import("@context-vault/core/core/config");
1485
1520
  const { initDatabase } = await import("@context-vault/core/index/db");
1486
1521
  const { gatherVaultStatus } = await import("@context-vault/core/core/status");
1522
+ const { errorLogPath, errorLogCount } =
1523
+ await import("@context-vault/core/core/error-log");
1487
1524
 
1488
1525
  const config = resolveConfig();
1489
1526
 
@@ -1566,6 +1603,18 @@ async function runStatus() {
1566
1603
  console.log(yellow(" Stale paths detected in DB."));
1567
1604
  console.log(` Run ${cyan("context-vault reindex")} to update.`);
1568
1605
  }
1606
+
1607
+ const logCount = errorLogCount(config.dataDir);
1608
+ if (logCount > 0) {
1609
+ const logPath = errorLogPath(config.dataDir);
1610
+ console.log();
1611
+ console.log(
1612
+ yellow(
1613
+ ` ${logCount} startup error${logCount === 1 ? "" : "s"} logged — run ${cyan("context-vault doctor")} for details`,
1614
+ ),
1615
+ );
1616
+ console.log(` ${dim(logPath)}`);
1617
+ }
1569
1618
  console.log();
1570
1619
  }
1571
1620
 
@@ -2110,16 +2159,25 @@ async function runRecall() {
2110
2159
  const results = await hybridSearch(ctx, query, { limit: 5 });
2111
2160
  if (!results.length) return;
2112
2161
 
2113
- const lines = ["## Context Vault\n"];
2162
+ const MAX_TOTAL = 2000;
2163
+ const ENTRY_BODY_LIMIT = 400;
2164
+ const entries = [];
2165
+ let totalChars = 0;
2166
+
2114
2167
  for (const r of results) {
2115
2168
  const entryTags = r.tags ? JSON.parse(r.tags) : [];
2116
- lines.push(`### ${r.title || "(untitled)"} [${r.kind}]`);
2117
- if (entryTags.length) lines.push(`tags: ${entryTags.join(", ")}`);
2118
- lines.push(r.body?.slice(0, 400) + (r.body?.length > 400 ? "..." : ""));
2119
- lines.push("");
2169
+ const tagsAttr = entryTags.length ? ` tags="${entryTags.join(",")}"` : "";
2170
+ const body = r.body?.slice(0, ENTRY_BODY_LIMIT) ?? "";
2171
+ const entry = `<entry kind="${r.kind || "knowledge"}"${tagsAttr}>\n${body}\n</entry>`;
2172
+ if (totalChars + entry.length > MAX_TOTAL) break;
2173
+ entries.push(entry);
2174
+ totalChars += entry.length;
2120
2175
  }
2121
2176
 
2122
- process.stdout.write(lines.join("\n"));
2177
+ if (!entries.length) return;
2178
+
2179
+ const block = `<context-vault>\n${entries.join("\n")}\n</context-vault>\n`;
2180
+ process.stdout.write(block);
2123
2181
  } catch {
2124
2182
  // fail silently — never interrupt the user's workflow
2125
2183
  } finally {
@@ -2129,6 +2187,69 @@ async function runRecall() {
2129
2187
  }
2130
2188
  }
2131
2189
 
2190
+ async function runFlush() {
2191
+ const { resolveConfig } = await import("@context-vault/core/core/config");
2192
+ const { initDatabase } = await import("@context-vault/core/index/db");
2193
+
2194
+ let db;
2195
+ try {
2196
+ const config = resolveConfig();
2197
+ db = await initDatabase(config.dbPath);
2198
+
2199
+ const { c: entryCount } = db
2200
+ .prepare("SELECT COUNT(*) as c FROM vault")
2201
+ .get();
2202
+
2203
+ const lastSaveRow = db
2204
+ .prepare("SELECT MAX(COALESCE(updated_at, created_at)) as ts FROM vault")
2205
+ .get();
2206
+ const lastSave = lastSaveRow?.ts ?? "n/a";
2207
+
2208
+ console.log(
2209
+ `context-vault ok — ${entryCount} ${entryCount === 1 ? "entry" : "entries"}, last save: ${lastSave}`,
2210
+ );
2211
+ } catch (e) {
2212
+ console.error(red(`context-vault flush failed: ${e.message}`));
2213
+ process.exit(1);
2214
+ } finally {
2215
+ try {
2216
+ db?.close();
2217
+ } catch {}
2218
+ }
2219
+ }
2220
+
2221
+ /**
2222
+ * Copies all skills from the bundled assets/skills/ directory into ~/.claude/skills/.
2223
+ * Returns an array of installed skill names.
2224
+ */
2225
+ function installSkills() {
2226
+ const assetsSkillsDir = join(ROOT, "assets", "skills");
2227
+ const targetDir = join(HOME, ".claude", "skills");
2228
+
2229
+ if (!existsSync(assetsSkillsDir)) return [];
2230
+
2231
+ const skillNames = readdirSync(assetsSkillsDir).filter((name) => {
2232
+ try {
2233
+ return statSync(join(assetsSkillsDir, name)).isDirectory();
2234
+ } catch {
2235
+ return false;
2236
+ }
2237
+ });
2238
+
2239
+ const installed = [];
2240
+ for (const skillName of skillNames) {
2241
+ const srcDir = join(assetsSkillsDir, skillName);
2242
+ const destDir = join(targetDir, skillName);
2243
+ mkdirSync(destDir, { recursive: true });
2244
+ const files = readdirSync(srcDir);
2245
+ for (const file of files) {
2246
+ copyFileSync(join(srcDir, file), join(destDir, file));
2247
+ }
2248
+ installed.push(skillName);
2249
+ }
2250
+ return installed;
2251
+ }
2252
+
2132
2253
  /** Returns the path to Claude Code's global settings.json */
2133
2254
  function claudeSettingsPath() {
2134
2255
  return join(HOME, ".claude", "settings.json");
@@ -2176,6 +2297,76 @@ function installClaudeHook() {
2176
2297
  return true;
2177
2298
  }
2178
2299
 
2300
+ /**
2301
+ * Writes a SessionEnd hook entry for context-vault flush to ~/.claude/settings.json.
2302
+ * Returns true if installed, false if already present.
2303
+ */
2304
+ function installSessionEndHook() {
2305
+ const settingsPath = claudeSettingsPath();
2306
+ let settings = {};
2307
+
2308
+ if (existsSync(settingsPath)) {
2309
+ const raw = readFileSync(settingsPath, "utf-8");
2310
+ try {
2311
+ settings = JSON.parse(raw);
2312
+ } catch {
2313
+ const bak = settingsPath + ".bak";
2314
+ copyFileSync(settingsPath, bak);
2315
+ console.log(yellow(` Backed up corrupted settings to ${bak}`));
2316
+ }
2317
+ }
2318
+
2319
+ if (!settings.hooks) settings.hooks = {};
2320
+ if (!settings.hooks.SessionEnd) settings.hooks.SessionEnd = [];
2321
+
2322
+ const alreadyInstalled = settings.hooks.SessionEnd.some((h) =>
2323
+ h.hooks?.some((hh) => hh.command?.includes("context-vault flush")),
2324
+ );
2325
+ if (alreadyInstalled) return false;
2326
+
2327
+ settings.hooks.SessionEnd.push({
2328
+ hooks: [
2329
+ {
2330
+ type: "command",
2331
+ command: "npx context-vault flush",
2332
+ timeout: 10,
2333
+ },
2334
+ ],
2335
+ });
2336
+
2337
+ mkdirSync(dirname(settingsPath), { recursive: true });
2338
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
2339
+ return true;
2340
+ }
2341
+
2342
+ /**
2343
+ * Removes the context-vault flush SessionEnd hook from ~/.claude/settings.json.
2344
+ * Returns true if removed, false if not found.
2345
+ */
2346
+ function removeSessionEndHook() {
2347
+ const settingsPath = claudeSettingsPath();
2348
+ if (!existsSync(settingsPath)) return false;
2349
+
2350
+ let settings;
2351
+ try {
2352
+ settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
2353
+ } catch {
2354
+ return false;
2355
+ }
2356
+
2357
+ if (!settings.hooks?.SessionEnd) return false;
2358
+
2359
+ const before = settings.hooks.SessionEnd.length;
2360
+ settings.hooks.SessionEnd = settings.hooks.SessionEnd.filter(
2361
+ (h) => !h.hooks?.some((hh) => hh.command?.includes("context-vault flush")),
2362
+ );
2363
+
2364
+ if (settings.hooks.SessionEnd.length === before) return false;
2365
+
2366
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
2367
+ return true;
2368
+ }
2369
+
2179
2370
  /**
2180
2371
  * Removes the context-vault recall hook from ~/.claude/settings.json.
2181
2372
  * Returns true if removed, false if not found.
@@ -2204,62 +2395,371 @@ function removeClaudeHook() {
2204
2395
  return true;
2205
2396
  }
2206
2397
 
2207
- async function runHooks() {
2398
+ async function runSkills() {
2208
2399
  const sub = args[1];
2209
2400
 
2210
2401
  if (sub === "install") {
2402
+ console.log();
2211
2403
  try {
2212
- const installed = installClaudeHook();
2213
- if (installed) {
2214
- console.log(`\n ${green("")} Claude Code memory hook installed.\n`);
2215
- console.log(
2216
- dim(
2217
- " On every prompt, context-vault searches your vault for relevant entries",
2218
- ),
2219
- );
2220
- console.log(
2221
- dim(
2222
- " and injects them as additional context alongside Claude's native memory.",
2223
- ),
2224
- );
2225
- console.log(
2226
- dim(`\n To remove: ${cyan("context-vault hooks remove")}`),
2227
- );
2404
+ const names = installSkills();
2405
+ if (names.length === 0) {
2406
+ console.log(` ${yellow("!")} No bundled skills found.\n`);
2228
2407
  } else {
2229
- console.log(`\n ${yellow("!")} Hook already installed.\n`);
2408
+ for (const name of names) {
2409
+ console.log(
2410
+ ` ${green("+")} ${name} — installed to ~/.claude/skills/${name}/`,
2411
+ );
2412
+ }
2413
+ console.log();
2414
+ console.log(dim(" Skills are active immediately in Claude Code."));
2415
+ console.log(dim(` Trigger with: /${names.join(", /")}`));
2230
2416
  }
2231
2417
  } catch (e) {
2232
- console.error(`\n ${red("x")} Failed to install hook: ${e.message}\n`);
2418
+ console.error(` ${red("x")} Skills install failed: ${e.message}\n`);
2233
2419
  process.exit(1);
2234
2420
  }
2235
2421
  console.log();
2236
- } else if (sub === "remove") {
2422
+ } else {
2423
+ console.log(`
2424
+ ${bold("context-vault skills")} <install>
2425
+
2426
+ Manage bundled Claude Code skills.
2427
+
2428
+ ${bold("Commands:")}
2429
+ ${cyan("skills install")} Copy bundled skills into ~/.claude/skills/
2430
+
2431
+ ${bold("Bundled skills:")}
2432
+ ${cyan("compile-context")} Compile vault entries into a project brief using create_snapshot
2433
+ `);
2434
+ }
2435
+ }
2436
+
2437
+ async function runHooksInstall() {
2438
+ try {
2439
+ const installed = installClaudeHook();
2440
+ if (installed) {
2441
+ console.log(
2442
+ `\n ${green("✓")} Hook installed. Context vault will inject relevant entries on every prompt.\n`,
2443
+ );
2444
+ console.log(
2445
+ dim(
2446
+ " On every prompt, context-vault searches your vault for relevant entries",
2447
+ ),
2448
+ );
2449
+ console.log(
2450
+ dim(
2451
+ " and injects them as a <context-vault> block before Claude sees your message.",
2452
+ ),
2453
+ );
2454
+ console.log(
2455
+ dim(`\n To remove: ${cyan("context-vault hooks uninstall")}`),
2456
+ );
2457
+ } else {
2458
+ console.log(`\n ${yellow("!")} Hook already installed.\n`);
2459
+ }
2460
+ } catch (e) {
2461
+ console.error(`\n ${red("x")} Failed to install hook: ${e.message}\n`);
2462
+ process.exit(1);
2463
+ }
2464
+ console.log();
2465
+
2466
+ const installFlush =
2467
+ flags.has("--flush") ||
2468
+ (await prompt(
2469
+ " Install SessionEnd flush hook? (saves vault health summary at session end) (y/N):",
2470
+ "n",
2471
+ ));
2472
+ const shouldInstallFlush =
2473
+ installFlush === true ||
2474
+ (typeof installFlush === "string" &&
2475
+ installFlush.toLowerCase().startsWith("y"));
2476
+
2477
+ if (shouldInstallFlush) {
2237
2478
  try {
2238
- const removed = removeClaudeHook();
2239
- if (removed) {
2240
- console.log(`\n ${green("✓")} Claude Code memory hook removed.\n`);
2479
+ const flushInstalled = installSessionEndHook();
2480
+ if (flushInstalled) {
2481
+ console.log(`\n ${green("✓")} SessionEnd flush hook installed.\n`);
2482
+ console.log(
2483
+ dim(
2484
+ " At the end of each session, context-vault flush confirms the vault is healthy.",
2485
+ ),
2486
+ );
2241
2487
  } else {
2242
- console.log(`\n ${yellow("!")} Hook not found — nothing to remove.\n`);
2488
+ console.log(
2489
+ `\n ${yellow("!")} SessionEnd flush hook already installed.\n`,
2490
+ );
2243
2491
  }
2244
2492
  } catch (e) {
2245
- console.error(`\n ${red("x")} Failed to remove hook: ${e.message}\n`);
2493
+ console.error(
2494
+ `\n ${red("x")} Failed to install session flush hook: ${e.message}\n`,
2495
+ );
2246
2496
  process.exit(1);
2247
2497
  }
2498
+ console.log();
2499
+ }
2500
+ }
2501
+
2502
+ async function runHooksUninstall() {
2503
+ try {
2504
+ const removed = removeClaudeHook();
2505
+ if (removed) {
2506
+ console.log(`\n ${green("✓")} Claude Code memory hook removed.\n`);
2507
+ } else {
2508
+ console.log(`\n ${yellow("!")} Hook not found — nothing to remove.\n`);
2509
+ }
2510
+ } catch (e) {
2511
+ console.error(`\n ${red("x")} Failed to remove hook: ${e.message}\n`);
2512
+ process.exit(1);
2513
+ }
2514
+
2515
+ try {
2516
+ const flushRemoved = removeSessionEndHook();
2517
+ if (flushRemoved) {
2518
+ console.log(`\n ${green("✓")} SessionEnd flush hook removed.\n`);
2519
+ }
2520
+ } catch (e) {
2521
+ console.error(
2522
+ `\n ${red("x")} Failed to remove session flush hook: ${e.message}\n`,
2523
+ );
2524
+ }
2525
+ }
2526
+
2527
+ async function runHooks() {
2528
+ const sub = args[1];
2529
+
2530
+ if (sub === "install") {
2531
+ await runHooksInstall();
2532
+ } else if (sub === "remove" || sub === "uninstall") {
2533
+ await runHooksUninstall();
2248
2534
  } else {
2249
2535
  console.log(`
2250
- ${bold("context-vault hooks")} <install|remove>
2536
+ ${bold("context-vault hooks")} <install|uninstall>
2251
2537
 
2252
2538
  Manage the Claude Code memory hook integration.
2253
2539
  When installed, context-vault automatically searches your vault on every user
2254
- prompt and injects relevant entries as additional context.
2540
+ prompt and injects relevant entries as a <context-vault> XML block.
2255
2541
 
2256
2542
  ${bold("Commands:")}
2257
- ${cyan("hooks install")} Write UserPromptSubmit hook to ~/.claude/settings.json
2258
- ${cyan("hooks remove")} Remove the hook from ~/.claude/settings.json
2543
+ ${cyan("hooks install")} Write UserPromptSubmit hook to ~/.claude/settings.json
2544
+ Also prompts to install a SessionEnd flush hook
2545
+ ${cyan("hooks uninstall")} Remove the recall hook and SessionEnd flush hook
2259
2546
  `);
2260
2547
  }
2261
2548
  }
2262
2549
 
2550
+ async function runClaude() {
2551
+ const sub = args[1];
2552
+
2553
+ if (sub === "install") {
2554
+ await runHooksInstall();
2555
+ } else if (sub === "uninstall" || sub === "remove") {
2556
+ await runHooksUninstall();
2557
+ } else {
2558
+ console.log(`
2559
+ ${bold("context-vault claude")} <install|uninstall>
2560
+
2561
+ Manage the Claude Code memory hook integration.
2562
+ Alias for ${cyan("context-vault hooks install|uninstall")}.
2563
+
2564
+ ${bold("Commands:")}
2565
+ ${cyan("claude install")} Write UserPromptSubmit hook to ~/.claude/settings.json
2566
+ ${cyan("claude uninstall")} Remove the recall hook and SessionEnd flush hook
2567
+ `);
2568
+ }
2569
+ }
2570
+
2571
+ async function runDoctor() {
2572
+ const { resolveConfig } = await import("@context-vault/core/core/config");
2573
+ const { errorLogPath, errorLogCount } =
2574
+ await import("@context-vault/core/core/error-log");
2575
+
2576
+ console.log();
2577
+ console.log(` ${bold("◇ context-vault doctor")} ${dim(`v${VERSION}`)}`);
2578
+ console.log();
2579
+
2580
+ let allOk = true;
2581
+
2582
+ // ── Node.js version ──────────────────────────────────────────────────────
2583
+ const nodeMajor = parseInt(process.versions.node.split(".")[0], 10);
2584
+ if (nodeMajor < 20) {
2585
+ console.log(
2586
+ ` ${red("✘")} Node.js ${process.versions.node} — requires >= 20`,
2587
+ );
2588
+ console.log(
2589
+ ` ${dim("Fix: install a newer Node.js from https://nodejs.org/")}`,
2590
+ );
2591
+ allOk = false;
2592
+ } else {
2593
+ console.log(
2594
+ ` ${green("✓")} Node.js ${process.versions.node} ${dim(`(${process.execPath})`)}`,
2595
+ );
2596
+ }
2597
+
2598
+ // ── Config ───────────────────────────────────────────────────────────────
2599
+ let config;
2600
+ try {
2601
+ config = resolveConfig();
2602
+ const configExists = existsSync(config.configPath);
2603
+ console.log(
2604
+ ` ${green("✓")} Config ${dim(`(${configExists ? "exists" : "using defaults"}: ${config.configPath})`)}`,
2605
+ );
2606
+ } catch (e) {
2607
+ console.log(` ${red("✘")} Config parse error: ${e.message}`);
2608
+ console.log(
2609
+ ` ${dim(`Fix: delete or repair ${join(HOME, ".context-mcp", "config.json")}`)}`,
2610
+ );
2611
+ allOk = false;
2612
+ }
2613
+
2614
+ if (config) {
2615
+ // ── Data dir ───────────────────────────────────────────────────────────
2616
+ if (existsSync(config.dataDir)) {
2617
+ console.log(` ${green("✓")} Data dir ${dim(config.dataDir)}`);
2618
+ } else {
2619
+ console.log(
2620
+ ` ${yellow("!")} Data dir missing — will be created on next start`,
2621
+ );
2622
+ console.log(` ${dim(`mkdir -p "${config.dataDir}"`)}`);
2623
+ }
2624
+
2625
+ // ── Vault dir ─────────────────────────────────────────────────────────
2626
+ if (existsSync(config.vaultDir)) {
2627
+ try {
2628
+ const probe = join(config.vaultDir, ".write-probe");
2629
+ writeFileSync(probe, "");
2630
+ unlinkSync(probe);
2631
+ console.log(` ${green("✓")} Vault dir ${dim(config.vaultDir)}`);
2632
+ } catch {
2633
+ console.log(` ${red("✘")} Vault dir not writable: ${config.vaultDir}`);
2634
+ console.log(` ${dim(`Fix: chmod u+w "${config.vaultDir}"`)}`);
2635
+ allOk = false;
2636
+ }
2637
+ } else {
2638
+ console.log(
2639
+ ` ${yellow("!")} Vault dir missing — will be created on next start`,
2640
+ );
2641
+ console.log(` ${dim(`mkdir -p "${config.vaultDir}"`)}`);
2642
+ }
2643
+
2644
+ // ── Database ──────────────────────────────────────────────────────────
2645
+ if (existsSync(config.dbPath)) {
2646
+ try {
2647
+ const { initDatabase } = await import("@context-vault/core/index/db");
2648
+ const db = await initDatabase(config.dbPath);
2649
+ db.close();
2650
+ console.log(` ${green("✓")} Database ${dim(config.dbPath)}`);
2651
+ } catch (e) {
2652
+ console.log(` ${red("✘")} Database error: ${e.message}`);
2653
+ console.log(
2654
+ ` ${dim(`Fix: rm "${config.dbPath}" (data will be lost)`)}`,
2655
+ );
2656
+ allOk = false;
2657
+ }
2658
+ } else {
2659
+ console.log(
2660
+ ` ${yellow("!")} Database missing — will be created on next start`,
2661
+ );
2662
+ }
2663
+
2664
+ // ── Launcher (server.mjs) ─────────────────────────────────────────────
2665
+ const launcherPath = join(HOME, ".context-mcp", "server.mjs");
2666
+ if (existsSync(launcherPath)) {
2667
+ const launcherContent = readFileSync(launcherPath, "utf-8");
2668
+ const match = launcherContent.match(/import "(.+?)"/);
2669
+ if (match) {
2670
+ const serverEntryPath = match[1];
2671
+ if (existsSync(serverEntryPath)) {
2672
+ console.log(
2673
+ ` ${green("✓")} Launcher ${dim(`→ ${serverEntryPath}`)}`,
2674
+ );
2675
+ } else {
2676
+ console.log(
2677
+ ` ${red("✘")} Launcher points to missing server: ${serverEntryPath}`,
2678
+ );
2679
+ console.log(
2680
+ ` ${dim("Fix: run context-vault setup to reinstall")}`,
2681
+ );
2682
+ allOk = false;
2683
+ }
2684
+ } else {
2685
+ console.log(` ${green("✓")} Launcher exists ${dim(launcherPath)}`);
2686
+ }
2687
+ } else {
2688
+ console.log(` ${yellow("!")} Launcher not found at ${launcherPath}`);
2689
+ console.log(` ${dim("Fix: run context-vault setup")}`);
2690
+ allOk = false;
2691
+ }
2692
+
2693
+ // ── Error log ─────────────────────────────────────────────────────────
2694
+ const logPath = errorLogPath(config.dataDir);
2695
+ const logCount = errorLogCount(config.dataDir);
2696
+ if (logCount > 0) {
2697
+ console.log();
2698
+ console.log(
2699
+ ` ${yellow("!")} Error log has ${logCount} entr${logCount === 1 ? "y" : "ies"}: ${dim(logPath)}`,
2700
+ );
2701
+ try {
2702
+ const lines = readFileSync(logPath, "utf-8")
2703
+ .split("\n")
2704
+ .filter((l) => l.trim());
2705
+ const last = JSON.parse(lines[lines.length - 1]);
2706
+ console.log(` Last error: ${red(last.message)}`);
2707
+ console.log(
2708
+ ` Phase: ${dim(last.phase || "unknown")} Time: ${dim(last.timestamp)}`,
2709
+ );
2710
+ } catch {}
2711
+ console.log(` ${dim(`To clear: rm "${logPath}"`)}`);
2712
+ allOk = false;
2713
+ } else {
2714
+ console.log(` ${green("✓")} No startup errors logged`);
2715
+ }
2716
+ }
2717
+
2718
+ // ── MCP tool configs ──────────────────────────────────────────────────────
2719
+ console.log();
2720
+ console.log(bold(" Tool Configurations"));
2721
+ const claudeConfigPath = join(HOME, ".claude.json");
2722
+ if (existsSync(claudeConfigPath)) {
2723
+ try {
2724
+ const claudeConfig = JSON.parse(readFileSync(claudeConfigPath, "utf-8"));
2725
+ const servers = claudeConfig?.mcpServers || {};
2726
+ if (servers["context-vault"]) {
2727
+ const srv = servers["context-vault"];
2728
+ const cmd = [srv.command, ...(srv.args || [])].join(" ");
2729
+ console.log(` ${green("+")} Claude Code: ${dim(cmd)}`);
2730
+ } else {
2731
+ console.log(` ${dim("-")} Claude Code: context-vault not configured`);
2732
+ console.log(` ${dim("Fix: run context-vault setup")}`);
2733
+ }
2734
+ } catch {
2735
+ console.log(
2736
+ ` ${yellow("!")} Claude Code: could not read ~/.claude.json`,
2737
+ );
2738
+ }
2739
+ } else {
2740
+ console.log(` ${dim("-")} Claude Code: ~/.claude.json not found`);
2741
+ }
2742
+
2743
+ // ── Summary ───────────────────────────────────────────────────────────────
2744
+ console.log();
2745
+ if (allOk) {
2746
+ console.log(
2747
+ ` ${green("✓ All checks passed.")} If the MCP server still fails, try:`,
2748
+ );
2749
+ console.log(
2750
+ ` ${dim("context-vault setup")} — reconfigure tool integrations`,
2751
+ );
2752
+ } else {
2753
+ console.log(
2754
+ ` ${yellow("Some issues found.")} Address the items above, then restart your AI tool.`,
2755
+ );
2756
+ console.log(
2757
+ ` ${dim("context-vault setup")} — reconfigure and repair installation`,
2758
+ );
2759
+ }
2760
+ console.log();
2761
+ }
2762
+
2263
2763
  async function runServe() {
2264
2764
  await import("../src/server/index.js");
2265
2765
  }
@@ -2291,6 +2791,15 @@ async function main() {
2291
2791
  case "hooks":
2292
2792
  await runHooks();
2293
2793
  break;
2794
+ case "claude":
2795
+ await runClaude();
2796
+ break;
2797
+ case "skills":
2798
+ await runSkills();
2799
+ break;
2800
+ case "flush":
2801
+ await runFlush();
2802
+ break;
2294
2803
  case "recall":
2295
2804
  await runRecall();
2296
2805
  break;
@@ -2321,6 +2830,9 @@ async function main() {
2321
2830
  case "migrate":
2322
2831
  await runMigrate();
2323
2832
  break;
2833
+ case "doctor":
2834
+ await runDoctor();
2835
+ break;
2324
2836
  default:
2325
2837
  console.error(red(`Unknown command: ${command}`));
2326
2838
  console.error(`Run ${cyan("context-vault --help")} for usage.`);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@context-vault/core",
3
- "version": "2.12.0",
3
+ "version": "2.14.0",
4
4
  "type": "module",
5
5
  "description": "Shared core: capture, index, retrieve, tools, and utilities for context-vault",
6
6
  "main": "src/index.js",
@@ -0,0 +1,47 @@
1
+ import { z } from "zod";
2
+ import { ok } from "../helpers.js";
3
+
4
+ export const name = "clear_context";
5
+
6
+ export const description =
7
+ "Reset active in-memory session context without deleting vault entries. Call this when switching projects or topics mid-session. With `scope`, all subsequent get_context calls should filter to that tag/project. Vault data is never modified.";
8
+
9
+ export const inputSchema = {
10
+ scope: z
11
+ .string()
12
+ .optional()
13
+ .describe(
14
+ "Optional tag or project name to focus on going forward. When provided, treat subsequent get_context calls as if filtered to this tag.",
15
+ ),
16
+ };
17
+
18
+ /**
19
+ * @param {object} args
20
+ * @param {import('../types.js').BaseCtx & Partial<import('../types.js').HostedCtxExtensions>} _ctx
21
+ */
22
+ export function handler({ scope } = {}) {
23
+ const lines = [
24
+ "## Context Reset",
25
+ "",
26
+ "Active session context has been cleared. All previous context from this session should be disregarded.",
27
+ "",
28
+ "Vault entries are unchanged — no data was deleted.",
29
+ ];
30
+
31
+ if (scope?.trim()) {
32
+ const trimmed = scope.trim();
33
+ lines.push(
34
+ "",
35
+ `### Active Scope: \`${trimmed}\``,
36
+ "",
37
+ `Going forward, treat \`get_context\` calls as scoped to the tag or project **"${trimmed}"** unless the user explicitly requests a different scope or passes their own tag filters.`,
38
+ );
39
+ } else {
40
+ lines.push(
41
+ "",
42
+ "No scope set. Use `get_context` normally — all vault entries are accessible.",
43
+ );
44
+ }
45
+
46
+ return ok(lines.join("\n"));
47
+ }
@@ -0,0 +1,222 @@
1
+ import { z } from "zod";
2
+ import { hybridSearch } from "../../retrieve/index.js";
3
+ import { captureAndIndex } from "../../capture/index.js";
4
+ import { normalizeKind } from "../../core/files.js";
5
+ import { ok, err, ensureVaultExists } from "../helpers.js";
6
+
7
+ const NOISE_KINDS = new Set(["prompt-history", "task-notification"]);
8
+ const SYNTHESIS_MODEL = "claude-haiku-4-5-20251001";
9
+ const MAX_ENTRIES_FOR_SYNTHESIS = 40;
10
+ const MAX_BODY_PER_ENTRY = 600;
11
+
12
+ export const name = "create_snapshot";
13
+
14
+ export const description =
15
+ "Pull all relevant vault entries matching a topic, run an LLM synthesis pass to deduplicate and structure them into a context brief, then save and return the brief's ULID. The brief is saved as kind: 'brief' with a deterministic identity_key for retrieval.";
16
+
17
+ export const inputSchema = {
18
+ topic: z.string().describe("The topic or project name to snapshot"),
19
+ tags: z
20
+ .array(z.string())
21
+ .optional()
22
+ .describe("Optional tag filters — entries must match at least one"),
23
+ kinds: z
24
+ .array(z.string())
25
+ .optional()
26
+ .describe("Optional kind filters to restrict which entry types are pulled"),
27
+ identity_key: z
28
+ .string()
29
+ .optional()
30
+ .describe(
31
+ "Deterministic key for the saved brief (defaults to slugified topic). Use the same key to overwrite a previous snapshot.",
32
+ ),
33
+ };
34
+
35
+ function buildSynthesisPrompt(topic, entries) {
36
+ const entriesBlock = entries
37
+ .map((e, i) => {
38
+ const tags = e.tags ? JSON.parse(e.tags) : [];
39
+ const tagStr = tags.length ? tags.join(", ") : "none";
40
+ const body = e.body
41
+ ? e.body.slice(0, MAX_BODY_PER_ENTRY) +
42
+ (e.body.length > MAX_BODY_PER_ENTRY ? "…" : "")
43
+ : "(no body)";
44
+ return [
45
+ `### Entry ${i + 1} [${e.kind}] id: ${e.id}`,
46
+ `tags: ${tagStr}`,
47
+ `updated: ${e.updated_at || e.created_at || "unknown"}`,
48
+ body,
49
+ ].join("\n");
50
+ })
51
+ .join("\n\n");
52
+
53
+ return `You are a knowledge synthesis assistant. Given the following vault entries about "${topic}", produce a structured context brief.
54
+
55
+ Deduplicate overlapping information, resolve any contradictions (note them in Audit Notes), and organise the content into the sections below. Keep each section concise and actionable. Omit sections that have no relevant content.
56
+
57
+ Output ONLY the markdown document — no preamble, no explanation.
58
+
59
+ Required format:
60
+ # ${topic} — Context Brief
61
+ ## Status
62
+ (current state of the topic)
63
+ ## Key Decisions
64
+ (architectural or strategic decisions made)
65
+ ## Patterns & Conventions
66
+ (recurring patterns, coding conventions, standards)
67
+ ## Active Constraints
68
+ (known limitations, hard requirements, deadlines)
69
+ ## Open Questions
70
+ (unresolved questions or areas needing investigation)
71
+ ## Audit Notes
72
+ (contradictions detected, stale entries flagged with their ids)
73
+
74
+ ---
75
+ VAULT ENTRIES:
76
+
77
+ ${entriesBlock}`;
78
+ }
79
+
80
+ async function callLlm(prompt) {
81
+ const { Anthropic } = await import("@anthropic-ai/sdk");
82
+ const client = new Anthropic();
83
+ const message = await client.messages.create({
84
+ model: SYNTHESIS_MODEL,
85
+ max_tokens: 2048,
86
+ messages: [{ role: "user", content: prompt }],
87
+ });
88
+ const block = message.content.find((b) => b.type === "text");
89
+ if (!block) throw new Error("LLM returned no text content");
90
+ return block.text;
91
+ }
92
+
93
+ function slugifyTopic(topic) {
94
+ return topic
95
+ .toLowerCase()
96
+ .replace(/[^a-z0-9]+/g, "-")
97
+ .replace(/^-+|-+$/g, "")
98
+ .slice(0, 120);
99
+ }
100
+
101
+ export async function handler(
102
+ { topic, tags, kinds, identity_key },
103
+ ctx,
104
+ { ensureIndexed },
105
+ ) {
106
+ const { config } = ctx;
107
+ const userId = ctx.userId !== undefined ? ctx.userId : undefined;
108
+
109
+ const vaultErr = ensureVaultExists(config);
110
+ if (vaultErr) return vaultErr;
111
+
112
+ if (!topic?.trim()) {
113
+ return err("Required: topic (non-empty string)", "INVALID_INPUT");
114
+ }
115
+
116
+ await ensureIndexed();
117
+
118
+ const normalizedKinds = kinds?.map(normalizeKind) ?? [];
119
+
120
+ let candidates = [];
121
+
122
+ if (normalizedKinds.length > 0) {
123
+ for (const kindFilter of normalizedKinds) {
124
+ const rows = await hybridSearch(ctx, topic, {
125
+ kindFilter,
126
+ limit: Math.ceil(MAX_ENTRIES_FOR_SYNTHESIS / normalizedKinds.length),
127
+ userIdFilter: userId,
128
+ includeSuperseeded: false,
129
+ });
130
+ candidates.push(...rows);
131
+ }
132
+ const seen = new Set();
133
+ candidates = candidates.filter((r) => {
134
+ if (seen.has(r.id)) return false;
135
+ seen.add(r.id);
136
+ return true;
137
+ });
138
+ } else {
139
+ candidates = await hybridSearch(ctx, topic, {
140
+ limit: MAX_ENTRIES_FOR_SYNTHESIS,
141
+ userIdFilter: userId,
142
+ includeSuperseeded: false,
143
+ });
144
+ }
145
+
146
+ if (tags?.length) {
147
+ candidates = candidates.filter((r) => {
148
+ const entryTags = r.tags ? JSON.parse(r.tags) : [];
149
+ return tags.some((t) => entryTags.includes(t));
150
+ });
151
+ }
152
+
153
+ const noiseIds = candidates
154
+ .filter((r) => NOISE_KINDS.has(r.kind))
155
+ .map((r) => r.id);
156
+
157
+ const synthesisEntries = candidates.filter((r) => !NOISE_KINDS.has(r.kind));
158
+
159
+ if (synthesisEntries.length === 0) {
160
+ return err(
161
+ `No entries found for topic "${topic}" to synthesize. Try a broader topic or different tags.`,
162
+ "NO_ENTRIES",
163
+ );
164
+ }
165
+
166
+ let briefBody;
167
+ try {
168
+ const prompt = buildSynthesisPrompt(topic, synthesisEntries);
169
+ briefBody = await callLlm(prompt);
170
+ } catch (e) {
171
+ return err(
172
+ `LLM synthesis failed: ${e.message}. Ensure ANTHROPIC_API_KEY is set.`,
173
+ "LLM_ERROR",
174
+ );
175
+ }
176
+
177
+ const effectiveIdentityKey =
178
+ identity_key ?? `snapshot-${slugifyTopic(topic)}`;
179
+
180
+ const briefTags = [
181
+ "snapshot",
182
+ ...(tags ?? []),
183
+ ...(normalizedKinds.length > 0 ? [] : []),
184
+ ];
185
+
186
+ const supersedes = noiseIds.length > 0 ? noiseIds : undefined;
187
+
188
+ const entry = await captureAndIndex(ctx, {
189
+ kind: "brief",
190
+ title: `${topic} — Context Brief`,
191
+ body: briefBody,
192
+ tags: briefTags,
193
+ source: "create_snapshot",
194
+ identity_key: effectiveIdentityKey,
195
+ supersedes,
196
+ userId,
197
+ meta: {
198
+ topic,
199
+ entry_count: synthesisEntries.length,
200
+ noise_superseded: noiseIds.length,
201
+ synthesized_from: synthesisEntries.map((e) => e.id),
202
+ },
203
+ });
204
+
205
+ const parts = [
206
+ `✓ Snapshot created → id: ${entry.id}`,
207
+ ` title: ${entry.title}`,
208
+ ` identity_key: ${effectiveIdentityKey}`,
209
+ ` synthesized from: ${synthesisEntries.length} entries`,
210
+ noiseIds.length > 0
211
+ ? ` noise superseded: ${noiseIds.length} entries`
212
+ : null,
213
+ "",
214
+ "_Retrieve with: get_context(kind: 'brief', identity_key: '" +
215
+ effectiveIdentityKey +
216
+ "')_",
217
+ ]
218
+ .filter((l) => l !== null)
219
+ .join("\n");
220
+
221
+ return ok(parts);
222
+ }
@@ -5,6 +5,87 @@ import { normalizeKind } from "../../core/files.js";
5
5
  import { ok, err } from "../helpers.js";
6
6
  import { isEmbedAvailable } from "../../index/embed.js";
7
7
 
8
+ const STALE_DUPLICATE_DAYS = 7;
9
+
10
+ /**
11
+ * Detect conflicts among a set of search result entries.
12
+ *
13
+ * Two checks are performed:
14
+ * 1. Supersession: if entry A's `superseded_by` points to any entry B in the
15
+ * result set, A is stale and should be discarded in favour of B.
16
+ * 2. Stale duplicate: two entries share the same kind and at least one common
17
+ * tag, but their `updated_at` timestamps differ by more than
18
+ * STALE_DUPLICATE_DAYS days — suggesting the older one may be outdated.
19
+ *
20
+ * No LLM calls, no new dependencies — pure in-memory set operations on the
21
+ * rows already fetched from the DB.
22
+ *
23
+ * @param {Array} entries - Result rows (as returned by hybridSearch / filter-only mode)
24
+ * @param {import('../types.js').BaseCtx} _ctx - Unused for now; reserved for future DB look-ups
25
+ * @returns {Array<{entry_a_id: string, entry_b_id: string, reason: string, recommendation: string}>}
26
+ */
27
+ export function detectConflicts(entries, _ctx) {
28
+ const conflicts = [];
29
+ const idSet = new Set(entries.map((e) => e.id));
30
+
31
+ for (const entry of entries) {
32
+ if (entry.superseded_by && idSet.has(entry.superseded_by)) {
33
+ conflicts.push({
34
+ entry_a_id: entry.id,
35
+ entry_b_id: entry.superseded_by,
36
+ reason: "superseded",
37
+ recommendation: `Discard \`${entry.id}\` — it has been explicitly superseded by \`${entry.superseded_by}\`.`,
38
+ });
39
+ }
40
+ }
41
+
42
+ const supersededConflictPairs = new Set(
43
+ conflicts.map((c) => `${c.entry_a_id}|${c.entry_b_id}`),
44
+ );
45
+
46
+ for (let i = 0; i < entries.length; i++) {
47
+ for (let j = i + 1; j < entries.length; j++) {
48
+ const a = entries[i];
49
+ const b = entries[j];
50
+
51
+ if (
52
+ supersededConflictPairs.has(`${a.id}|${b.id}`) ||
53
+ supersededConflictPairs.has(`${b.id}|${a.id}`)
54
+ ) {
55
+ continue;
56
+ }
57
+
58
+ if (a.kind !== b.kind) continue;
59
+
60
+ const tagsA = a.tags ? JSON.parse(a.tags) : [];
61
+ const tagsB = b.tags ? JSON.parse(b.tags) : [];
62
+
63
+ if (!tagsA.length || !tagsB.length) continue;
64
+
65
+ const tagsSetA = new Set(tagsA);
66
+ const sharedTag = tagsB.some((t) => tagsSetA.has(t));
67
+ if (!sharedTag) continue;
68
+
69
+ const dateA = new Date(a.updated_at || a.created_at);
70
+ const dateB = new Date(b.updated_at || b.created_at);
71
+ if (isNaN(dateA.getTime()) || isNaN(dateB.getTime())) continue;
72
+
73
+ const diffDays = Math.abs(dateA - dateB) / 86400000;
74
+ if (diffDays <= STALE_DUPLICATE_DAYS) continue;
75
+
76
+ const [older, newer] = dateA < dateB ? [a, b] : [b, a];
77
+ conflicts.push({
78
+ entry_a_id: older.id,
79
+ entry_b_id: newer.id,
80
+ reason: "stale_duplicate",
81
+ recommendation: `Verify \`${older.id}\` is still accurate — it shares kind "${older.kind}" and tags with \`${newer.id}\` but was last updated ${Math.round(diffDays)} days earlier.`,
82
+ });
83
+ }
84
+ }
85
+
86
+ return conflicts;
87
+ }
88
+
8
89
  export const name = "get_context";
9
90
 
10
91
  export const description =
@@ -48,6 +129,12 @@ export const inputSchema = {
48
129
  .describe(
49
130
  "If true, include entries that have been superseded by newer ones. Default: false.",
50
131
  ),
132
+ detect_conflicts: z
133
+ .boolean()
134
+ .optional()
135
+ .describe(
136
+ "If true, compare results for contradicting entries and append a conflicts array. Flags superseded entries still in results and stale duplicates (same kind+tags, updated_at >7 days apart). No LLM calls — pure DB logic.",
137
+ ),
51
138
  };
52
139
 
53
140
  /**
@@ -66,6 +153,7 @@ export async function handler(
66
153
  until,
67
154
  limit,
68
155
  include_superseded,
156
+ detect_conflicts,
69
157
  },
70
158
  ctx,
71
159
  { ensureIndexed, reindexFailed },
@@ -227,6 +315,9 @@ export async function handler(
227
315
  }
228
316
  }
229
317
 
318
+ // Conflict detection
319
+ const conflicts = detect_conflicts ? detectConflicts(filtered, ctx) : [];
320
+
230
321
  const lines = [];
231
322
  if (reindexFailed)
232
323
  lines.push(
@@ -265,5 +356,23 @@ export async function handler(
265
356
  lines.push(r.body?.slice(0, 300) + (r.body?.length > 300 ? "..." : ""));
266
357
  lines.push("");
267
358
  }
359
+
360
+ if (detect_conflicts) {
361
+ if (conflicts.length === 0) {
362
+ lines.push(
363
+ `## Conflict Detection\n\nNo conflicts detected among results.\n`,
364
+ );
365
+ } else {
366
+ lines.push(`## Conflict Detection (${conflicts.length} flagged)\n`);
367
+ for (const c of conflicts) {
368
+ lines.push(
369
+ `- **${c.reason}**: \`${c.entry_a_id}\` vs \`${c.entry_b_id}\``,
370
+ );
371
+ lines.push(` Recommendation: ${c.recommendation}`);
372
+ }
373
+ lines.push("");
374
+ }
375
+ }
376
+
268
377
  return ok(lines.join("\n"));
269
378
  }
@@ -11,6 +11,8 @@ import * as deleteContext from "./tools/delete-context.js";
11
11
  import * as submitFeedback from "./tools/submit-feedback.js";
12
12
  import * as ingestUrl from "./tools/ingest-url.js";
13
13
  import * as contextStatus from "./tools/context-status.js";
14
+ import * as clearContext from "./tools/clear-context.js";
15
+ import * as createSnapshot from "./tools/create-snapshot.js";
14
16
 
15
17
  const toolModules = [
16
18
  getContext,
@@ -20,6 +22,8 @@ const toolModules = [
20
22
  submitFeedback,
21
23
  ingestUrl,
22
24
  contextStatus,
25
+ clearContext,
26
+ createSnapshot,
23
27
  ];
24
28
 
25
29
  const TOOL_TIMEOUT_MS = 60_000;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-vault",
3
- "version": "2.12.0",
3
+ "version": "2.14.0",
4
4
  "type": "module",
5
5
  "description": "Persistent memory for AI agents — saves and searches knowledge across sessions",
6
6
  "bin": {
@@ -17,6 +17,7 @@
17
17
  "prepack": "node scripts/prepack.js"
18
18
  },
19
19
  "files": [
20
+ "assets/",
20
21
  "bin/",
21
22
  "src/",
22
23
  "scripts/",
@@ -55,7 +56,7 @@
55
56
  "@context-vault/core"
56
57
  ],
57
58
  "dependencies": {
58
- "@context-vault/core": "^2.12.0",
59
+ "@context-vault/core": "^2.14.0",
59
60
  "@modelcontextprotocol/sdk": "^1.26.0",
60
61
  "sqlite-vec": "^0.1.0"
61
62
  }
@@ -265,6 +265,50 @@ async function main() {
265
265
  }
266
266
  }
267
267
 
268
+ // ─── Top-level Safety Net ────────────────────────────────────────────────────
269
+ // Catch any errors that escape the main() try/catch (e.g. thrown in MCP
270
+ // transport callbacks or in unrelated async chains). Claude Code surfaces
271
+ // stderr when a server exits unexpectedly, so every message written here will
272
+ // be visible to the user.
273
+
274
+ process.on("uncaughtException", (err) => {
275
+ const dataDir = join(homedir(), ".context-mcp");
276
+ const logEntry = {
277
+ timestamp: new Date().toISOString(),
278
+ error_type: "uncaughtException",
279
+ message: err.message,
280
+ stack: err.stack?.split("\n").slice(0, 5).join(" | "),
281
+ node_version: process.version,
282
+ platform: process.platform,
283
+ arch: process.arch,
284
+ cv_version: pkg.version,
285
+ };
286
+ appendErrorLog(dataDir, logEntry);
287
+ console.error(`[context-vault] Uncaught exception: ${err.message}`);
288
+ console.error(`[context-vault] Error log: ${join(dataDir, "error.log")}`);
289
+ console.error(`[context-vault] Run: context-vault doctor`);
290
+ process.exit(1);
291
+ });
292
+
293
+ process.on("unhandledRejection", (reason) => {
294
+ const dataDir = join(homedir(), ".context-mcp");
295
+ const message = reason instanceof Error ? reason.message : String(reason);
296
+ const logEntry = {
297
+ timestamp: new Date().toISOString(),
298
+ error_type: "unhandledRejection",
299
+ message,
300
+ node_version: process.version,
301
+ platform: process.platform,
302
+ arch: process.arch,
303
+ cv_version: pkg.version,
304
+ };
305
+ appendErrorLog(dataDir, logEntry);
306
+ console.error(`[context-vault] Unhandled rejection: ${message}`);
307
+ console.error(`[context-vault] Error log: ${join(dataDir, "error.log")}`);
308
+ console.error(`[context-vault] Run: context-vault doctor`);
309
+ process.exit(1);
310
+ });
311
+
268
312
  main().catch((err) => {
269
313
  console.error(`[context-vault] Unexpected fatal error: ${err.message}`);
270
314
  process.exit(1);