@finchagentic/mcp 4.1.0 โ†’ 4.2.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.
@@ -0,0 +1,329 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.STAKE_TOOLS = void 0;
4
+ exports.parseStakeAmount = parseStakeAmount;
5
+ exports.handleStakeTool = handleStakeTool;
6
+ const ethers_1 = require("ethers");
7
+ const convex_js_1 = require("../convex.js");
8
+ const config_js_1 = require("../config.js");
9
+ const rh_mcp_js_1 = require("./rh-mcp.js");
10
+ // FINCH staking - MCP wrapper around the EXISTING app/convex staking actions
11
+ // (stake.ts's stakeFINCH/unstakeFINCH, stakeInternal.ts's myStakingStats/
12
+ // globalStats). No staking logic lives here - this only parses a
13
+ // human-friendly amount ("all", "max", "50%", "1m", "100k", a raw number)
14
+ // into wei and calls the backend, exactly like the webapp's Stake page does.
15
+ //
16
+ // IMPORTANT - custodial wallet, not the local signing wallet: unlike
17
+ // base_mcp_swap/rh_mcp_swap (which sign and broadcast with the LOCAL
18
+ // ~/.finch/wallet.json key and need no login), staking moves FINCH from the
19
+ // account's CUSTODIAL wallet (the one shown on the webapp's Wallet page) -
20
+ // stake.ts's actions resolve identity via a real logged-in session, not a
21
+ // wallet signature. Requires `finch login` first; there is no way around
22
+ // this without changing stake.ts's own session-based design, which is out
23
+ // of scope here. The local wallet and the custodial wallet are DIFFERENT
24
+ // addresses with different balances - never conflate them in output.
25
+ //
26
+ // SECURITY: no tool here ever returns a private key or seed phrase, and
27
+ // none ever will - the custodial key stays server-side (Turnkey-enclave /
28
+ // encrypted-at-rest), exactly as it already does for the webapp. "Log in,
29
+ // then stake" is the ceiling of what this integration does; it does not,
30
+ // and must not, add an export path.
31
+ const FINCH_CA = "0xce1981b0431fb495912cab057d2877a290199824";
32
+ const FINCH_DECIMALS = 18;
33
+ const ERC20_BALANCE_ABI = ["function balanceOf(address) view returns (uint256)"];
34
+ // Mirrors app/convex/stake.ts's STAKING_TIERS - fixed constants, not worth a
35
+ // round-trip to the backend to fetch. Keep in sync if the backend tiers change.
36
+ const STAKING_TIERS = {
37
+ 7: { multiplier: 1.0, label: "Flexible" },
38
+ 30: { multiplier: 1.5, label: "Committed" },
39
+ 90: { multiplier: 2.0, label: "Diamond Hands" },
40
+ };
41
+ const VALID_LOCK_TIERS = Object.keys(STAKING_TIERS).map(Number);
42
+ function requireLogin() {
43
+ const token = (0, config_js_1.getSavedToken)();
44
+ return token ? { token } : null;
45
+ }
46
+ const NOT_LOGGED_IN_MSG = "Staking needs a logged-in session (not just the local wallet). Run `finch login` first, then retry.";
47
+ async function finchBalanceOf(address) {
48
+ const provider = await (0, rh_mcp_js_1.rhProviderAsync)();
49
+ const finch = new ethers_1.ethers.Contract(FINCH_CA, ERC20_BALANCE_ABI, provider);
50
+ return await finch.balanceOf(address);
51
+ }
52
+ // Parses "all" | "max" | "50%" | "1m" | "100k" | "100000" | "value 100000"
53
+ // against a live balance. Returns wei as a string, or throws with a message
54
+ // the caller can show verbatim.
55
+ function parseStakeAmount(input, balanceWei) {
56
+ const raw = input.trim().toLowerCase().replace(/^value\s+/, "");
57
+ if (raw === "all" || raw === "max") {
58
+ if (balanceWei <= 0n)
59
+ throw new Error("Wallet has 0 FINCH - nothing to stake.");
60
+ return balanceWei;
61
+ }
62
+ const pctMatch = raw.match(/^(\d+(?:\.\d+)?)\s*%$/);
63
+ if (pctMatch) {
64
+ const pct = parseFloat(pctMatch[1]);
65
+ if (!(pct > 0) || pct > 100)
66
+ throw new Error("Percentage must be between 0 and 100.");
67
+ return (balanceWei * BigInt(Math.round(pct * 100))) / 10000n;
68
+ }
69
+ const suffixMatch = raw.match(/^(\d+(?:\.\d+)?)\s*([km])$/);
70
+ if (suffixMatch) {
71
+ const n = parseFloat(suffixMatch[1]);
72
+ const mult = suffixMatch[2] === "m" ? 1000000 : 1000;
73
+ return ethers_1.ethers.parseUnits((n * mult).toString(), FINCH_DECIMALS);
74
+ }
75
+ const plainMatch = raw.match(/^(\d+(?:\.\d+)?)$/);
76
+ if (plainMatch) {
77
+ return ethers_1.ethers.parseUnits(plainMatch[1], FINCH_DECIMALS);
78
+ }
79
+ throw new Error(`Could not parse amount "${input}". Use "all", "max", a percentage like "50%", ` +
80
+ `a shorthand like "1m" or "100k", or a plain number like "100000".`);
81
+ }
82
+ function fmtFinch(wei) {
83
+ return `${Number(ethers_1.ethers.formatUnits(wei, FINCH_DECIMALS)).toLocaleString("en-US", { maximumFractionDigits: 4 })} FINCH`;
84
+ }
85
+ exports.STAKE_TOOLS = [
86
+ {
87
+ name: "stake_finch_status",
88
+ description: "View your FINCH staking status - custodial wallet balance available to stake, your active stakes " +
89
+ "(amount, lock-up remaining, accrued rewards), and pool-wide stats (total staked, daily USDG reward). " +
90
+ "Requires `finch login` (stakes live on your account's custodial wallet, not the local MCP signing wallet).",
91
+ inputSchema: { type: "object", properties: {}, required: [] },
92
+ },
93
+ {
94
+ name: "stake_finch",
95
+ description: "Stake FINCH from your custodial wallet (the one shown on the webapp's Wallet page) to earn USDG " +
96
+ "rewards. Locks for a fixed period chosen at stake time - unstake is blocked until then, and the " +
97
+ "tier cannot be changed mid-stake. Longer locks earn a higher reward multiplier: " +
98
+ "7 days = 1.0x (default) ยท 30 days = 1.5x ยท 90 days = 2.0x. " +
99
+ "Requires `finch login` first and `confirm: true`. Amount accepts \"all\"/\"max\" (entire " +
100
+ "available balance), a percentage (\"50%\"), a shorthand (\"1m\", \"100k\"), or a plain number " +
101
+ "(\"100000\").",
102
+ inputSchema: {
103
+ type: "object",
104
+ properties: {
105
+ amount: {
106
+ type: "string",
107
+ description: "\"all\" | \"max\" | \"50%\" | \"1m\" | \"100k\" | \"100000\"",
108
+ },
109
+ lockTier: {
110
+ type: "number",
111
+ enum: [7, 30, 90],
112
+ description: "Lock period in days: 7 (1.0x, default), 30 (1.5x), or 90 (2.0x). Omit for 7-day/1.0x.",
113
+ },
114
+ confirm: {
115
+ type: "boolean",
116
+ description: "Must be true to stake - this moves real FINCH out of your custodial wallet.",
117
+ },
118
+ },
119
+ required: ["amount", "confirm"],
120
+ },
121
+ },
122
+ {
123
+ name: "unstake_finch",
124
+ description: "Unstake FINCH back to your custodial wallet, plus any pending USDG rewards. Only works once the " +
125
+ "lock-up period has passed (check stake_finch_status for which stakes are unlockable). Requires " +
126
+ "`finch login` and `confirm: true`. Pass a specific stakeId, or \"all\" to unstake every currently " +
127
+ "unlockable stake in one call (each processed and reported individually).",
128
+ inputSchema: {
129
+ type: "object",
130
+ properties: {
131
+ stakeId: { type: "string", description: "A stake ID from stake_finch_status, or \"all\"" },
132
+ confirm: { type: "boolean", description: "Must be true to unstake." },
133
+ },
134
+ required: ["stakeId", "confirm"],
135
+ },
136
+ },
137
+ {
138
+ name: "stake_auto_restake",
139
+ description: "Turn auto-restake on/off for one of your stakes. When ON, the moment the lock-up ends the stake " +
140
+ "is automatically renewed for another full period at the SAME tier - no FINCH ever leaves the " +
141
+ "treasury, no gas, no confirmation needed each cycle (a background job on the Finch backend does " +
142
+ "this, roughly every 15 minutes). When OFF (default), you instead get a one-time notification that " +
143
+ "the stake is unlockable and it just sits there earning nothing until you run unstake_finch. " +
144
+ "Does NOT auto-compound rewards into the stake - that would require signing from your custodial " +
145
+ "wallet unattended, which this intentionally does not do; claim rewards yourself with " +
146
+ "claimVestedRewards (you'll get a notification when they're ready). Requires `finch login`.",
147
+ inputSchema: {
148
+ type: "object",
149
+ properties: {
150
+ stakeId: { type: "string", description: "A stake ID from stake_finch_status" },
151
+ enabled: { type: "boolean", description: "true to turn auto-restake on, false to turn it off" },
152
+ },
153
+ required: ["stakeId", "enabled"],
154
+ },
155
+ },
156
+ ];
157
+ async function handleStakeTool(name, args) {
158
+ if (name === "stake_finch_status") {
159
+ const login = requireLogin();
160
+ if (!login)
161
+ return { content: [{ type: "text", text: NOT_LOGGED_IN_MSG }], isError: true };
162
+ const data = await (0, convex_js_1.callConvex)("/mcp/stake/status", "GET", undefined, "stake_finch_status");
163
+ if (data.error)
164
+ return { content: [{ type: "text", text: `Error: ${data.error}` }], isError: true };
165
+ const address = data.walletAddress ?? null;
166
+ let balanceWei = null;
167
+ if (address) {
168
+ try {
169
+ balanceWei = await finchBalanceOf(address);
170
+ }
171
+ catch { /* balance is a nice-to-have, don't fail the whole status */ }
172
+ }
173
+ const my = data.myStats;
174
+ const global = data.global;
175
+ const lines = [
176
+ `## FINCH Staking Status`,
177
+ ``,
178
+ address ? `**Custodial wallet:** \`${address}\` ${balanceWei != null ? `(${fmtFinch(balanceWei)} available to stake)` : ""}` : `_Could not resolve custodial wallet address._`,
179
+ ``,
180
+ global ? `**Pool:** ${global.totalStakedHuman.toLocaleString()} FINCH staked by ${global.totalStakers} stakers ยท ${global.dailyRewardUsdg} USDG/day pool` : "",
181
+ `**Lock tiers:** ${VALID_LOCK_TIERS.map((t) => `${t}d/${STAKING_TIERS[t].multiplier}x (${STAKING_TIERS[t].label})`).join(" ยท ")} โ€” pick with \`stake_finch lockTier\`, default 7d/1.0x`,
182
+ ``,
183
+ my ? `**Your stakes:** ${my.activeStakes} active ยท ${my.totalStaked.toLocaleString()} FINCH staked ยท ${my.totalRewards.toFixed(4)} USDG accrued ยท ${my.canUnstakeCount} unlockable now` : "",
184
+ ];
185
+ if (my?.stakes?.length) {
186
+ lines.push(``, `**Detail:**`);
187
+ for (const s of my.stakes) {
188
+ const unlocked = Date.now() >= s.unlockAt;
189
+ const tier = STAKING_TIERS[s.lockTier ?? 7] ?? STAKING_TIERS[7];
190
+ const autoNote = s.status === "staked" ? (s.autoRestake ? " ยท ๐Ÿ”„ auto-restake ON" : "") : "";
191
+ lines.push(`- \`${s._id}\` โ€” ${s.amountHuman} FINCH ยท ${s.lockTier ?? 7}d/${tier.multiplier}x ยท ${s.status}${s.status === "staked" ? (unlocked ? " ยท unlockable now" : ` ยท unlocks ${new Date(s.unlockAt).toISOString().slice(0, 10)}`) : ""}${autoNote}`);
192
+ }
193
+ }
194
+ return { content: [{ type: "text", text: lines.filter(Boolean).join("\n") }] };
195
+ }
196
+ if (name === "stake_finch") {
197
+ const raw = (args ?? {});
198
+ const { amount, confirm } = raw;
199
+ // Coerce defensively - MCP clients commonly send numeric args as JSON
200
+ // strings, and unlike `amount` (a string by design, parsed by
201
+ // parseStakeAmount) or `confirm` (unambiguous boolean), a stray
202
+ // "30" here must not silently mismatch the numeric VALID_LOCK_TIERS
203
+ // check and fall through to the 7-day default with no error.
204
+ const lockTier = raw.lockTier === undefined || raw.lockTier === null || raw.lockTier === ""
205
+ ? undefined
206
+ : Number(raw.lockTier);
207
+ if (!amount)
208
+ return { content: [{ type: "text", text: "amount is required" }], isError: true };
209
+ if (lockTier !== undefined && (!Number.isFinite(lockTier) || !VALID_LOCK_TIERS.includes(lockTier))) {
210
+ return {
211
+ content: [{ type: "text", text: `Invalid lockTier ${JSON.stringify(raw.lockTier)} - must be one of ${VALID_LOCK_TIERS.join(", ")} (days).` }],
212
+ isError: true,
213
+ };
214
+ }
215
+ if (confirm !== true) {
216
+ return {
217
+ content: [{ type: "text", text: "Refusing to stake without confirmation - pass `confirm: true` after reviewing the amount." }],
218
+ isError: true,
219
+ };
220
+ }
221
+ const login = requireLogin();
222
+ if (!login)
223
+ return { content: [{ type: "text", text: NOT_LOGGED_IN_MSG }], isError: true };
224
+ const status = await (0, convex_js_1.callConvex)("/mcp/stake/status", "GET", undefined, "stake_finch_status");
225
+ if (status.error)
226
+ return { content: [{ type: "text", text: `Error: ${status.error}` }], isError: true };
227
+ if (!status.walletAddress)
228
+ return { content: [{ type: "text", text: "Could not resolve your custodial wallet address." }], isError: true };
229
+ let balanceWei;
230
+ try {
231
+ balanceWei = await finchBalanceOf(status.walletAddress);
232
+ }
233
+ catch (e) {
234
+ return { content: [{ type: "text", text: `Could not read FINCH balance: ${e?.message ?? e}` }], isError: true };
235
+ }
236
+ let amountWei;
237
+ try {
238
+ amountWei = parseStakeAmount(amount, balanceWei);
239
+ }
240
+ catch (e) {
241
+ return { content: [{ type: "text", text: e.message }], isError: true };
242
+ }
243
+ if (amountWei > balanceWei) {
244
+ return {
245
+ content: [{ type: "text", text: `Insufficient FINCH. Have ${fmtFinch(balanceWei)}, tried to stake ${fmtFinch(amountWei)}.` }],
246
+ isError: true,
247
+ };
248
+ }
249
+ const result = await (0, convex_js_1.callConvex)("/mcp/stake/stake", "POST", { amountWei: amountWei.toString(), lockTier }, "stake_finch");
250
+ if (result.error)
251
+ return { content: [{ type: "text", text: `Stake failed: ${result.error}` }], isError: true };
252
+ const tier = STAKING_TIERS[lockTier ?? 7];
253
+ return {
254
+ content: [{
255
+ type: "text",
256
+ text: [
257
+ `โœ… Staked ${fmtFinch(amountWei)}`,
258
+ `Lock: ${lockTier ?? 7} days (${tier.label}, ${tier.multiplier}x rewards)`,
259
+ `Stake ID: \`${result.stakeId}\``,
260
+ `Tx: \`${result.txHash}\``,
261
+ `${rh_mcp_js_1.RH_EXPLORER}/tx/${result.txHash}`,
262
+ ].join("\n"),
263
+ }],
264
+ };
265
+ }
266
+ if (name === "unstake_finch") {
267
+ const { stakeId, confirm } = (args ?? {});
268
+ if (!stakeId)
269
+ return { content: [{ type: "text", text: "stakeId is required (or \"all\")" }], isError: true };
270
+ if (confirm !== true) {
271
+ return {
272
+ content: [{ type: "text", text: "Refusing to unstake without confirmation - pass `confirm: true`." }],
273
+ isError: true,
274
+ };
275
+ }
276
+ const login = requireLogin();
277
+ if (!login)
278
+ return { content: [{ type: "text", text: NOT_LOGGED_IN_MSG }], isError: true };
279
+ let targetIds;
280
+ if (stakeId.toLowerCase() === "all") {
281
+ const status = await (0, convex_js_1.callConvex)("/mcp/stake/status", "GET", undefined, "stake_finch_status");
282
+ if (status.error)
283
+ return { content: [{ type: "text", text: `Error: ${status.error}` }], isError: true };
284
+ const now = Date.now();
285
+ targetIds = (status.myStats?.stakes ?? [])
286
+ .filter((s) => s.status === "staked" && now >= s.unlockAt)
287
+ .map((s) => s._id);
288
+ if (!targetIds.length) {
289
+ return { content: [{ type: "text", text: "No stakes are currently unlockable." }] };
290
+ }
291
+ }
292
+ else {
293
+ targetIds = [stakeId];
294
+ }
295
+ const lines = [];
296
+ for (const id of targetIds) {
297
+ const result = await (0, convex_js_1.callConvex)("/mcp/stake/unstake", "POST", { stakeId: id }, "unstake_finch");
298
+ if (result.error) {
299
+ lines.push(`๐Ÿ”ด \`${id}\`: ${result.error}`);
300
+ }
301
+ else {
302
+ lines.push(`โœ… \`${id}\` unstaked โ€” tx \`${result.txHash}\`${result.rewardsTxHash ? ` ยท rewards tx \`${result.rewardsTxHash}\`` : ""}`);
303
+ }
304
+ }
305
+ return { content: [{ type: "text", text: lines.join("\n") }] };
306
+ }
307
+ if (name === "stake_auto_restake") {
308
+ const { stakeId, enabled } = (args ?? {});
309
+ if (!stakeId)
310
+ return { content: [{ type: "text", text: "stakeId is required" }], isError: true };
311
+ if (typeof enabled !== "boolean")
312
+ return { content: [{ type: "text", text: "enabled (true/false) is required" }], isError: true };
313
+ const login = requireLogin();
314
+ if (!login)
315
+ return { content: [{ type: "text", text: NOT_LOGGED_IN_MSG }], isError: true };
316
+ const result = await (0, convex_js_1.callConvex)("/mcp/stake/auto-restake", "POST", { stakeId, enabled }, "stake_auto_restake");
317
+ if (result.error)
318
+ return { content: [{ type: "text", text: `Failed: ${result.error}` }], isError: true };
319
+ return {
320
+ content: [{
321
+ type: "text",
322
+ text: enabled
323
+ ? `๐Ÿ”„ Auto-restake ON for \`${stakeId}\`. It'll renew itself for another full period at the same tier every time it unlocks - no action needed from you.`
324
+ : `โธ๏ธ Auto-restake OFF for \`${stakeId}\`. You'll get a notification when it unlocks instead, and it'll just sit there until you run \`unstake_finch\`.`,
325
+ }],
326
+ };
327
+ }
328
+ return null;
329
+ }
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.VAULT_TOOLS = void 0;
4
+ exports.buildVaultList = buildVaultList;
5
+ exports.buildVaultSearch = buildVaultSearch;
4
6
  exports.handleVaultTool = handleVaultTool;
