context-vault 2.10.2 → 2.11.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
@@ -234,7 +234,10 @@ ${bold("Commands:")}
234
234
  ${cyan("connect")} --key cv_... Connect AI tools to hosted vault
235
235
  ${cyan("switch")} local|hosted Switch between local and hosted MCP modes
236
236
  ${cyan("serve")} Start the MCP server (used by AI clients)
237
+ ${cyan("hooks")} install|remove Install or remove Claude Code memory hook
238
+ ${cyan("recall")} Search vault from a Claude Code hook (reads stdin)
237
239
  ${cyan("reindex")} Rebuild search index from knowledge files
240
+ ${cyan("prune")} Remove expired entries (use --dry-run to preview)
238
241
  ${cyan("status")} Show vault diagnostics
239
242
  ${cyan("update")} Check for and install updates
240
243
  ${cyan("uninstall")} Remove MCP configs and optionally data
@@ -359,7 +362,7 @@ async function runSetup() {
359
362
  }
360
363
 
361
364
  // Detect tools
362
- console.log(dim(` [1/5]`) + bold(" Detecting tools...\n"));
365
+ console.log(dim(` [1/6]`) + bold(" Detecting tools...\n"));
363
366
  const { detected, results: detectionResults } = await detectAllTools();
364
367
  printDetectionResults(detectionResults);
365
368
  console.log();
@@ -427,7 +430,7 @@ async function runSetup() {
427
430
  }
428
431
 
429
432
  // Vault directory (content files)
430
- console.log(dim(` [2/5]`) + bold(" Configuring vault...\n"));
433
+ console.log(dim(` [2/6]`) + bold(" Configuring vault...\n"));
431
434
  const defaultVaultDir = getFlag("--vault-dir") || join(HOME, "vault");
432
435
  const vaultDir = isNonInteractive
433
436
  ? defaultVaultDir
