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 +143 -47
- package/node_modules/@context-vault/core/package.json +2 -2
- package/node_modules/@context-vault/core/src/capture/file-ops.js +2 -0
- package/node_modules/@context-vault/core/src/capture/index.js +14 -0
- package/node_modules/@context-vault/core/src/core/categories.js +1 -0
- package/node_modules/@context-vault/core/src/core/files.js +6 -29
- package/node_modules/@context-vault/core/src/core/frontmatter.js +1 -0
- package/node_modules/@context-vault/core/src/core/linking.js +161 -0
- package/node_modules/@context-vault/core/src/core/migrate-dirs.js +196 -0
- package/node_modules/@context-vault/core/src/core/temporal.js +146 -0
- package/node_modules/@context-vault/core/src/index/db.js +178 -8
- package/node_modules/@context-vault/core/src/index/index.js +89 -28
- package/node_modules/@context-vault/core/src/index.js +5 -0
- package/node_modules/@context-vault/core/src/retrieve/index.js +9 -136
- package/node_modules/@context-vault/core/src/server/tools/create-snapshot.js +37 -68
- package/node_modules/@context-vault/core/src/server/tools/get-context.js +108 -21
- package/node_modules/@context-vault/core/src/server/tools/save-context.js +29 -6
- package/node_modules/@context-vault/core/src/server/tools.js +0 -2
- package/package.json +3 -3
- package/src/server/index.js +3 -2
- package/node_modules/@context-vault/core/src/server/tools/submit-feedback.js +0 -55
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")}
|
|
297
|
-
${cyan("connect")} --key cv_...
|
|
298
|
-
${cyan("switch")} local|hosted
|
|
299
|
-
${cyan("serve")}
|
|
300
|
-
${cyan("hooks")} install|uninstall
|
|
301
|
-
${cyan("claude")} install|uninstall
|
|
302
|
-
${cyan("skills")} install
|
|
303
|
-
${cyan("health")}
|
|
304
|
-
${cyan("
|
|
305
|
-
${cyan("
|
|
306
|
-
${cyan("
|
|
307
|
-
${cyan("
|
|
308
|
-
${cyan("
|
|
309
|
-
${cyan("
|
|
310
|
-
${cyan("
|
|
311
|
-
${cyan("
|
|
312
|
-
${cyan("
|
|
313
|
-
${cyan("
|
|
314
|
-
${cyan("
|
|
315
|
-
${cyan("
|
|
316
|
-
${cyan("update")}
|
|
317
|
-
${cyan("uninstall")}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
${cyan("
|
|
323
|
-
${cyan("
|
|
324
|
-
|
|
325
|
-
${
|
|
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(`
|
|
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, {
|
|
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(
|
|
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.
|
|
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": ">=
|
|
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
|
|
@@ -37,42 +37,19 @@ export function slugify(text, maxLen = 60) {
|
|
|
37
37
|
return slug;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
return dirName.replace(/s$/, "");
|
|
47
|
+
return dirName;
|
|
69
48
|
}
|
|
70
49
|
|
|
71
|
-
/** Normalize a kind input
|
|
50
|
+
/** Normalize a kind input to its canonical form. Kind names are returned as-is. */
|
|
72
51
|
export function normalizeKind(input) {
|
|
73
|
-
|
|
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. */
|
|
@@ -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
|
+
}
|