5
7
  const zod_1 = require("zod");
6
8
  const convex_js_1 = require("../convex.js");
@@ -280,6 +282,35 @@ function formatBytes(n) {
280
282
  function formatDate(ts) {
281
283
  return new Date(ts).toUTCString();
282
284
  }
285
+ // โ”€โ”€ Structured output builders (schemas in output-schemas.ts) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
286
+ function buildVaultList(entries, type) {
287
+ return {
288
+ type: type ?? null,
289
+ count: entries.length,
290
+ entries: entries.map((e) => ({
291
+ key: e.key,
292
+ title: e.title ?? null,
293
+ type: e.type ?? null,
294
+ version: e.version ?? null,
295
+ size: e.size ?? null,
296
+ updatedAt: e.updatedAt ?? null,
297
+ isPinned: !!e.isPinned,
298
+ })),
299
+ };
300
+ }
301
+ function buildVaultSearch(query, results) {
302
+ return {
303
+ query,
304
+ count: results.length,
305
+ results: results.map((r) => ({
306
+ key: r.key,
307
+ title: r.title ?? null,
308
+ type: r.type ?? null,
309
+ score: r.score ?? null,
310
+ preview: r.preview ?? null,
311
+ })),
312
+ };
313
+ }
283
314
  // โ”€โ”€โ”€ Handler โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
284
315
  async function handleVaultTool(name, args) {
285
316
  // When the user has opted into a fully-local, user-owned vault
@@ -292,6 +323,20 @@ async function handleVaultTool(name, args) {
292
323
  const parsed = SaveSchema.safeParse(args);
293
324
  if (!parsed.success)
294
325
  return { content: [{ type: "text", text: `Invalid input: ${parsed.error.issues[0].message}` }], isError: true };
326
+ if (parsed.data.type === "credential") {
327
+ // vault_save writes plaintext to disk/DB - "credential" is only a
328
+ // valid FILTER value for vault_list/search/export (which correctly
329
+ // exclude it), never a valid type to actually SAVE through here.
330
+ // vault_store_credential is the only path that encrypts at rest.
331
+ return {
332
+ content: [{
333
+ type: "text",
334
+ text: "Use `vault_store_credential` to save a secret - it encrypts at rest (AES-256-GCM). " +
335
+ "`vault_save` writes plaintext, so `type: \"credential\"` is refused here.",
336
+ }],
337
+ isError: true,
338
+ };
339
+ }
295
340
  // Auto-generate title from content if not provided
296
341
  const firstLine = parsed.data.content.split("\n")[0].replace(/^#+\s*/, "").slice(0, 80);
297
342
  const autoTitle = parsed.data.title ?? (firstLine || `${parsed.data.type} - ${new Date().toISOString().slice(0, 10)}`);
@@ -383,17 +428,14 @@ async function handleVaultTool(name, args) {
383
428
  }
384
429
  return { content: [{ type: "text", text: `vault_read error: ${data.error}` }], isError: true };
385
430
  }
386
- // Large entries are offloaded to Convex File Storage. The doc holds a
387
- // preview only; pull the real content from /vault/blob.
388
- let fullContent = data.content ?? "";
389
- if (data.contentFileId) {
390
- try {
391
- fullContent = await (0, convex_js_1.callConvexRaw)(`/vault/blob?id=${encodeURIComponent(data.contentFileId)}`, "vault_read");
392
- }
393
- catch (err) {
394
- fullContent = (data.content ?? "") + `\n\n_(could not load full blob: ${err.message})_`;
395
- }
396
- }
431
+ // NOTE: there is no blob-storage tier on the backend (see
432
+ // app/convex/vault.ts MAX_CONTENT_BYTES comment) - oversized content is
433
+ // rejected at save time, not offloaded to file storage, so `contentFileId`
434
+ // never comes back on a vault entry. A `/vault/blob` fallback used to live
435
+ // here but the route was never registered in http.ts either, so it was
436
+ // dead in both directions - removed rather than fixed against a storage
437
+ // tier that doesn't exist. Re-add only alongside building that tier.
438
+ const fullContent = data.content ?? "";
397
439
  const sizeLabel = data.originalSize ? formatBytes(data.originalSize) : formatBytes(data.size);
398
440
  const backlinksBlock = Array.isArray(data.backlinks) && data.backlinks.length > 0
399
441
  ? `\n๐Ÿ”™ Linked from (${data.backlinks.length}):\n${data.backlinks.map((b) => ` โ† \`${b.key}\`${b.title ? ` - ${b.title}` : ""}`).join("\n")}`
@@ -433,11 +475,18 @@ async function handleVaultTool(name, args) {
433
475
  if (data.error)
434
476
  return { content: [{ type: "text", text: `Error: ${data.error}` }], isError: true };
435
477
  const entries = data.entries ?? [];
436
- if (!entries.length)
437
- return { content: [{ type: "text", text: `No vault entries found${parsed.data.type ? ` of type '${parsed.data.type}'` : ""}.` }] };
478
+ if (!entries.length) {
479
+ return {
480
+ content: [{ type: "text", text: `No vault entries found${parsed.data.type ? ` of type '${parsed.data.type}'` : ""}.` }],
481
+ structuredContent: buildVaultList([], parsed.data.type),
482
+ };
483
+ }
438
484
  const header = `๐Ÿ“š **Finch Vault** (${entries.length} entries)`;
439
485
  const rows = entries.map((e) => `${e.isPinned ? "๐Ÿ“Œ " : ""}[\`${e.key}\`] ${e.title} - v${e.version} ยท ${e.type} ยท ${formatBytes(e.size)} ยท ${formatDate(e.updatedAt)}`);
440
- return { content: [{ type: "text", text: [header, "", ...rows].join("\n") }] };
486
+ return {
487
+ content: [{ type: "text", text: [header, "", ...rows].join("\n") }],
488
+ structuredContent: buildVaultList(entries, parsed.data.type),
489
+ };
441
490
  }
