@finchagentic/mcp 4.0.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.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +345 -0
  3. package/dist/_http-cache.js +96 -0
  4. package/dist/agent-loop.js +231 -0
  5. package/dist/annotations.js +113 -0
  6. package/dist/cli.js +1195 -0
  7. package/dist/clink-input.js +15 -0
  8. package/dist/config.js +132 -0
  9. package/dist/convex.js +151 -0
  10. package/dist/dex-pair.js +54 -0
  11. package/dist/enrichment-router.js +315 -0
  12. package/dist/index.js +256 -0
  13. package/dist/llm.js +323 -0
  14. package/dist/local-memory.js +102 -0
  15. package/dist/local-vault.js +454 -0
  16. package/dist/output-schemas.js +551 -0
  17. package/dist/prompts.js +111 -0
  18. package/dist/public-url.js +107 -0
  19. package/dist/resources.js +116 -0
  20. package/dist/server.js +300 -0
  21. package/dist/signal-gate.js +57 -0
  22. package/dist/token-decimals.js +26 -0
  23. package/dist/token-gate.js +88 -0
  24. package/dist/tool-filter.js +44 -0
  25. package/dist/tools/_solidity-scan.js +313 -0
  26. package/dist/tools/agents.js +729 -0
  27. package/dist/tools/automation.js +314 -0
  28. package/dist/tools/base-mcp.js +478 -0
  29. package/dist/tools/base.js +269 -0
  30. package/dist/tools/chronicle.js +268 -0
  31. package/dist/tools/coder.js +94 -0
  32. package/dist/tools/deep-research.js +1416 -0
  33. package/dist/tools/defi.js +291 -0
  34. package/dist/tools/equity.js +364 -0
  35. package/dist/tools/events.js +182 -0
  36. package/dist/tools/framework.js +150 -0
  37. package/dist/tools/github.js +514 -0
  38. package/dist/tools/insider.js +264 -0
  39. package/dist/tools/insight.js +634 -0
  40. package/dist/tools/market.js +555 -0
  41. package/dist/tools/memory.js +1046 -0
  42. package/dist/tools/miroshark.js +343 -0
  43. package/dist/tools/monitor.js +319 -0
  44. package/dist/tools/os.js +226 -0
  45. package/dist/tools/packets.js +296 -0
  46. package/dist/tools/research-chain.js +226 -0
  47. package/dist/tools/research-compare.js +280 -0
  48. package/dist/tools/research.js +188 -0
  49. package/dist/tools/rh-bridge.js +148 -0
  50. package/dist/tools/rh-mcp.js +1411 -0
  51. package/dist/tools/rh-orders.js +471 -0
  52. package/dist/tools/scanner.js +534 -0
  53. package/dist/tools/vault.js +764 -0
  54. package/dist/tools/wallet.js +200 -0
  55. package/dist/types.js +2 -0
  56. package/dist/wallet.js +184 -0
  57. package/package.json +87 -0