@@ -484,6 +487,39 @@ async function runSetup() {
484
487
  vaultConfig.dbPath = join(dataDir, "vault.db");
485
488
  vaultConfig.devDir = join(HOME, "dev");
486
489
  vaultConfig.mode = "local";
490
+
491
+ // Telemetry opt-in
492
+ console.log(`\n ${dim("[3/6]")}${bold(" Anonymous error reporting\n")}`);
493
+ console.log(
494
+ dim(
495
+ " When enabled, unhandled errors send a minimal event (type, tool name,",
496
+ ),
497
+ );
498
+ console.log(
499
+ dim(" version, platform) to help diagnose issues. No vault content,"),
500
+ );
501
+ console.log(
502
+ dim(" file paths, or personal data is ever sent. Off by default."),
503
+ );
504
+ console.log(dim(" Full schema: https://contextvault.dev/telemetry"));
505
+ console.log();
506
+
507
+ let telemetryEnabled = vaultConfig.telemetry === true;
508
+ if (!isNonInteractive) {
509
+ const defaultChoice = telemetryEnabled ? "Y" : "n";
510
+ const telemetryAnswer = await prompt(
511
+ ` Enable anonymous error reporting? (y/N):`,
512
+ defaultChoice,
513
+ );
514
+ telemetryEnabled =
515
+ telemetryAnswer.toLowerCase() === "y" ||
516
+ telemetryAnswer.toLowerCase() === "yes";
517
+ }
518
+ vaultConfig.telemetry = telemetryEnabled;
519
+ console.log(
520
+ ` ${telemetryEnabled ? green("+") : dim("-")} Telemetry: ${telemetryEnabled ? "enabled" : "disabled"}`,
521
+ );
522
+
487
523
  writeFileSync(configPath, JSON.stringify(vaultConfig, null, 2) + "\n");
488
524
  console.log(`\n ${green("+")} Wrote ${configPath}`);
489
525
 
@@ -491,7 +527,7 @@ async function runSetup() {
491
527
  const skipEmbeddings = flags.has("--skip-embeddings");
492
528
  if (skipEmbeddings) {
493
529
  console.log(
494
- `\n ${dim("[3/5]")}${bold(" Embedding model")} ${dim("(skipped)")}`,
530
+ `\n ${dim("[4/6]")}${bold(" Embedding model")} ${dim("(skipped)")}`,
495
531
  );
496
532
  console.log(
497
533
  dim(
@@ -503,7 +539,7 @@ async function runSetup() {
503
539
  );
504
540
  } else {
505
541
  console.log(
506
- `\n ${dim("[3/5]")}${bold(" Downloading embedding model...")}`,
542
+ `\n ${dim("[4/6]")}${bold(" Downloading embedding model...")}`,
507
543
  );
508
544
  console.log(dim(" all-MiniLM-L6-v2 (~22MB, one-time download)\n"));
509
545
  {
@@ -592,7 +628,7 @@ async function runSetup() {
592
628
  }
593
629
 
594
630
  // Configure each tool — pass vault dir as arg if non-default
595
- console.log(`\n ${dim("[4/5]")}${bold(" Configuring tools...\n")}`);
631
+ console.log(`\n ${dim("[5/6]")}${bold(" Configuring tools...\n")}`);
596
632
  const results = [];
597
633
  const defaultVDir = join(HOME, "vault");
598
634
  const customVaultDir =
@@ -615,6 +651,47 @@ async function runSetup() {
615
651
  }
616
652
  }
617
653
 
654
+ // Claude Code memory hook (opt-in)
655
+ const claudeConfigured = results.some(
656
+ (r) => r.ok && r.tool.id === "claude-code",
657
+ );
658
+ const hookFlag = flags.has("--hooks");
659
+ if (claudeConfigured) {
660
+ let installHook = hookFlag;
661
+ if (!hookFlag && !isNonInteractive) {
662
+ console.log();
663
+ console.log(dim(" Claude Code detected — install memory hook?"));
664
+ console.log(
665
+ dim(
666
+ " Searches your vault on every prompt and injects relevant entries",
667
+ ),
668
+ );
669
+ console.log(
670
+ dim(" as additional context alongside Claude's native memory."),
671
+ );
672
+ console.log();
673
+ const answer = await prompt(
674
+ " Install Claude Code memory hook? (y/N):",
675
+ "N",
676
+ );
677
+ installHook = answer.toLowerCase() === "y";
678
+ }
679
+ if (installHook) {
680
+ try {
681
+ const installed = installClaudeHook();
682
+ if (installed) {
683
+ console.log(`\n ${green("+")} Memory hook installed`);
684
+ }
685
+ } catch (e) {
686
+ console.log(`\n ${red("x")} Hook install failed: ${e.message}`);
687
+ }
688
+ } else if (!isNonInteractive && !hookFlag) {
689
+ console.log(
690
+ dim(` Skipped — install later: context-vault hooks install`),
691
+ );
692
+ }
693
+ }
694
+
618
695
  // Seed entry
619
696
  const seeded = createSeedEntries(resolvedVaultDir);
620
697
  if (seeded > 0) {
@@ -624,7 +701,7 @@ async function runSetup() {
624
701
  }
625
702
 
626
703
  // Health check
627
- console.log(`\n ${dim("[5/5]")}${bold(" Health check...")}\n`);
704
+ console.log(`\n ${dim("[6/6]")}${bold(" Health check...")}\n`);
628
705
  const okResults = results.filter((r) => r.ok);
629
706
 
630
707
  // Verify DB is accessible
@@ -1308,6 +1385,69 @@ async function runReindex() {
1308
1385
  console.log(` ${dim("·")} ${stats.unchanged} unchanged`);
1309
1386
  }
1310
1387
 
1388
+ async function runPrune() {
1389
+ const dryRun = flags.has("--dry-run");
1390
+
1391
+ const { resolveConfig } = await import("@context-vault/core/core/config");
1392
+ const { initDatabase, prepareStatements, insertVec, deleteVec } =
1393
+ await import("@context-vault/core/index/db");
1394
+ const { pruneExpired } = await import("@context-vault/core/index");
1395
+
1396
+ const config = resolveConfig();
1397
+ if (!config.vaultDirExists) {
1398
+ console.error(red(`Vault directory not found: ${config.vaultDir}`));
1399
+ console.error("Run " + cyan("context-vault setup") + " to configure.");
1400
+ process.exit(1);
1401
+ }
1402
+
1403
+ const db = await initDatabase(config.dbPath);
1404
+
1405
+ if (dryRun) {
1406
+ const expired = db
1407
+ .prepare(
1408
+ "SELECT id, kind, title, expires_at FROM vault WHERE expires_at IS NOT NULL AND expires_at <= datetime('now')",
1409
+ )
1410
+ .all();
1411
+ db.close();
1412
+
1413
+ if (expired.length === 0) {
1414
+ console.log(green(" No expired entries found."));
1415
+ return;
1416
+ }
1417
+
1418
+ console.log(
1419
+ `\n ${bold(String(expired.length))} expired ${expired.length === 1 ? "entry" : "entries"} would be removed:\n`,
1420
+ );
1421
+ for (const e of expired) {
1422
+ const label = e.title ? `${e.kind}: ${e.title}` : `${e.kind} (${e.id})`;
1423
+ console.log(` ${dim("-")} ${label} ${dim(`(expired ${e.expires_at})`)}`);
1424
+ }
1425
+ console.log(dim("\n Dry run — no entries were removed."));
1426
+ return;
1427
+ }
1428
+
1429
+ const stmts = prepareStatements(db);
1430
+ const ctx = {
1431
+ db,
1432
+ config,
1433
+ stmts,
1434
+ embed: async () => null,
1435
+ insertVec: (r, e) => insertVec(stmts, r, e),
1436
+ deleteVec: (r) => deleteVec(stmts, r),
1437
+ };
1438
+
1439
+ const count = await pruneExpired(ctx);
1440
+ db.close();
1441
+
1442
+ if (count === 0) {
1443
+ console.log(green(" No expired entries found."));
1444
+ } else {
1445
+ console.log(
1446
+ green(` ✓ Pruned ${count} expired ${count === 1 ? "entry" : "entries"}`),
1447
+ );
1448
+ }
1449
+ }
1450
+
1311
1451
  async function runStatus() {
1312
1452
  const { resolveConfig } = await import("@context-vault/core/core/config");
1313
1453
  const { initDatabase } = await import("@context-vault/core/index/db");
@@ -1900,6 +2040,196 @@ async function runIngest() {
1900
2040
  console.log();
1901
2041
  }
1902
2042
 
2043
+ async function runRecall() {
2044
+ let query;
2045
+
2046
+ if (!process.stdin.isTTY) {
2047
+ const raw = await new Promise((resolve) => {
2048
+ let data = "";
2049
+ process.stdin.on("data", (chunk) => (data += chunk));
2050
+ process.stdin.on("end", () => resolve(data));
2051
+ });
2052
+ try {
2053
+ const payload = JSON.parse(raw);
2054
+ query = payload.prompt || payload.query || "";
2055
+ } catch {
2056
+ query = args[1] || raw.trim();
2057
+ }
2058
+ } else {
2059
+ query = args.slice(1).join(" ");
2060
+ }
2061
+
2062
+ if (!query?.trim()) return;
2063
+
2064
+ let db;
2065
+ try {
2066
+ const { resolveConfig } = await import("@context-vault/core/core/config");
2067
+ const config = resolveConfig();
2068
+
2069
+ if (!config.vaultDirExists) return;
2070
+
2071
+ const { initDatabase, prepareStatements } =
2072
+ await import("@context-vault/core/index/db");
2073
+ const { embed } = await import("@context-vault/core/index/embed");
2074
+ const { hybridSearch } = await import("@context-vault/core/retrieve/index");
2075
+
2076
+ db = await initDatabase(config.dbPath);
2077
+ const stmts = prepareStatements(db);
2078
+ const ctx = { db, config, stmts, embed };
2079
+
2080
+ const results = await hybridSearch(ctx, query, { limit: 5 });
2081
+ if (!results.length) return;
2082
+
2083
+ const lines = ["## Context Vault\n"];
2084
+ for (const r of results) {
2085
+ const entryTags = r.tags ? JSON.parse(r.tags) : [];
2086
+ lines.push(`### ${r.title || "(untitled)"} [${r.kind}]`);
2087
+ if (entryTags.length) lines.push(`tags: ${entryTags.join(", ")}`);
2088
+ lines.push(r.body?.slice(0, 400) + (r.body?.length > 400 ? "..." : ""));
2089
+ lines.push("");
2090
+ }
2091
+
2092
+ process.stdout.write(lines.join("\n"));
2093
+ } catch {
2094
+ // fail silently — never interrupt the user's workflow
2095
+ } finally {
2096
+ try {
2097
+ db?.close();
2098
+ } catch {}
2099
+ }
2100
+ }
2101
+
2102
+ /** Returns the path to Claude Code's global settings.json */
2103
+ function claudeSettingsPath() {
2104
+ return join(HOME, ".claude", "settings.json");
2105
+ }
2106
+
2107
+ /**
2108
+ * Writes a UserPromptSubmit hook entry for context-vault recall to ~/.claude/settings.json.
2109
+ * Returns true if installed, false if already present.
2110
+ */
2111
+ function installClaudeHook() {
2112
+ const settingsPath = claudeSettingsPath();
2113
+ let settings = {};
2114
+
2115
+ if (existsSync(settingsPath)) {
2116
+ const raw = readFileSync(settingsPath, "utf-8");
2117
+ try {
2118
+ settings = JSON.parse(raw);
2119
+ } catch {
2120
+ const bak = settingsPath + ".bak";
2121
+ copyFileSync(settingsPath, bak);
2122
+ console.log(yellow(` Backed up corrupted settings to ${bak}`));
2123
+ }
2124
+ }
2125
+
2126
+ if (!settings.hooks) settings.hooks = {};
2127
+ if (!settings.hooks.UserPromptSubmit) settings.hooks.UserPromptSubmit = [];
2128
+
2129
+ const alreadyInstalled = settings.hooks.UserPromptSubmit.some((h) =>
2130
+ h.hooks?.some((hh) => hh.command?.includes("context-vault recall")),
2131
+ );
2132
+ if (alreadyInstalled) return false;
2133
+
2134
+ settings.hooks.UserPromptSubmit.push({
2135
+ hooks: [
2136
+ {
2137
+ type: "command",
2138
+ command: "context-vault recall",
2139
+ timeout: 10,
2140
+ },
2141
+ ],
2142
+ });
2143
+
2144
+ mkdirSync(dirname(settingsPath), { recursive: true });
2145
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
2146
+ return true;
2147
+ }
2148
+
2149
+ /**
2150
+ * Removes the context-vault recall hook from ~/.claude/settings.json.
2151
+ * Returns true if removed, false if not found.
2152
+ */
2153
+ function removeClaudeHook() {
2154
+ const settingsPath = claudeSettingsPath();
2155
+ if (!existsSync(settingsPath)) return false;
2156
+
2157
+ let settings;
2158
+ try {
2159
+ settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
2160
+ } catch {
2161
+ return false;
2162
+ }
2163
+
2164
+ if (!settings.hooks?.UserPromptSubmit) return false;
2165
+
2166
+ const before = settings.hooks.UserPromptSubmit.length;
2167
+ settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit.filter(
2168
+ (h) => !h.hooks?.some((hh) => hh.command?.includes("context-vault recall")),
2169
+ );
2170
+
2171
+ if (settings.hooks.UserPromptSubmit.length === before) return false;
2172
+
2173
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
2174
+ return true;
2175
+ }
2176
+
2177
+ async function runHooks() {
2178
+ const sub = args[1];
2179
+
2180
+ if (sub === "install") {
2181
+ try {
2182
+ const installed = installClaudeHook();
2183
+ if (installed) {
2184
+ console.log(`\n ${green("✓")} Claude Code memory hook installed.\n`);
2185
+ console.log(
2186
+ dim(
2187
+ " On every prompt, context-vault searches your vault for relevant entries",
2188
+ ),
2189
+ );
2190
+ console.log(
2191
+ dim(
2192
+ " and injects them as additional context alongside Claude's native memory.",
2193
+ ),
2194
+ );
2195
+ console.log(
2196
+ dim(`\n To remove: ${cyan("context-vault hooks remove")}`),
2197
+ );
2198
+ } else {
2199
+ console.log(`\n ${yellow("!")} Hook already installed.\n`);
2200
+ }
2201
+ } catch (e) {
2202
+ console.error(`\n ${red("x")} Failed to install hook: ${e.message}\n`);
2203
+ process.exit(1);
2204
+ }
2205
+ console.log();
2206
+ } else if (sub === "remove") {
2207
+ try {
2208
+ const removed = removeClaudeHook();
2209
+ if (removed) {
2210
+ console.log(`\n ${green("✓")} Claude Code memory hook removed.\n`);
2211
+ } else {
2212
+ console.log(`\n ${yellow("!")} Hook not found — nothing to remove.\n`);
2213
+ }
2214
+ } catch (e) {
2215
+ console.error(`\n ${red("x")} Failed to remove hook: ${e.message}\n`);
2216
+ process.exit(1);
2217
+ }
2218
+ } else {
2219
+ console.log(`
2220
+ ${bold("context-vault hooks")} <install|remove>
2221
+
2222
+ Manage the Claude Code memory hook integration.
2223
+ When installed, context-vault automatically searches your vault on every user
2224
+ prompt and injects relevant entries as additional context.
2225
+
2226
+ ${bold("Commands:")}
2227
+ ${cyan("hooks install")} Write UserPromptSubmit hook to ~/.claude/settings.json
2228
+ ${cyan("hooks remove")} Remove the hook from ~/.claude/settings.json
2229
+ `);
2230
+ }
2231
+ }
2232
+
1903
2233
  async function runServe() {
1904
2234
  await import("../src/server/index.js");
1905
2235
  }
@@ -1928,6 +2258,12 @@ async function main() {
1928
2258
  case "serve":
1929
2259
  await runServe();
1930
2260
  break;
2261
+ case "hooks":
2262
+ await runHooks();
2263
+ break;
2264
+ case "recall":
2265
+ await runRecall();
2266
+ break;
1931
2267
  case "import":
1932
2268
  await runImport();
1933
2269
  break;
@@ -1940,6 +2276,9 @@ async function main() {
1940
2276
  case "reindex":
1941
2277
  await runReindex();
1942
2278
  break;
2279
+ case "prune":
2280
+ await runPrune();
2281
+ break;
1943
2282
  case "status":
1944
2283
  await runStatus();
1945
2284
  break;
@@ -1,31 +1,22 @@
1
1
  {
2
2
  "name": "@context-vault/core",
3
- "version": "2.10.2",
3
+ "version": "2.11.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",
7
7
  "exports": {
8
8
  ".": "./src/index.js",
9
+ "./constants": "./src/constants.js",
9
10
  "./capture": "./src/capture/index.js",
10
- "./capture/formatters": "./src/capture/formatters.js",
11
- "./capture/file-ops": "./src/capture/file-ops.js",
12
- "./index/db": "./src/index/db.js",
13
- "./index/embed": "./src/index/embed.js",
11
+ "./capture/*": "./src/capture/*.js",
14
12
  "./index": "./src/index/index.js",
13
+ "./index/*": "./src/index/*.js",
15
14
  "./retrieve": "./src/retrieve/index.js",
16
- "./server/tools": "./src/server/tools.js",
17
- "./server/helpers": "./src/server/helpers.js",
18
- "./core/categories": "./src/core/categories.js",
19
- "./core/config": "./src/core/config.js",
20
- "./core/files": "./src/core/files.js",
21
- "./core/frontmatter": "./src/core/frontmatter.js",
22
- "./core/error-log": "./src/core/error-log.js",
23
- "./core/status": "./src/core/status.js",
24
- "./capture/importers": "./src/capture/importers.js",
25
- "./capture/import-pipeline": "./src/capture/import-pipeline.js",
26
- "./capture/ingest-url": "./src/capture/ingest-url.js",
15
+ "./retrieve/*": "./src/retrieve/*.js",
27
16
  "./sync": "./src/sync/sync.js",
28
- "./constants": "./src/constants.js"
17
+ "./sync/*": "./src/sync/*.js",
18
+ "./core/*": "./src/core/*.js",
19
+ "./server/*": "./src/server/*.js"
29
20
  },
30
21
  "files": [
31
22
  "src/"
@@ -32,10 +32,12 @@ export function writeEntryFile(
32
32
  tags,
33
33
  source,
34
34
  createdAt,
35
+ updatedAt,
35
36
  folder,
36
37
  category,
37
38
  identity_key,
38
39
  expires_at,
40
+ supersedes,
39
41
  },
40
42
  ) {
41
43
  // P5: folder is now a top-level param; also accept from meta for backward compat
@@ -61,9 +63,11 @@ export function writeEntryFile(
61
63
 
62
64
  if (identity_key) fmFields.identity_key = identity_key;
63
65
  if (expires_at) fmFields.expires_at = expires_at;
66
+ if (supersedes?.length) fmFields.supersedes = supersedes;
64
67
  fmFields.tags = tags || [];
65
68
  fmFields.source = source || "claude-code";
66
69
  fmFields.created = created;
70
+ if (updatedAt && updatedAt !== created) fmFields.updated = updatedAt;
67
71
 
68
72
  const mdBody = formatBody(kind, { title, body, meta });
69
73
 
@@ -26,6 +26,7 @@ export function writeEntry(
26
26
  folder,
27
27
  identity_key,
28
28
  expires_at,
29
+ supersedes,
29
30
  userId,
30
31
  },
31
32
  ) {
@@ -47,6 +48,7 @@ export function writeEntry(
47
48
  // Entity upsert: check for existing file at deterministic path
48
49
  let id;
49
50
  let createdAt;
51
+ let updatedAt;
50
52
  if (category === "entity" && identity_key) {
51
53
  const identitySlug = slugify(identity_key);
52
54
  const dir = resolve(ctx.config.vaultDir, kindToPath(kind));
@@ -58,13 +60,16 @@ export function writeEntry(
58
60
  const { meta: fmMeta } = parseFrontmatter(raw);
59
61
  id = fmMeta.id || ulid();
60
62
  createdAt = fmMeta.created || new Date().toISOString();
63
+ updatedAt = new Date().toISOString();
61
64
  } else {
62
65
  id = ulid();
63
66
  createdAt = new Date().toISOString();
67
+ updatedAt = createdAt;
64
68
  }
65
69
  } else {
66
70
  id = ulid();
67
71
  createdAt = new Date().toISOString();
72
+ updatedAt = createdAt;
68
73
  }
69
74
 
70
75
  const filePath = writeEntryFile(ctx.config.vaultDir, kind, {
@@ -75,10 +80,12 @@ export function writeEntry(
75
80
  tags,
76
81
  source,
77
82
  createdAt,
83
+ updatedAt,
78
84
  folder,
79
85
  category,
80
86
  identity_key,
81
87
  expires_at,
88
+ supersedes,
82
89
  });
83
90
 
84
91
  return {
@@ -92,8 +99,10 @@ export function writeEntry(
92
99
  tags,
93
100
  source,
94
101
  createdAt,
102
+ updatedAt,
95
103
  identity_key,
96
104
  expires_at,
105
+ supersedes,
97
106
  userId: userId || null,
98
107
  };
99
108
  }
@@ -121,6 +130,10 @@ export function updateEntryFile(ctx, existing, updates) {
121
130
  updates.source !== undefined ? updates.source : existing.source;
122
131
  const expires_at =
123
132
  updates.expires_at !== undefined ? updates.expires_at : existing.expires_at;
133
+ const supersedes =
134
+ updates.supersedes !== undefined
135
+ ? updates.supersedes
136
+ : fmMeta.supersedes || null;
124
137
 
125
138
  let mergedMeta;
126
139
  if (updates.meta !== undefined) {
@@ -130,6 +143,7 @@ export function updateEntryFile(ctx, existing, updates) {
130
143
  }
131
144
 
132
145
  // Build frontmatter
146
+ const now = new Date().toISOString();
133
147
  const fmFields = { id: existing.id };
134
148
  for (const [k, v] of Object.entries(mergedMeta)) {
135
149
  if (k === "folder") continue;
@@ -137,9 +151,11 @@ export function updateEntryFile(ctx, existing, updates) {
137
151
  }
138
152
  if (existing.identity_key) fmFields.identity_key = existing.identity_key;
139
153
  if (expires_at) fmFields.expires_at = expires_at;
154
+ if (supersedes?.length) fmFields.supersedes = supersedes;
140
155
  fmFields.tags = tags;
141
156
  fmFields.source = source || "claude-code";
142
157
  fmFields.created = fmMeta.created || existing.created_at;
158
+ if (now !== fmFields.created) fmFields.updated = now;
143
159
 
144
160
  const mdBody = formatBody(existing.kind, { title, body, meta: mergedMeta });
145
161
  const md = formatFrontmatter(fmFields) + mdBody;
@@ -159,8 +175,10 @@ export function updateEntryFile(ctx, existing, updates) {
159
175
  tags,
160
176
  source,
161
177
  createdAt: fmMeta.created || existing.created_at,
178
+ updatedAt: now,
162
179
  identity_key: existing.identity_key,
163
180
  expires_at,
181
+ supersedes,
164
182
  userId: existing.user_id || null,
165
183
  };
166
184
  }
@@ -180,6 +198,14 @@ export async function captureAndIndex(ctx, data) {
180
198
  const entry = writeEntry(ctx, data);
181
199
  try {
182
200
  await indexEntry(ctx, entry);
201
+ // Apply supersedes: mark referenced entries as superseded by this entry
202
+ if (entry.supersedes?.length && ctx.stmts.updateSupersededBy) {
203
+ for (const supersededId of entry.supersedes) {
204
+ if (typeof supersededId === "string" && supersededId.trim()) {
205
+ ctx.stmts.updateSupersededBy.run(entry.id, supersededId.trim());
206
+ }
207
+ }
208
+ }
183
209
  return entry;
184
210
  } catch (err) {
185
211
  // Rollback: restore previous content for entity upserts, delete for new entries
@@ -6,3 +6,10 @@ export const MAX_TAGS_COUNT = 20;
6
6
  export const MAX_META_LENGTH = 10 * 1024; // 10KB
7
7
  export const MAX_SOURCE_LENGTH = 200;
8
8
  export const MAX_IDENTITY_KEY_LENGTH = 200;
9
+
10
+ export const DEFAULT_GROWTH_THRESHOLDS = {
11
+ totalEntries: { warn: 1000, critical: 5000 },
12
+ eventEntries: { warn: 500, critical: 2000 },
13
+ vaultSizeBytes: { warn: 50 * 1024 * 1024, critical: 200 * 1024 * 1024 },
14
+ eventsWithoutTtl: { warn: 200 },
15
+ };
@@ -40,6 +40,17 @@ const CATEGORY_DIR_NAMES = {
40
40
  /** Set of valid category directory names (for reindex discovery) */
41
41
  export const CATEGORY_DIRS = new Set(Object.values(CATEGORY_DIR_NAMES));
42
42
 
43
+ /**
44
+ * Staleness thresholds (in days) per knowledge kind.
45
+ * Kinds not listed here are considered enduring (no staleness threshold).
46
+ * Based on updated_at; falls back to created_at if updated_at is null.
47
+ */
48
+ export const KIND_STALENESS_DAYS = {
49
+ pattern: 180,
50
+ decision: 365,
51
+ reference: 90,
52
+ };
53
+
43
54
  export function categoryFor(kind) {
44
55
  return KIND_CATEGORY[kind] || "knowledge";
45
56
  }