442
491
  case "vault_search": {
443
492
  const parsed = SearchSchema.safeParse(args);
@@ -449,7 +498,12 @@ async function handleVaultTool(name, args) {
449
498
  // entries are indexed as multiple chunks tagged with isVaultChunk +
450
499
  // vaultKey - group chunks back to their parent entry so the result
451
500
  // list shows one row per entry, not one row per chunk.
452
- {
501
+ //
502
+ // Skipped entirely when vaultBackend is local - a local vault's whole
503
+ // point is "no network," so the query string must never leave the
504
+ // machine, not even to check for results before falling back to the
505
+ // (always-local) full-text branch below.
506
+ if (!localVault) {
453
507
  const limit = parsed.data.limit ?? 20;
454
508
  // Over-fetch so that after chunk dedup we still have ~limit rows.
455
509
  const smResults = await (0, memory_js_1.searchSupermemory)(parsed.data.query, Math.min(50, limit * 3));
@@ -489,7 +543,7 @@ async function handleVaultTool(name, args) {
489
543
  const grouped = Array.from(groups.values())
490
544
  .sort((a, b) => b.bestScore - a.bestScore)
491
545
  .slice(0, limit);
492
- const header = `๐Ÿ” **Vault Search** [Semantic]: "${parsed.data.query}" - ${grouped.length} entry/entries`;
546
+ const header = `๐Ÿ” **Vault Search**: "${parsed.data.query}" - ${grouped.length} entry/entries`;
493
547
  const rows = grouped.map((g, i) => {
494
548
  const score = g.bestScore ? ` ${(g.bestScore * 100).toFixed(0)}%` : "";
495
549
  const chunkBadge = g.isVaultChunk && g.chunkHits > 1
@@ -500,7 +554,10 @@ async function handleVaultTool(name, args) {
500
554
  ` ${g.bestPreview}${g.bestPreview.length >= 200 ? "โ€ฆ" : ""}`,
501
555
  ].join("\n");
502
556
  });
503
- return { content: [{ type: "text", text: [header, "", ...rows].join("\n") }] };
557
+ return {
558
+ content: [{ type: "text", text: [header, "", ...rows].join("\n") }],
559
+ structuredContent: buildVaultSearch(parsed.data.query, grouped.map((g) => ({ key: g.key, title: g.title, type: g.type, score: g.bestScore, preview: g.bestPreview }))),
560
+ };
504
561
  }
505
562
  }
506
563
  }
@@ -516,14 +573,21 @@ async function handleVaultTool(name, args) {
516
573
  if (data.error)
517
574
  return { content: [{ type: "text", text: `Error: ${data.error}` }], isError: true };
518
575
  const results = data.results ?? [];
519
- if (!results.length)
520
- return { content: [{ type: "text", text: `No vault entries found for: "${parsed.data.query}"` }] };
576
+ if (!results.length) {
577
+ return {
578
+ content: [{ type: "text", text: `No vault entries found for: "${parsed.data.query}"` }],
579
+ structuredContent: buildVaultSearch(parsed.data.query, []),
580
+ };
581
+ }
521
582
  const header = `๐Ÿ” **Vault Search**: "${parsed.data.query}" - ${results.length} result(s)`;
522
583
  const rows = results.map((r, i) => [
523
584
  `${i + 1}. [\`${r.key}\`] **${r.title}** (${r.type} ยท v${r.version})`,
524
585
  ` ${r.preview}`,
525
586
  ].join("\n"));
526
- return { content: [{ type: "text", text: [header, "", ...rows].join("\n") }] };
587
+ return {
588
+ content: [{ type: "text", text: [header, "", ...rows].join("\n") }],
589
+ structuredContent: buildVaultSearch(parsed.data.query, results),
590
+ };
527
591
  }
528
592
  case "vault_history": {
529
593
  const parsed = HistorySchema.safeParse(args);
@@ -714,7 +778,21 @@ async function handleVaultTool(name, args) {
714
778
  : await (0, convex_js_1.callConvex)("/vault/delete", "POST", { key: parsed.data.key }, "vault_delete");
715
779
  if (data.error)
716
780
  return { content: [{ type: "text", text: `Error: ${data.error}` }], isError: true };
717
- return { content: [{ type: "text", text: `๐Ÿ—‘๏ธ Deleted: \`${parsed.data.key}\` (${data.versionsRemoved ?? 0} versions removed)` }] };
781
+ // vault_save mirrors non-credential entries into memory for search - a
782
+ // "PERMANENT... cannot be undone" delete that leaves that mirror intact
783
+ // is not actually permanent. Only relevant for the local memory-file
784
+ // backend (the hosted Convex path cleans its own memories table inside
785
+ // the /vault/delete mutation itself, same request, no separate call).
786
+ let memoriesRemoved = 0;
787
+ const localMem = (0, local_memory_js_1.getLocalMemoryConfig)();
788
+ if (localMem) {
789
+ memoriesRemoved = (0, local_memory_js_1.localMemoryDeleteByVaultKey)(localMem, parsed.data.key);
790
+ }
791
+ else if (typeof data.memoriesRemoved === "number") {
792
+ memoriesRemoved = data.memoriesRemoved;
793
+ }
794
+ const memoryNote = memoriesRemoved > 0 ? ` + ${memoriesRemoved} memory mirror${memoriesRemoved === 1 ? "" : "s"} removed` : "";
795
+ return { content: [{ type: "text", text: `๐Ÿ—‘๏ธ Deleted: \`${parsed.data.key}\` (${data.versionsRemoved ?? 0} versions removed${memoryNote})` }] };
718
796
  }
719
797
  case "vault_tag": {
720
798
  const parsed = TagSchema.safeParse(args);