@@ -0,0 +1,102 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getLocalMemoryConfig = getLocalMemoryConfig;
4
+ exports.isLocalMemoryReachable = isLocalMemoryReachable;
5
+ exports.localMemoryAdd = localMemoryAdd;
6
+ exports.localMemorySearch = localMemorySearch;
7
+ exports.localMemoryList = localMemoryList;
8
+ exports.localMemoryDelete = localMemoryDelete;
9
+ exports.localMemoryProfile = localMemoryProfile;
10
+ const config_js_1 = require("./config.js");
11
+ const DEFAULT_URL = "http://localhost:6767";
12
+ const REACHABILITY_TIMEOUT_MS = 1500;
13
+ const CALL_TIMEOUT_MS = 15000;
14
+ // Returns config only when the user has explicitly opted into local memory
15
+ // (memoryBackend: "local") and a key is present. Callers should still treat
16
+ // a null return as "use Convex" - this never throws.
17
+ function getLocalMemoryConfig() {
18
+ try {
19
+ const cfg = (0, config_js_1.readConfig)();
20
+ if (cfg.memoryBackend !== "local" || !cfg.supermemoryApiKey)
21
+ return null;
22
+ return { url: cfg.supermemoryUrl ?? DEFAULT_URL, apiKey: cfg.supermemoryApiKey };
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ // Requires a genuine 2xx, not just "something answered" - a stale/wrong
29
+ // supermemoryApiKey returns 401 (server is up, but every real memory call
30
+ // will fail the same way), and that must show as unhealthy, not healthy.
31
+ async function isLocalMemoryReachable(cfg) {
32
+ try {
33
+ const res = await fetch(`${cfg.url}/v3/search`, {
34
+ method: "POST",
35
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${cfg.apiKey}` },
36
+ body: JSON.stringify({ q: "__finch_reachability_check__", limit: 1 }),
37
+ signal: AbortSignal.timeout(REACHABILITY_TIMEOUT_MS),
38
+ });
39
+ return res.ok;
40
+ }
41
+ catch {
42
+ return false;
43
+ }
44
+ }
45
+ async function localMemoryAdd(cfg, content, metadata, sourceUrl) {
46
+ const res = await fetch(`${cfg.url}/v3/documents`, {
47
+ method: "POST",
48
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${cfg.apiKey}` },
49
+ body: JSON.stringify({ content, metadata, ...(sourceUrl ? { sourceUrl } : {}) }),
50
+ signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
51
+ });
52
+ if (!res.ok)
53
+ throw new Error(`local supermemory add failed: HTTP ${res.status}`);
54
+ const data = await res.json();
55
+ return { id: data.id ?? data.documentId ?? "saved" };
56
+ }
57
+ async function localMemorySearch(cfg, query, limit) {
58
+ const res = await fetch(`${cfg.url}/v3/search`, {
59
+ method: "POST",
60
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${cfg.apiKey}` },
61
+ body: JSON.stringify({ q: query, limit }),
62
+ signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
63
+ });
64
+ if (!res.ok)
65
+ throw new Error(`local supermemory search failed: HTTP ${res.status}`);
66
+ const data = await res.json();
67
+ return (data.results ?? []).map((r) => ({
68
+ id: r.id ?? r.documentId ?? "",
69
+ content: r.content ?? r.chunks?.map((c) => c.content ?? "").join("\n") ?? "",
70
+ metadata: r.metadata ?? {},
71
+ score: r.score,
72
+ }));
73
+ }
74
+ // supermemory's search endpoint doubles as "list": a wildcard query returns
75
+ // recent documents. No dedicated /list endpoint is documented for the local
76
+ // server, so callers pass "*" and post-filter by tag client-side, matching
77
+ // how the Convex-side /memory/list already does its own tag post-filter.
78
+ async function localMemoryList(cfg, limit, tag) {
79
+ const rows = await localMemorySearch(cfg, "*", Math.max(limit * (tag ? 3 : 1), limit));
80
+ const filtered = tag ? rows.filter((r) => Array.isArray(r.metadata?.tags) && r.metadata.tags.includes(tag)) : rows;
81
+ return filtered.slice(0, limit);
82
+ }
83
+ // Delete endpoint isn't confirmed in the public self-hosting docs at the
84
+ // time this was written - throws a distinct error so callers can surface a
85
+ // clear "not supported locally yet" message instead of a generic failure.
86
+ async function localMemoryDelete(cfg, id) {
87
+ const res = await fetch(`${cfg.url}/v3/documents/${encodeURIComponent(id)}`, {
88
+ method: "DELETE",
89
+ headers: { Authorization: `Bearer ${cfg.apiKey}` },
90
+ signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
91
+ });
92
+ if (!res.ok)
93
+ throw new Error(`local supermemory delete failed: HTTP ${res.status}`);
94
+ }
95
+ // No dedicated count endpoint is documented for the local server, so this
96
+ // approximates via a capped wildcard search - accurate up to `SAMPLE_LIMIT`,
97
+ // reported as a floor ("200+") beyond that rather than a false exact count.
98
+ const PROFILE_SAMPLE_LIMIT = 200;
99
+ async function localMemoryProfile(cfg) {
100
+ const rows = await localMemorySearch(cfg, "*", PROFILE_SAMPLE_LIMIT);
101
+ return { total: rows.length, status: "ok", space: "local", approximate: rows.length >= PROFILE_SAMPLE_LIMIT };
102
+ }
@@ -0,0 +1,454 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.getLocalVaultConfig = getLocalVaultConfig;
37
+ exports.localVaultSave = localVaultSave;
38
+ exports.localVaultRead = localVaultRead;
39
+ exports.localVaultList = localVaultList;
40
+ exports.localVaultSearch = localVaultSearch;
41
+ exports.localVaultHistory = localVaultHistory;
42
+ exports.localVaultDiff = localVaultDiff;
43
+ exports.localVaultExport = localVaultExport;
44
+ exports.localVaultPin = localVaultPin;
45
+ exports.localVaultDelete = localVaultDelete;
46
+ exports.localVaultTag = localVaultTag;
47
+ exports.localVaultLink = localVaultLink;
48
+ exports.localVaultRelated = localVaultRelated;
49
+ exports.localVaultStoreCredential = localVaultStoreCredential;
50
+ exports.localVaultGetCredential = localVaultGetCredential;
51
+ const fs = __importStar(require("fs"));
52
+ const os = __importStar(require("os"));
53
+ const path = __importStar(require("path"));
54
+ const crypto = __importStar(require("crypto"));
55
+ const config_js_1 = require("./config.js");
56
+ // Fully-local, user-owned Noel-Vault backend. Mirrors the two-tier pattern of
57
+ // local-memory.ts: when the user opts in (`vaultBackend: "local"`), the vault
58
+ // tools store versioned artifacts on the user's own disk under
59
+ // ~/.finch/vault/ - no Finch account, no Convex, no network, no cost to
60
+ // the platform. Zero runtime dependencies (plain JSON + one content file per
61
+ // version), same spirit as codebase-memory-mcp's local-first SQLite store.
62
+ //
63
+ // Every exported function returns objects shaped like the Convex /vault/*
64
+ // responses so tools/vault.ts can swap the data source with a one-line branch
65
+ // and reuse all of its existing rendering. Not-found cases THROW an Error whose
66
+ // message contains "not found", matching how callConvex surfaces a 404 (the
67
+ // vault handlers already catch and format that).
68
+ const VAULT_DIR = path.join(os.homedir(), ".finch", "vault");
69
+ // Local vault is enabled purely by config - it needs no server, just the
70
+ // filesystem. Returns null (→ "use Convex") when the user hasn't opted in.
71
+ function getLocalVaultConfig() {
72
+ try {
73
+ if ((0, config_js_1.readConfig)().vaultBackend !== "local")
74
+ return null;
75
+ return { dir: VAULT_DIR };
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ }
81
+ function now() { return Date.now(); }
82
+ function ensureDir(cfg) {
83
+ fs.mkdirSync(path.join(cfg.dir, "data"), { recursive: true });
84
+ }
85
+ function indexPath(cfg) { return path.join(cfg.dir, "index.json"); }
86
+ function readIndex(cfg) {
87
+ try {
88
+ const raw = fs.readFileSync(indexPath(cfg), "utf8");
89
+ const parsed = JSON.parse(raw);
90
+ return { entries: parsed.entries ?? {}, links: parsed.links ?? [] };
91
+ }
92
+ catch {
93
+ return { entries: {}, links: [] };
94
+ }
95
+ }
96
+ // Atomic write: temp file + rename, so a crash mid-write can't corrupt the
97
+ // manifest every entry depends on.
98
+ function writeIndex(cfg, idx) {
99
+ ensureDir(cfg);
100
+ const tmp = indexPath(cfg) + `.tmp-${process.pid}`;
101
+ fs.writeFileSync(tmp, JSON.stringify(idx, null, 2), "utf8");
102
+ fs.renameSync(tmp, indexPath(cfg));
103
+ }
104
+ function keyDir(key) {
105
+ return crypto.createHash("sha1").update(key).digest("hex").slice(0, 16);
106
+ }
107
+ function contentPath(cfg, dir, version) {
108
+ return path.join(cfg.dir, "data", dir, `v${version}`);
109
+ }
110
+ function readContent(cfg, dir, version) {
111
+ try {
112
+ return fs.readFileSync(contentPath(cfg, dir, version), "utf8");
113
+ }
114
+ catch {
115
+ return "";
116
+ }
117
+ }
118
+ function writeContent(cfg, dir, version, content) {
119
+ const d = path.join(cfg.dir, "data", dir);
120
+ fs.mkdirSync(d, { recursive: true });
121
+ fs.writeFileSync(contentPath(cfg, dir, version), content, "utf8");
122
+ }
123
+ function slugify(s) {
124
+ return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "entry";
125
+ }
126
+ function notFound(key) {
127
+ return new Error(`Vault entry not found: ${key}`);
128
+ }
129
+ function localVaultSave(cfg, input) {
130
+ const idx = readIndex(cfg);
131
+ let key = input.key ?? `${input.type}/${slugify(input.title)}`;
132
+ // An auto-generated key can collide with an unrelated entry when two
133
+ // different titles slugify to the same thing. Only version-in-place when the
134
+ // key was explicit or the existing entry is the same title; otherwise
135
+ // disambiguate, so a different note never silently gets folded into this one.
136
+ if (!input.key && idx.entries[key] && idx.entries[key].title !== input.title) {
137
+ let n = 2;
138
+ while (idx.entries[`${key}-${n}`])
139
+ n++;
140
+ key = `${key}-${n}`;
141
+ }
142
+ // Parity with the hosted vault: [[wikilinks]] become graph edges, #tags are
143
+ // pulled into the entry's tag set.
144
+ const inlineTags = Array.from(new Set((input.content.match(/(?:^|\s)#([a-z0-9][a-z0-9_-]*)/gi) ?? []).map((t) => t.trim().replace(/^#/, "").toLowerCase())));
145
+ const wikilinks = Array.from(new Set((input.content.match(/\[\[([^\]]+)\]\]/g) ?? []).map((w) => w.slice(2, -2).trim())));
146
+ const tags = Array.from(new Set([...(input.tags ?? []), ...inlineTags]));
147
+ const existing = idx.entries[key];
148
+ const ts = now();
149
+ if (existing) {
150
+ const currentContent = readContent(cfg, existing.dir, existing.currentVersion);
151
+ if (currentContent === input.content) {
152
+ // No change - don't bump the version (matches hosted "Unchanged").
153
+ return { key, version: existing.currentVersion, changed: false, linksCreated: 0, linksMissing: [], inlineTagsExtracted: inlineTags.length };
154
+ }
155
+ const version = existing.currentVersion + 1;
156
+ writeContent(cfg, existing.dir, version, input.content);
157
+ existing.currentVersion = version;
158
+ existing.title = input.title;
159
+ existing.type = input.type;
160
+ existing.contentType = input.contentType ?? existing.contentType;
161
+ existing.agentId = input.agentId ?? existing.agentId;
162
+ existing.tags = Array.from(new Set([...existing.tags, ...tags]));
163
+ existing.updatedAt = ts;
164
+ existing.metadata = input.metadata ?? existing.metadata;
165
+ existing.versions.push({ version, commitMsg: input.commitMsg, agentId: input.agentId, size: Buffer.byteLength(input.content), createdAt: ts });
166
+ const linkResult = applyWikilinks(idx, key, wikilinks);
167
+ writeIndex(cfg, idx);
168
+ return { key, version, changed: true, ...linkResult, inlineTagsExtracted: inlineTags.length };
169
+ }
170
+ const dir = keyDir(key);
171
+ writeContent(cfg, dir, 1, input.content);
172
+ idx.entries[key] = {
173
+ key, dir, type: input.type, title: input.title, contentType: input.contentType,
174
+ agentId: input.agentId, tags, pinned: false, currentVersion: 1,
175
+ createdAt: ts, updatedAt: ts, metadata: input.metadata,
176
+ versions: [{ version: 1, commitMsg: input.commitMsg, agentId: input.agentId, size: Buffer.byteLength(input.content), createdAt: ts }],
177
+ };
178
+ const linkResult = applyWikilinks(idx, key, wikilinks);
179
+ writeIndex(cfg, idx);
180
+ return { key, version: 1, changed: true, ...linkResult, inlineTagsExtracted: inlineTags.length };
181
+ }
182
+ function applyWikilinks(idx, fromKey, targets) {
183
+ let linksCreated = 0;
184
+ const linksMissing = [];
185
+ for (const toKey of targets) {
186
+ if (!idx.entries[toKey]) {
187
+ linksMissing.push(toKey);
188
+ continue;
189
+ }
190
+ const dup = idx.links.find((l) => l.fromKey === fromKey && l.toKey === toKey && l.relation === "references");
191
+ if (!dup) {
192
+ idx.links.push({ fromKey, toKey, relation: "references" });
193
+ linksCreated++;
194
+ }
195
+ }
196
+ return { linksCreated, linksMissing };
197
+ }
198
+ // ── read ──────────────────────────────────────────────────────────────────────
199
+ function localVaultRead(cfg, key) {
200
+ const idx = readIndex(cfg);
201
+ const e = idx.entries[key];
202
+ if (!e)
203
+ throw notFound(key);
204
+ const linkedKeys = idx.links.filter((l) => l.fromKey === key).map((l) => `${l.toKey} (${l.relation})`);
205
+ const backlinks = idx.links
206
+ .filter((l) => l.toKey === key)
207
+ .map((l) => ({ key: l.fromKey, title: idx.entries[l.fromKey]?.title }));
208
+ return {
209
+ key: e.key, title: e.title, type: e.type, version: e.currentVersion,
210
+ size: e.versions[e.versions.length - 1]?.size, tags: e.tags, isPinned: e.pinned,
211
+ agentId: e.agentId, updatedAt: e.updatedAt, content: readContent(cfg, e.dir, e.currentVersion),
212
+ linkedKeys, backlinks,
213
+ };
214
+ }
215
+ // ── list ──────────────────────────────────────────────────────────────────────
216
+ function localVaultList(cfg, opts) {
217
+ const idx = readIndex(cfg);
218
+ let rows = Object.values(idx.entries).filter((e) => e.type !== "credential");
219
+ if (opts.type)
220
+ rows = rows.filter((e) => e.type === opts.type);
221
+ if (opts.agentId)
222
+ rows = rows.filter((e) => e.agentId === opts.agentId);
223
+ if (opts.pinned !== undefined)
224
+ rows = rows.filter((e) => e.pinned === opts.pinned);
225
+ rows.sort((a, b) => (Number(b.pinned) - Number(a.pinned)) || (b.updatedAt - a.updatedAt));
226
+ const entries = rows.slice(0, opts.limit ?? 50).map((e) => ({
227
+ key: e.key, title: e.title, type: e.type, version: e.currentVersion,
228
+ size: e.versions[e.versions.length - 1]?.size, updatedAt: e.updatedAt, isPinned: e.pinned,
229
+ }));
230
+ return { entries };
231
+ }
232
+ // ── search (full-text, local) ─────────────────────────────────────────────────
233
+ function localVaultSearch(cfg, query, opts) {
234
+ const idx = readIndex(cfg);
235
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
236
+ const scored = [];
237
+ for (const e of Object.values(idx.entries)) {
238
+ if (e.type === "credential")
239
+ continue;
240
+ if (opts.type && e.type !== opts.type)
241
+ continue;
242
+ const content = readContent(cfg, e.dir, e.currentVersion);
243
+ const hay = `${e.title}\n${e.tags.join(" ")}\n${content}`.toLowerCase();
244
+ let score = 0;
245
+ for (const t of terms) {
246
+ if (e.title.toLowerCase().includes(t))
247
+ score += 3;
248
+ if (e.tags.some((tag) => tag.toLowerCase().includes(t)))
249
+ score += 2;
250
+ const occurrences = hay.split(t).length - 1;
251
+ score += Math.min(occurrences, 5);
252
+ }
253
+ if (score > 0) {
254
+ const firstHit = terms.map((t) => content.toLowerCase().indexOf(t)).filter((i) => i >= 0).sort((a, b) => a - b)[0] ?? 0;
255
+ scored.push({ e, score, preview: content.slice(Math.max(0, firstHit - 20), firstHit + 180).replace(/\n/g, " ") });
256
+ }
257
+ }
258
+ scored.sort((a, b) => b.score - a.score);
259
+ const results = scored.slice(0, opts.limit ?? 20).map(({ e, preview }) => ({
260
+ key: e.key, title: e.title, type: e.type, version: e.currentVersion, preview,
261
+ }));
262
+ return { results };
263
+ }
264
+ // ── history ───────────────────────────────────────────────────────────────────
265
+ function localVaultHistory(cfg, key) {
266
+ const idx = readIndex(cfg);
267
+ const e = idx.entries[key];
268
+ if (!e)
269
+ throw notFound(key);
270
+ return {
271
+ key: e.key, title: e.title, currentVersion: e.currentVersion,
272
+ history: [...e.versions].sort((a, b) => b.version - a.version),
273
+ };
274
+ }
275
+ // ── diff ──────────────────────────────────────────────────────────────────────
276
+ function lineDiff(a, b) {
277
+ const al = a.split("\n"), bl = b.split("\n");
278
+ const m = al.length, n = bl.length;
279
+ // LCS length table (bottom-up) - fine for the artifact sizes a vault holds.
280
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
281
+ for (let i = m - 1; i >= 0; i--)
282
+ for (let j = n - 1; j >= 0; j--)
283
+ dp[i][j] = al[i] === bl[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
284
+ const out = [];
285
+ let i = 0, j = 0;
286
+ while (i < m && j < n) {
287
+ if (al[i] === bl[j]) {
288
+ out.push(` ${al[i]}`);
289
+ i++;
290
+ j++;
291
+ }
292
+ else if (dp[i + 1][j] >= dp[i][j + 1]) {
293
+ out.push(`- ${al[i]}`);
294
+ i++;
295
+ }
296
+ else {
297
+ out.push(`+ ${bl[j]}`);
298
+ j++;
299
+ }
300
+ }
301
+ while (i < m)
302
+ out.push(`- ${al[i++]}`);
303
+ while (j < n)
304
+ out.push(`+ ${bl[j++]}`);
305
+ return out.join("\n");
306
+ }
307
+ function localVaultDiff(cfg, key, fromVersion, toVersion) {
308
+ const idx = readIndex(cfg);
309
+ const e = idx.entries[key];
310
+ if (!e)
311
+ throw notFound(key);
312
+ const has = (v) => e.versions.some((x) => x.version === v);
313
+ if (!has(fromVersion) || !has(toVersion)) {
314
+ throw new Error(`version range v${fromVersion}->v${toVersion} invalid for ${key}`);
315
+ }
316
+ const from = readContent(cfg, e.dir, fromVersion);
317
+ const to = readContent(cfg, e.dir, toVersion);
318
+ // The LCS diff is O(m*n) in memory - guard against a pathologically large
319
+ // multi-line entry blowing up the process instead of returning a diff.
320
+ const la = from.split("\n").length, lb = to.split("\n").length;
321
+ if (la * lb > 4000000) {
322
+ return { key, diff: `(v${fromVersion}: ${la} lines · v${toVersion}: ${lb} lines — too large for a line-by-line diff)` };
323
+ }
324
+ return { key, diff: lineDiff(from, to) };
325
+ }
326
+ // ── export ────────────────────────────────────────────────────────────────────
327
+ function localVaultExport(cfg, type) {
328
+ const idx = readIndex(cfg);
329
+ let rows = Object.values(idx.entries).filter((e) => e.type !== "credential");
330
+ if (type)
331
+ rows = rows.filter((e) => e.type === type);
332
+ const entries = rows.map((e) => ({
333
+ key: e.key, title: e.title, type: e.type, version: e.currentVersion,
334
+ content: readContent(cfg, e.dir, e.currentVersion),
335
+ }));
336
+ return { exportedAt: now(), totalEntries: entries.length, entries };
337
+ }
338
+ // ── pin / delete / tag ────────────────────────────────────────────────────────
339
+ function localVaultPin(cfg, key, pinned) {
340
+ const idx = readIndex(cfg);
341
+ const e = idx.entries[key];
342
+ if (!e)
343
+ throw notFound(key);
344
+ e.pinned = pinned;
345
+ writeIndex(cfg, idx);
346
+ return { pinned };
347
+ }
348
+ function localVaultDelete(cfg, key) {
349
+ const idx = readIndex(cfg);
350
+ const e = idx.entries[key];
351
+ if (!e)
352
+ throw notFound(key);
353
+ const versionsRemoved = e.versions.length;
354
+ try {
355
+ fs.rmSync(path.join(cfg.dir, "data", e.dir), { recursive: true, force: true });
356
+ }
357
+ catch { /* best effort */ }
358
+ delete idx.entries[key];
359
+ idx.links = idx.links.filter((l) => l.fromKey !== key && l.toKey !== key);
360
+ writeIndex(cfg, idx);
361
+ return { versionsRemoved };
362
+ }
363
+ function localVaultTag(cfg, key, tags, replace) {
364
+ const idx = readIndex(cfg);
365
+ const e = idx.entries[key];
366
+ if (!e)
367
+ throw notFound(key);
368
+ e.tags = replace ? Array.from(new Set(tags)) : Array.from(new Set([...e.tags, ...tags]));
369
+ e.updatedAt = now();
370
+ writeIndex(cfg, idx);
371
+ return { tags: e.tags };
372
+ }
373
+ // ── link / related ────────────────────────────────────────────────────────────
374
+ function localVaultLink(cfg, fromKey, toKey, relation) {
375
+ const idx = readIndex(cfg);
376
+ if (!idx.entries[fromKey])
377
+ throw notFound(fromKey);
378
+ if (!idx.entries[toKey])
379
+ throw notFound(toKey);
380
+ const existing = idx.links.find((l) => l.fromKey === fromKey && l.toKey === toKey);
381
+ if (existing) {
382
+ existing.relation = relation;
383
+ writeIndex(cfg, idx);
384
+ return { updated: true };
385
+ }
386
+ idx.links.push({ fromKey, toKey, relation });
387
+ writeIndex(cfg, idx);
388
+ return { updated: false };
389
+ }
390
+ function localVaultRelated(cfg, key, relation) {
391
+ const idx = readIndex(cfg);
392
+ if (!idx.entries[key])
393
+ throw notFound(key);
394
+ const related = [];
395
+ for (const l of idx.links) {
396
+ if (relation && l.relation !== relation)
397
+ continue;
398
+ if (l.fromKey === key) {
399
+ const t = idx.entries[l.toKey];
400
+ if (t)
401
+ related.push({ key: l.toKey, title: t.title, type: t.type, relation: l.relation, direction: "→" });
402
+ }
403
+ else if (l.toKey === key) {
404
+ const f = idx.entries[l.fromKey];
405
+ if (f)
406
+ related.push({ key: l.fromKey, title: f.title, type: f.type, relation: l.relation, direction: "←" });
407
+ }
408
+ }
409
+ return { key, related };
410
+ }
411
+ // ── credentials (AES-256-GCM, key at ~/.finch/vault/.credkey, 0600) ─────────
412
+ function credKeyPath(cfg) { return path.join(cfg.dir, ".credkey"); }
413
+ function credStorePath(cfg) { return path.join(cfg.dir, "credentials.json"); }
414
+ function getCredKey(cfg) {
415
+ ensureDir(cfg);
416
+ const p = credKeyPath(cfg);
417
+ try {
418
+ return Buffer.from(fs.readFileSync(p, "utf8").trim(), "hex");
419
+ }
420
+ catch {
421
+ const key = crypto.randomBytes(32);
422
+ fs.writeFileSync(p, key.toString("hex"), { mode: 0o600 });
423
+ return key;
424
+ }
425
+ }
426
+ function readCredStore(cfg) {
427
+ try {
428
+ return JSON.parse(fs.readFileSync(credStorePath(cfg), "utf8"));
429
+ }
430
+ catch {
431
+ return {};
432
+ }
433
+ }
434
+ function localVaultStoreCredential(cfg, name, value, description) {
435
+ const key = getCredKey(cfg);
436
+ const iv = crypto.randomBytes(12);
437
+ const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
438
+ const enc = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
439
+ const store = readCredStore(cfg);
440
+ store[name] = { iv: iv.toString("hex"), tag: cipher.getAuthTag().toString("hex"), data: enc.toString("hex"), description, storedAt: new Date(now()).toUTCString() };
441
+ fs.writeFileSync(credStorePath(cfg), JSON.stringify(store, null, 2), { mode: 0o600 });
442
+ return { name, key: `credential/${name}` };
443
+ }
444
+ function localVaultGetCredential(cfg, name) {
445
+ const store = readCredStore(cfg);
446
+ const rec = store[name];
447
+ if (!rec)
448
+ throw notFound(`credential ${name}`);
449
+ const key = getCredKey(cfg);
450
+ const decipher = crypto.createDecipheriv("aes-256-gcm", key, Buffer.from(rec.iv, "hex"));
451
+ decipher.setAuthTag(Buffer.from(rec.tag, "hex"));
452
+ const dec = Buffer.concat([decipher.update(Buffer.from(rec.data, "hex")), decipher.final()]).toString("utf8");
453
+ return { name, value: dec, description: rec.description, storedAt: rec.storedAt };
454
+ }