context-vault 2.17.0 → 2.17.1

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
@@ -282,7 +282,7 @@ function printDetectionResults(results) {
282
282
  }
283
283
  }
284
284
 
285
- function showHelp() {
285
+ function showHelp(showAll = false) {
286
286
  console.log(`
287
287
  ${bold("◇ context-vault")} ${dim(`v${VERSION}`)}
288
288
  ${dim("Persistent memory for AI agents")}
@@ -293,37 +293,48 @@ ${bold("Usage:")}
293
293
  ${dim("No command → runs setup (first time) or shows status (existing vault)")}
294
294
 
295
295
  ${bold("Commands:")}
296
- ${cyan("setup")} Interactive MCP server installer
297
- ${cyan("connect")} --key cv_... Connect AI tools to hosted vault
298
- ${cyan("switch")} local|hosted Switch between local and hosted MCP modes
299
- ${cyan("serve")} Start the MCP server (used by AI clients)
300
- ${cyan("hooks")} install|uninstall Install or remove Claude Code memory hook
301
- ${cyan("claude")} install|uninstall Alias for hooks install|uninstall
302
- ${cyan("skills")} install Install bundled Claude Code skills
303
- ${cyan("health")} Quick health check — vault, DB, entry count
304
- ${cyan("restart")} Stop running MCP server processes (client auto-restarts)
305
- ${cyan("flush")} Check vault health and confirm DB is accessible
306
- ${cyan("recall")} Search vault from a Claude Code hook (reads stdin)
307
- ${cyan("session-capture")} Save a session summary entry (reads JSON from stdin)
308
- ${cyan("session-end")} Run session-end hook (parse transcript + capture)
309
- ${cyan("post-tool-call")} Run post-tool-call hook (log tool usage)
310
- ${cyan("save")} Save an entry to the vault from CLI
311
- ${cyan("search")} Search vault entries from CLI
312
- ${cyan("reindex")} Rebuild search index from knowledge files
313
- ${cyan("prune")} Remove expired entries (use --dry-run to preview)
314
- ${cyan("status")} Show vault diagnostics
315
- ${cyan("doctor")} Diagnose and repair common issues
316
- ${cyan("update")} Check for and install updates
317
- ${cyan("uninstall")} Remove MCP configs and optionally data
318
- ${cyan("import")} <path> Import entries from file or directory
319
- ${cyan("export")} Export vault to JSON or CSV
320
- ${cyan("ingest")} <url> Fetch URL and save as vault entry
321
- ${cyan("ingest-project")} <path> Scan project directory and register as project entity
322
- ${cyan("migrate")} Migrate vault between local and hosted
323
- ${cyan("consolidate")} Find hot tags and cold entries for maintenance
324
-
325
- ${bold("Options:")}
296
+ ${cyan("setup")} Interactive MCP server installer
297
+ ${cyan("connect")} --key cv_... Connect AI tools to hosted vault
298
+ ${cyan("switch")} local|hosted Switch between local and hosted MCP modes
299
+ ${cyan("serve")} Start the MCP server (used by AI clients)
300
+ ${cyan("hooks")} install|uninstall Install or remove Claude Code memory hook
301
+ ${cyan("claude")} install|uninstall Alias for hooks install|uninstall
302
+ ${cyan("skills")} install Install bundled Claude Code skills
303
+ ${cyan("health")} Quick health check — vault, DB, entry count
304
+ ${cyan("status")} Show vault diagnostics
305
+ ${cyan("doctor")} Diagnose and repair common issues
306
+ ${cyan("restart")} Stop running MCP server processes (client auto-restarts)
307
+ ${cyan("search")} Search vault entries from CLI
308
+ ${cyan("save")} Save an entry to the vault from CLI
309
+ ${cyan("import")} <path> Import entries from file or directory
310
+ ${cyan("export")} Export vault to JSON or CSV
311
+ ${cyan("ingest")} <url> Fetch URL and save as vault entry
312
+ ${cyan("ingest-project")} <path> Scan project directory and register as project entity
313
+ ${cyan("reindex")} Rebuild search index from knowledge files
314
+ ${cyan("migrate-dirs")} [--dry-run] Rename plural vault dirs to singular (post-2.18.0)
315
+ ${cyan("prune")} Remove expired entries (use --dry-run to preview)
316
+ ${cyan("update")} Check for and install updates
317
+ ${cyan("uninstall")} Remove MCP configs and optionally data
318
+ `);
319
+
320
+ if (showAll) {
321
+ console.log(`${bold("Plumbing")} ${dim("(internal hook implementations and maintenance utilities):")}
322
+ ${cyan("recall")} Search vault from a Claude Code hook (reads stdin)
323
+ ${cyan("session-capture")} Save a session summary entry (reads JSON from stdin)
324
+ ${cyan("session-end")} Run session-end hook (parse transcript + capture)
325
+ ${cyan("post-tool-call")} Run post-tool-call hook (log tool usage)
326
+ ${cyan("flush")} Check vault health and confirm DB is accessible
327
+ ${cyan("consolidate")} Find hot tags and cold entries for maintenance
328
+ ${cyan("migrate")} Migrate vault between local and hosted
329
+ `);
330
+ } else {
331
+ console.log(` ${dim("Run")} ${dim("context-vault --help --all")} ${dim("to show internal plumbing commands.")}
332
+ `);
333
+ }
334
+
335
+ console.log(`${bold("Options:")}
326
336
  --help Show this help
337
+ --help --all Show all commands including internal plumbing
327
338
  --version Show version
328
339
  --vault-dir <path> Set vault directory (setup/serve)
329
340
  --yes Non-interactive mode (accept all defaults)
@@ -1696,6 +1707,80 @@ async function runReindex() {
1696
1707
  console.log(` ${dim("·")} ${stats.unchanged} unchanged`);
1697
1708
  }
1698
1709
 
1710
+ async function runMigrateDirs() {
1711
+ const dryRun = flags.has("--dry-run");
1712
+
1713
+ // Vault dir: positional arg (skip --flags), or fall back to configured vault
1714
+ const positional = args.slice(1).find((a) => !a.startsWith("--"));
1715
+ let vaultDir = positional;
1716
+
1717
+ if (!vaultDir) {
1718
+ const { resolveConfig } = await import("@context-vault/core/core/config");
1719
+ const config = resolveConfig();
1720
+ if (!config.vaultDirExists) {
1721
+ console.error(red(`Vault directory not found: ${config.vaultDir}`));
1722
+ console.error("Run " + cyan("context-vault setup") + " to configure.");
1723
+ process.exit(1);
1724
+ }
1725
+ vaultDir = config.vaultDir;
1726
+ }
1727
+
1728
+ if (!existsSync(vaultDir) || !statSync(vaultDir).isDirectory()) {
1729
+ console.error(red(`Error: ${vaultDir} is not a directory`));
1730
+ process.exit(1);
1731
+ }
1732
+
1733
+ const { planMigration, executeMigration } =
1734
+ await import("@context-vault/core/core/migrate-dirs");
1735
+
1736
+ const ops = planMigration(vaultDir);
1737
+
1738
+ if (ops.length === 0) {
1739
+ console.log(green("✓ No plural directories found — vault is up to date."));
1740
+ return;
1741
+ }
1742
+
1743
+ if (dryRun) {
1744
+ console.log(dim("Dry run — no files will be moved.\n"));
1745
+ }
1746
+
1747
+ for (const op of ops) {
1748
+ const fileLabel = `${op.fileCount} ${op.fileCount === 1 ? "file" : "files"}`;
1749
+ const actionLabel = op.action === "rename" ? "RENAME" : "MERGE";
1750
+ const suffix = dryRun ? dim(" [dry-run]") : "";
1751
+ console.log(
1752
+ ` ${cyan(actionLabel)}: ${op.pluralName}/ → ${op.singularName}/ (${fileLabel})${suffix}`,
1753
+ );
1754
+ }
1755
+
1756
+ if (dryRun) {
1757
+ console.log();
1758
+ console.log(
1759
+ dim(
1760
+ ` ${ops.length} ${ops.length === 1 ? "directory" : "directories"} would be renamed/merged.`,
1761
+ ),
1762
+ );
1763
+ console.log(dim(" Remove --dry-run to apply."));
1764
+ return;
1765
+ }
1766
+
1767
+ const { renamed, merged, errors } = executeMigration(ops);
1768
+
1769
+ console.log();
1770
+ if (renamed > 0) console.log(green(`✓ Renamed: ${renamed}`));
1771
+ if (merged > 0) console.log(green(`✓ Merged: ${merged}`));
1772
+ if (errors.length > 0) {
1773
+ for (const e of errors) console.log(red(` ✗ ${e}`));
1774
+ }
1775
+
1776
+ if (renamed + merged > 0) {
1777
+ console.log();
1778
+ console.log(
1779
+ dim("Run `context-vault reindex` to rebuild the search index."),
1780
+ );
1781
+ }
1782
+ }
1783
+
1699
1784
  async function runPrune() {
1700
1785
  const dryRun = flags.has("--dry-run");
1701
1786
 
@@ -1827,7 +1912,7 @@ async function runStatus() {
1827
1912
  const filled = maxCount > 0 ? Math.round((c / maxCount) * BAR_WIDTH) : 0;
1828
1913
  const bar = "█".repeat(filled) + "░".repeat(BAR_WIDTH - filled);
1829
1914
  const countStr = String(c).padStart(4);
1830
- console.log(` ${countStr} ${kind}s ${dim(bar)}`);
1915
+ console.log(` ${dim(bar)} ${countStr} ${kind}s`);
1831
1916
  }
1832
1917
  } else {
1833
1918
  console.log(`\n ${dim("(empty — no entries indexed)")}`);
@@ -2881,6 +2966,7 @@ async function runSearch() {
2881
2966
  const sort = getFlag("--sort") || "relevance";
2882
2967
  const format = getFlag("--format") || "plain";
2883
2968
  const showFull = flags.has("--full");
2969
+ const scopeArg = getFlag("--scope"); // "hot" | "events" | "all"
2884
2970
 
2885
2971
  const valuedFlags = new Set([
2886
2972
  "--kind",
@@ -2888,6 +2974,7 @@ async function runSearch() {
2888
2974
  "--limit",
2889
2975
  "--sort",
2890
2976
  "--format",
2977
+ "--scope",
2891
2978
  ]);
2892
2979
 
2893
2980
  const queryParts = [];
@@ -2927,8 +3014,18 @@ async function runSearch() {
2927
3014
 
2928
3015
  let results;
2929
3016
 
3017
+ // Resolve scope → category/exclude filter
3018
+ const validScopes = new Set(["hot", "events", "all"]);
3019
+ const resolvedScope = validScopes.has(scopeArg) ? scopeArg : "hot";
3020
+ const scopeCategoryFilter = resolvedScope === "events" ? "event" : null;
3021
+ const scopeExcludeEvents = resolvedScope === "hot";
3022
+
2930
3023
  if (query) {
2931
- results = await hybridSearch(ctx, query, { limit: limit * 2 });
3024
+ results = await hybridSearch(ctx, query, {
3025
+ limit: limit * 2,
3026
+ categoryFilter: scopeCategoryFilter,
3027
+ excludeEvents: scopeExcludeEvents,
3028
+ });
2932
3029
 
2933
3030
  if (kind) {
2934
3031
  results = results.filter((r) => r.kind === kind);
@@ -2941,6 +3038,10 @@ async function runSearch() {
2941
3038
  sql += " AND kind = ?";
2942
3039
  params.push(kind);
2943
3040
  }
3041
+ if (scopeCategoryFilter) {
3042
+ sql += " AND category = ?";
3043
+ params.push(scopeCategoryFilter);
3044
+ }
2944
3045
  sql += " ORDER BY COALESCE(updated_at, created_at) DESC LIMIT ?";
2945
3046
  params.push(limit);
2946
3047
  results = db.prepare(sql).all(...params);
@@ -3840,7 +3941,7 @@ async function runDoctor() {
3840
3941
  console.log(` ${dim(`${row.created_at} — ${row.title}`)}`);
3841
3942
  }
3842
3943
  console.log(
3843
- ` ${dim('Review: context-vault search --kind feedback --tag auto-captured')}`,
3944
+ ` ${dim("Review: context-vault search --kind feedback --tag auto-captured")}`,
3844
3945
  );
3845
3946
  }
3846
3947
  } catch {
@@ -3953,9 +4054,7 @@ async function runDoctor() {
3953
4054
  console.log(
3954
4055
  ` ${yellow("!")} ${tool.name}: using old name "context-mcp"`,
3955
4056
  );
3956
- console.log(
3957
- ` ${dim("Fix: run context-vault setup to update")}`,
3958
- );
4057
+ console.log(` ${dim("Fix: run context-vault setup to update")}`);
3959
4058
  anyToolConfigured = true;
3960
4059
  }
3961
4060
  } catch {
@@ -3979,9 +4078,7 @@ async function runDoctor() {
3979
4078
  }
3980
4079
 
3981
4080
  if (!anyToolConfigured) {
3982
- console.log(
3983
- ` ${yellow("!")} No AI tools have context-vault configured`,
3984
- );
4081
+ console.log(` ${yellow("!")} No AI tools have context-vault configured`);
3985
4082
  console.log(` ${dim("Fix: run context-vault setup")}`);
3986
4083
  allOk = false;
3987
4084
  }
@@ -4106,14 +4203,10 @@ async function runDoctor() {
4106
4203
 
4107
4204
  if (hookCount === 0) {
4108
4205
  console.log(` ${dim("-")} No context-vault hooks installed`);
4109
- console.log(
4110
- ` ${dim("Optional: run context-vault hooks install")}`,
4111
- );
4206
+ console.log(` ${dim("Optional: run context-vault hooks install")}`);
4112
4207
  }
4113
4208
  } catch {
4114
- console.log(
4115
- ` ${yellow("!")} Could not read ${settingsPath}`,
4116
- );
4209
+ console.log(` ${yellow("!")} Could not read ${settingsPath}`);
4117
4210
  }
4118
4211
  } else {
4119
4212
  console.log(` ${dim("-")} No Claude Code settings found`);
@@ -4465,7 +4558,7 @@ async function main() {
4465
4558
  }
4466
4559
 
4467
4560
  if (flags.has("--help") || command === "help") {
4468
- showHelp();
4561
+ showHelp(flags.has("--all"));
4469
4562
  return;
4470
4563
  }
4471
4564
 
@@ -4537,6 +4630,9 @@ async function main() {
4537
4630
  case "reindex":
4538
4631
  await runReindex();
4539
4632
  break;
4633
+ case "migrate-dirs":
4634
+ await runMigrateDirs();
4635
+ break;
4540
4636
  case "prune":
4541
4637
  await runPrune();
4542
4638
  break;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@context-vault/core",
3
- "version": "2.17.0",
3
+ "version": "2.17.1",
4
4
  "type": "module",
5
5
  "description": "Shared core: capture, index, retrieve, tools, and utilities for context-vault",
6
6
  "main": "src/index.js",
@@ -23,7 +23,7 @@
23
23
  ],
24
24
  "license": "MIT",
25
25
  "engines": {
26
- "node": ">=24"
26
+ "node": ">=20"
27
27
  },
28
28
  "author": "Felix Hellstrom",
29
29
  "repository": {
@@ -38,6 +38,7 @@ export function writeEntryFile(
38
38
  identity_key,
39
39
  expires_at,
40
40
  supersedes,
41
+ related_to,
41
42
  },
42
43
  ) {
43
44
  // P5: folder is now a top-level param; also accept from meta for backward compat
@@ -64,6 +65,7 @@ export function writeEntryFile(
64
65
  if (identity_key) fmFields.identity_key = identity_key;
65
66
  if (expires_at) fmFields.expires_at = expires_at;
66
67
  if (supersedes?.length) fmFields.supersedes = supersedes;
68
+ if (related_to?.length) fmFields.related_to = related_to;
67
69
  fmFields.tags = tags || [];
68
70
  fmFields.source = source || "claude-code";
69
71
  fmFields.created = created;
@@ -27,6 +27,7 @@ export function writeEntry(
27
27
  identity_key,
28
28
  expires_at,
29
29
  supersedes,
30
+ related_to,
30
31
  source_files,
31
32
  tier,
32
33
  userId,
@@ -88,6 +89,7 @@ export function writeEntry(
88
89
  identity_key,
89
90
  expires_at,
90
91
  supersedes,
92
+ related_to,
91
93
  });
92
94
 
93
95
  return {
@@ -105,6 +107,7 @@ export function writeEntry(
105
107
  identity_key,
106
108
  expires_at,
107
109
  supersedes,
110
+ related_to: related_to || null,
108
111
  source_files: source_files || null,
109
112
  tier: tier || null,
110
113
  userId: userId || null,
@@ -126,6 +129,9 @@ export function updateEntryFile(ctx, existing, updates) {
126
129
 
127
130
  const existingMeta = existing.meta ? JSON.parse(existing.meta) : {};
128
131
  const existingTags = existing.tags ? JSON.parse(existing.tags) : [];
132
+ const existingRelatedTo = existing.related_to
133
+ ? JSON.parse(existing.related_to)
134
+ : fmMeta.related_to || null;
129
135
 
130
136
  const title = updates.title !== undefined ? updates.title : existing.title;
131
137
  const body = updates.body !== undefined ? updates.body : existing.body;
@@ -138,6 +144,8 @@ export function updateEntryFile(ctx, existing, updates) {
138
144
  updates.supersedes !== undefined
139
145
  ? updates.supersedes
140
146
  : fmMeta.supersedes || null;
147
+ const related_to =
148
+ updates.related_to !== undefined ? updates.related_to : existingRelatedTo;
141
149
  const source_files =
142
150
  updates.source_files !== undefined
143
151
  ? updates.source_files
@@ -162,6 +170,7 @@ export function updateEntryFile(ctx, existing, updates) {
162
170
  if (existing.identity_key) fmFields.identity_key = existing.identity_key;
163
171
  if (expires_at) fmFields.expires_at = expires_at;
164
172
  if (supersedes?.length) fmFields.supersedes = supersedes;
173
+ if (related_to?.length) fmFields.related_to = related_to;
165
174
  fmFields.tags = tags;
166
175
  fmFields.source = source || "claude-code";
167
176
  fmFields.created = fmMeta.created || existing.created_at;
@@ -189,6 +198,7 @@ export function updateEntryFile(ctx, existing, updates) {
189
198
  identity_key: existing.identity_key,
190
199
  expires_at,
191
200
  supersedes,
201
+ related_to: related_to || null,
192
202
  source_files: source_files || null,
193
203
  userId: existing.user_id || null,
194
204
  };
@@ -217,6 +227,10 @@ export async function captureAndIndex(ctx, data) {
217
227
  }
218
228
  }
219
229
  }
230
+ // Store related_to links in DB
231
+ if (entry.related_to?.length && ctx.stmts.updateRelatedTo) {
232
+ ctx.stmts.updateRelatedTo.run(JSON.stringify(entry.related_to), entry.id);
233
+ }
220
234
  return entry;
221
235
  } catch (err) {
222
236
  // Rollback: restore previous content for entity upserts, delete for new entries
@@ -23,6 +23,7 @@ const KIND_CATEGORY = {
23
23
  source: "entity",
24
24
  bucket: "entity",
25
25
  // Event — append-only, decaying
26
+ event: "event",
26
27
  conversation: "event",
27
28
  message: "event",
28
29
  session: "event",
@@ -37,42 +37,19 @@ export function slugify(text, maxLen = 60) {
37
37
  return slug;
38
38
  }
39
39
 
40
- const PLURAL_MAP = {
41
- insight: "insights",
42
- decision: "decisions",
43
- pattern: "patterns",
44
- status: "statuses",
45
- analysis: "analyses",
46
- contact: "contacts",
47
- project: "projects",
48
- tool: "tools",
49
- source: "sources",
50
- conversation: "conversations",
51
- message: "messages",
52
- session: "sessions",
53
- log: "logs",
54
- feedback: "feedbacks",
55
- };
56
-
57
- const SINGULAR_MAP = Object.fromEntries(
58
- Object.entries(PLURAL_MAP).map(([k, v]) => [v, k]),
59
- );
60
-
40
+ /** Map kind name to its directory name. Kind names are used as-is (no pluralization). */
61
41
  export function kindToDir(kind) {
62
- if (PLURAL_MAP[kind]) return PLURAL_MAP[kind];
63
- return kind.endsWith("s") ? kind : kind + "s";
42
+ return kind;
64
43
  }
65
44
 
45
+ /** Map directory name back to kind name. Directory names equal kind names (identity). */
66
46
  export function dirToKind(dirName) {
67
- if (SINGULAR_MAP[dirName]) return SINGULAR_MAP[dirName];
68
- return dirName.replace(/s$/, "");
47
+ return dirName;
69
48
  }
70
49
 
71
- /** Normalize a kind input (singular or plural) to its canonical singular form. */
50
+ /** Normalize a kind input to its canonical form. Kind names are returned as-is. */
72
51
  export function normalizeKind(input) {
73
- if (PLURAL_MAP[input]) return input; // Already a known singular kind
74
- if (SINGULAR_MAP[input]) return SINGULAR_MAP[input]; // Known plural → singular
75
- return input; // Unknown — use as-is (don't strip 's')
52
+ return input;
76
53
  }
77
54
 
78
55
  /** Returns relative path from vault root → kind dir: "knowledge/insights", "events/sessions", etc. */
@@ -69,6 +69,7 @@ const RESERVED_FM_KEYS = new Set([
69
69
  "identity_key",
70
70
  "expires_at",
71
71
  "supersedes",
72
+ "related_to",
72
73
  ]);
73
74
 
74
75
  export function extractCustomMeta(fmMeta) {
@@ -0,0 +1,161 @@
1
+ /**
2
+ * linking.js — Pure graph traversal for related_to links.
3
+ *
4
+ * All functions accept a db handle and return data — no side effects.
5
+ * The calling layer (get-context handler) is responsible for I/O wiring.
6
+ */
7
+
8
+ /**
9
+ * Parse a `related_to` JSON string from the DB into an array of ID strings.
10
+ * Returns an empty array on any parse failure or null input.
11
+ *
12
+ * @param {string|null|undefined} raw
13
+ * @returns {string[]}
14
+ */
15
+ export function parseRelatedTo(raw) {
16
+ if (!raw) return [];
17
+ try {
18
+ const parsed = JSON.parse(raw);
19
+ if (!Array.isArray(parsed)) return [];
20
+ return parsed.filter((id) => typeof id === "string" && id.trim());
21
+ } catch {
22
+ return [];
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Fetch vault entries by their IDs, scoped to a user.
28
+ * Returns only entries that exist and are not expired or superseded.
29
+ *
30
+ * @param {import('node:sqlite').DatabaseSync} db
31
+ * @param {string[]} ids
32
+ * @param {string|null|undefined} userId
33
+ * @returns {object[]} Matching DB rows
34
+ */
35
+ export function resolveLinks(db, ids, userId) {
36
+ if (!ids.length) return [];
37
+ const unique = [...new Set(ids)];
38
+ const placeholders = unique.map(() => "?").join(",");
39
+ // When userId is defined (hosted mode), scope to that user.
40
+ // When userId is undefined (local mode), no user scoping — all entries accessible.
41
+ const userClause =
42
+ userId !== undefined && userId !== null ? "AND user_id = ?" : "";
43
+ const params =
44
+ userId !== undefined && userId !== null ? [...unique, userId] : unique;
45
+ try {
46
+ return db
47
+ .prepare(
48
+ `SELECT * FROM vault
49
+ WHERE id IN (${placeholders})
50
+ ${userClause}
51
+ AND (expires_at IS NULL OR expires_at > datetime('now'))
52
+ AND superseded_by IS NULL`,
53
+ )
54
+ .all(...params);
55
+ } catch {
56
+ return [];
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Find all entries that declare `entryId` in their `related_to` field
62
+ * (i.e. entries that point *to* this entry — backlinks).
63
+ * Scoped to the same user. Excludes expired and superseded entries.
64
+ *
65
+ * @param {import('node:sqlite').DatabaseSync} db
66
+ * @param {string} entryId
67
+ * @param {string|null|undefined} userId
68
+ * @returns {object[]} Entries with a backlink to entryId
69
+ */
70
+ export function resolveBacklinks(db, entryId, userId) {
71
+ if (!entryId) return [];
72
+ // When userId is defined (hosted mode), scope to that user.
73
+ // When userId is undefined (local mode), no user scoping — all entries accessible.
74
+ const userClause =
75
+ userId !== undefined && userId !== null ? "AND user_id = ?" : "";
76
+ const likePattern = `%"${entryId}"%`;
77
+ const params =
78
+ userId !== undefined && userId !== null
79
+ ? [likePattern, userId]
80
+ : [likePattern];
81
+ try {
82
+ return db
83
+ .prepare(
84
+ `SELECT * FROM vault
85
+ WHERE related_to LIKE ?
86
+ ${userClause}
87
+ AND (expires_at IS NULL OR expires_at > datetime('now'))
88
+ AND superseded_by IS NULL`,
89
+ )
90
+ .all(...params);
91
+ } catch {
92
+ return [];
93
+ }
94
+ }
95
+
96
+ /**
97
+ * For a set of primary entry IDs, collect all forward links (entries pointed
98
+ * to by `related_to`) and backlinks (entries that point back to any primary).
99
+ *
100
+ * Returns a Map of id → entry row for all linked entries, excluding entries
101
+ * already present in `primaryIds`.
102
+ *
103
+ * @param {import('node:sqlite').DatabaseSync} db
104
+ * @param {object[]} primaryEntries - Full entry rows (must have id + related_to fields)
105
+ * @param {string|null|undefined} userId
106
+ * @returns {{ forward: object[], backward: object[] }}
107
+ */
108
+ export function collectLinkedEntries(db, primaryEntries, userId) {
109
+ const primaryIds = new Set(primaryEntries.map((e) => e.id));
110
+
111
+ // Forward: resolve all IDs from related_to fields
112
+ const forwardIds = [];
113
+ for (const entry of primaryEntries) {
114
+ const ids = parseRelatedTo(entry.related_to);
115
+ for (const id of ids) {
116
+ if (!primaryIds.has(id)) forwardIds.push(id);
117
+ }
118
+ }
119
+ const forwardEntries = resolveLinks(db, forwardIds, userId).filter(
120
+ (e) => !primaryIds.has(e.id),
121
+ );
122
+
123
+ // Backward: find all entries that link to any primary entry
124
+ const backwardSeen = new Set();
125
+ const backwardEntries = [];
126
+ const forwardIds2 = new Set(forwardEntries.map((e) => e.id));
127
+ for (const entry of primaryEntries) {
128
+ const backlinks = resolveBacklinks(db, entry.id, userId);
129
+ for (const bl of backlinks) {
130
+ if (!primaryIds.has(bl.id) && !backwardSeen.has(bl.id)) {
131
+ backwardSeen.add(bl.id);
132
+ backwardEntries.push(bl);
133
+ }
134
+ }
135
+ }
136
+
137
+ return { forward: forwardEntries, backward: backwardEntries };
138
+ }
139
+
140
+ /**
141
+ * Validate a `related_to` value from user input.
142
+ * Must be an array of non-empty strings (ULID-like IDs).
143
+ * Returns an error message string if invalid, or null if valid.
144
+ *
145
+ * @param {unknown} relatedTo
146
+ * @returns {string|null}
147
+ */
148
+ export function validateRelatedTo(relatedTo) {
149
+ if (relatedTo === undefined || relatedTo === null) return null;
150
+ if (!Array.isArray(relatedTo))
151
+ return "related_to must be an array of entry IDs";
152
+ for (const id of relatedTo) {
153
+ if (typeof id !== "string" || !id.trim()) {
154
+ return "each related_to entry must be a non-empty string ID";
155
+ }
156
+ if (id.length > 32) {
157
+ return `related_to ID too long (max 32 chars): "${id.slice(0, 32)}..."`;
158
+ }
159
+ }
160
+ return null;
161
+ }