@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,1046 @@
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.MEMORY_TOOLS = void 0;
37
+ exports.syncToSupermemory = syncToSupermemory;
38
+ exports.searchSupermemory = searchSupermemory;
39
+ exports.fuseRRF = fuseRRF;
40
+ exports.hybridMemorySearch = hybridMemorySearch;
41
+ exports.buildMemorySearch = buildMemorySearch;
42
+ exports.buildMemoryContext = buildMemoryContext;
43
+ exports.buildMemoryProfile = buildMemoryProfile;
44
+ exports.buildMemoryList = buildMemoryList;
45
+ exports.handleMemoryTool = handleMemoryTool;
46
+ const zod_1 = require("zod");
47
+ const crypto = __importStar(require("crypto"));
48
+ const convex_js_1 = require("../convex.js");
49
+ const public_url_js_1 = require("../public-url.js");
50
+ const local_memory_js_1 = require("../local-memory.js");
51
+ // memory_extract and memory_consolidate used to run their own LLM calls here
52
+ // (and a matching pair of Convex routes did the same server-side). Both are now
53
+ // two-pass: the tool fetches and stores, the caller decides what the facts are
54
+ // and how they merge. Local mode no longer needs a provider key at all.
55
+ // ─── Helpers (proxied through Convex - server-side Supermemory key) ──────────
56
+ // Normalize content for dedup: lowercase + collapse whitespace + trim. This
57
+ // catches accidental duplicates from agents that re-emit the same fact with
58
+ // different leading/trailing whitespace or casing.
59
+ function contentHash(content) {
60
+ const normalized = content.toLowerCase().replace(/\s+/g, " ").trim();
61
+ return crypto.createHash("sha256").update(normalized).digest("hex").slice(0, 16);
62
+ }
63
+ // In-process LRU cache of content hashes added in this session. Supermemory
64
+ // is eventually consistent - calling memory_add twice rapidly with the same
65
+ // content would otherwise both succeed because /memory/list doesn't see
66
+ // the just-inserted entry yet. The cache closes that race so same-session
67
+ // duplicates are caught even before the index sees them.
68
+ const RECENT_HASH_TTL_MS = 60 * 60 * 1000; // 1 hour
69
+ const RECENT_HASH_MAX = 500;
70
+ const recentHashCache = new Map();
71
+ function rememberRecentHash(hash, id, title) {
72
+ // Re-insert moves it to the end of the Map (most-recently-used).
73
+ recentHashCache.delete(hash);
74
+ recentHashCache.set(hash, { id, title, addedAt: Date.now() });
75
+ while (recentHashCache.size > RECENT_HASH_MAX) {
76
+ const oldest = recentHashCache.keys().next().value;
77
+ if (oldest === undefined)
78
+ break;
79
+ recentHashCache.delete(oldest);
80
+ }
81
+ }
82
+ function lookupRecentHash(hash) {
83
+ const hit = recentHashCache.get(hash);
84
+ if (!hit)
85
+ return null;
86
+ if (Date.now() - hit.addedAt > RECENT_HASH_TTL_MS) {
87
+ recentHashCache.delete(hash);
88
+ return null;
89
+ }
90
+ return hit;
91
+ }
92
+ // Two-tier dedup lookup: in-process cache first (catches same-session
93
+ // dupes during eventual-consistency window), then a list call (catches
94
+ // cross-session dupes once they've been indexed). Returns null on any
95
+ // failure - dedup must never block legitimate writes.
96
+ async function findDuplicateMemory(hash) {
97
+ const cached = lookupRecentHash(hash);
98
+ if (cached)
99
+ return cached;
100
+ try {
101
+ const local = (0, local_memory_js_1.getLocalMemoryConfig)();
102
+ const results = local
103
+ ? await (0, local_memory_js_1.localMemoryList)(local, 50)
104
+ : ((await (0, convex_js_1.callConvex)("/memory/list", "POST", { n: 50 }))?.results ?? []);
105
+ const match = results.find((r) => r.metadata?.contentHash === hash);
106
+ if (!match)
107
+ return null;
108
+ return { id: match.id, title: match.metadata?.title, addedAt: match.metadata?.addedAt };
109
+ }
110
+ catch {
111
+ return null;
112
+ }
113
+ }
114
+ const SYNC_RETRY_DELAYS_MS = [500, 2000, 5000];
115
+ async function syncToSupermemory(content, metadata, sourceUrl) {
116
+ const local = (0, local_memory_js_1.getLocalMemoryConfig)();
117
+ let lastError = null;
118
+ if (local) {
119
+ // Same retry shape as the Convex path below - a transient local-server
120
+ // hiccup (e.g. mid-restart) shouldn't silently drop a vault_save-driven
121
+ // memory sync with only a console.error nobody sees.
122
+ for (let attempt = 0; attempt <= SYNC_RETRY_DELAYS_MS.length; attempt++) {
123
+ try {
124
+ await (0, local_memory_js_1.localMemoryAdd)(local, content, metadata, sourceUrl);
125
+ return;
126
+ }
127
+ catch (err) {
128
+ lastError = err;
129
+ const delay = SYNC_RETRY_DELAYS_MS[attempt];
130
+ if (delay === undefined)
131
+ break;
132
+ await new Promise((r) => setTimeout(r, delay));
133
+ }
134
+ }
135
+ const errMsg = lastError instanceof Error ? lastError.message : String(lastError);
136
+ const preview = content.slice(0, 120).replace(/\s+/g, " ");
137
+ console.error(`[memory] local sync_failed after ${SYNC_RETRY_DELAYS_MS.length + 1} attempts: ${errMsg} | preview: "${preview}"`);
138
+ // Chronicle has no local equivalent yet, so the failure is still logged
139
+ // centrally (best-effort) even in local-memory mode - this is audit
140
+ // trail, not memory content, so it doesn't defeat the local-mode intent.
141
+ await (0, convex_js_1.callConvex)("/chronicle/add", "POST", {
142
+ type: "system",
143
+ title: `Local memory sync failed after ${SYNC_RETRY_DELAYS_MS.length + 1} attempts`,
144
+ detail: `Error: ${errMsg}\nPreview: ${preview}`,
145
+ metadata: { ...metadata, sourceUrl, attempts: SYNC_RETRY_DELAYS_MS.length + 1, kind: "memory_sync_failed", backend: "local" },
146
+ source: "mcp",
147
+ }).catch(() => { });
148
+ return;
149
+ }
150
+ const payload = {
151
+ content,
152
+ metadata,
153
+ ...(sourceUrl ? { sourceUrl } : {}),
154
+ };
155
+ for (let attempt = 0; attempt <= SYNC_RETRY_DELAYS_MS.length; attempt++) {
156
+ try {
157
+ await (0, convex_js_1.callConvex)("/memory/add", "POST", payload);
158
+ return;
159
+ }
160
+ catch (err) {
161
+ lastError = err;
162
+ const delay = SYNC_RETRY_DELAYS_MS[attempt];
163
+ if (delay === undefined)
164
+ break;
165
+ await new Promise((r) => setTimeout(r, delay));
166
+ }
167
+ }
168
+ const errMsg = lastError instanceof Error ? lastError.message : String(lastError);
169
+ const preview = content.slice(0, 120).replace(/\s+/g, " ");
170
+ await (0, convex_js_1.callConvex)("/chronicle/add", "POST", {
171
+ type: "system",
172
+ title: `Supermemory sync failed after ${SYNC_RETRY_DELAYS_MS.length + 1} attempts`,
173
+ detail: `Error: ${errMsg}\nPreview: ${preview}`,
174
+ metadata: { ...metadata, sourceUrl, attempts: SYNC_RETRY_DELAYS_MS.length + 1, kind: "memory_sync_failed" },
175
+ source: "mcp",
176
+ }).catch(() => {
177
+ // Chronicle write itself failed - last-resort console; supermemory is best-effort.
178
+ console.error(`[memory] sync_failed: ${errMsg} | preview: "${preview}"`);
179
+ });
180
+ }
181
+ async function searchSupermemory(query, limit = 10) {
182
+ try {
183
+ const local = (0, local_memory_js_1.getLocalMemoryConfig)();
184
+ if (local)
185
+ return await (0, local_memory_js_1.localMemorySearch)(local, query, limit);
186
+ const data = await (0, convex_js_1.callConvex)("/memory/search", "POST", { q: query, n: limit });
187
+ return data?.results ?? [];
188
+ }
189
+ catch {
190
+ return [];
191
+ }
192
+ }
193
+ // Lexical (full-text / BM25-style) search over the Convex memories mirror.
194
+ // Returns rows in relevance-rank order so the fusion step can use rank
195
+ // directly without re-normalizing raw scores.
196
+ async function lexicalSearch(query, limit = 30) {
197
+ try {
198
+ const data = await (0, convex_js_1.callConvex)("/memory/lexical", "POST", { q: query, n: limit });
199
+ const rows = (data?.results ?? []);
200
+ return rows.map((r, idx) => ({
201
+ id: r.id,
202
+ content: r.content ?? "",
203
+ metadata: r.metadata ?? {},
204
+ rank: typeof r.rank === "number" ? r.rank : idx,
205
+ }));
206
+ }
207
+ catch {
208
+ return [];
209
+ }
210
+ }
211
+ // Reciprocal Rank Fusion - combines two ranked lists into one without needing
212
+ // to normalize raw scores. Each list contributes 1/(k + rank) to a doc's
213
+ // final score; docs that appear in both lists rank above docs in only one.
214
+ // k=60 is the canonical TREC/Lucene default; ours can be tuned via the eval
215
+ // suite. See: "Reciprocal Rank Fusion outperforms Condorcet and individual
216
+ // Rank Learning Methods" (Cormack et al., SIGIR 2009).
217
+ const RRF_K = 60;
218
+ function fuseRRF(semantic, lexical, k = RRF_K) {
219
+ const merged = new Map();
220
+ semantic.forEach((doc, rank) => {
221
+ merged.set(doc.id, {
222
+ id: doc.id,
223
+ content: doc.content,
224
+ metadata: doc.metadata,
225
+ fusedScore: 1 / (k + rank),
226
+ semanticRank: rank,
227
+ lexicalRank: null,
228
+ score: doc.score,
229
+ });
230
+ });
231
+ lexical.forEach((doc) => {
232
+ const existing = merged.get(doc.id);
233
+ const lexContrib = 1 / (k + doc.rank);
234
+ if (existing) {
235
+ existing.fusedScore += lexContrib;
236
+ existing.lexicalRank = doc.rank;
237
+ }
238
+ else {
239
+ merged.set(doc.id, {
240
+ id: doc.id,
241
+ content: doc.content,
242
+ metadata: doc.metadata,
243
+ fusedScore: lexContrib,
244
+ semanticRank: null,
245
+ lexicalRank: doc.rank,
246
+ });
247
+ }
248
+ });
249
+ return [...merged.values()].sort((a, b) => b.fusedScore - a.fusedScore);
250
+ }
251
+ // Hybrid retrieval: fan out semantic + lexical in parallel, fuse via RRF.
252
+ // Returns the unified candidate list - callers (memory_search) layer their
253
+ // own decay + reranking on top.
254
+ //
255
+ // Local mode skips the separate lexical fusion entirely: the self-hosted
256
+ // supermemory server already does hybrid semantic+lexical search server-side
257
+ // (that's the whole point of the RRF layer here - fusing cloud Supermemory's
258
+ // semantic-only results with Convex's own BM25 mirror table), so re-fusing
259
+ // on top of an already-hybrid result set would be redundant.
260
+ async function hybridMemorySearch(query, limit = 30) {
261
+ const local = (0, local_memory_js_1.getLocalMemoryConfig)();
262
+ if (local) {
263
+ const results = await searchSupermemory(query, limit); // routes to localMemorySearch
264
+ return results.map((r, rank) => ({
265
+ id: r.id,
266
+ content: r.content,
267
+ metadata: r.metadata,
268
+ fusedScore: r.score ?? 1 / (RRF_K + rank),
269
+ semanticRank: rank,
270
+ lexicalRank: null,
271
+ score: r.score,
272
+ }));
273
+ }
274
+ // Over-fetch from each side so the fusion has enough overlap to find
275
+ // co-ranked docs. Each side returns up to `limit` items; the union is
276
+ // capped to keep response size bounded.
277
+ const [semantic, lexical] = await Promise.all([
278
+ searchSupermemory(query, limit),
279
+ lexicalSearch(query, limit),
280
+ ]);
281
+ return fuseRRF(semantic, lexical);
282
+ }
283
+ // ─── Tool definitions ────────────────────────────────────────────────────────
284
+ exports.MEMORY_TOOLS = [
285
+ {
286
+ name: "memory_add",
287
+ description: "Add content to your Finch semantic memory - no setup needed, no extra API keys. " +
288
+ "Unlike vault_save, memory_add is instant: no versioning, no type required. " +
289
+ "Use for notes, decisions, preferences, or anything you want to find later with natural language. " +
290
+ "Pass sourceUrl to fetch and index any web page, GitHub repo, or Notion page automatically - " +
291
+ "searchable in ~30s. Memory is indexed semantically - 'what did I say about ETH yield?' " +
292
+ "will find it even without exact keywords. " +
293
+ "Auto-deduplicates: identical content in your recent 50 memories is skipped (override with force:true).",
294
+ inputSchema: {
295
+ type: "object",
296
+ properties: {
297
+ content: { type: "string", description: "Content to remember - text, markdown, or a note. Use a short title if providing sourceUrl." },
298
+ title: { type: "string", description: "Optional title for this memory" },
299
+ tags: { type: "array", items: { type: "string" }, description: "Tags for grouping" },
300
+ sourceUrl: { type: "string", description: "URL to fetch and index automatically (GitHub, Notion, web page, etc.). Content becomes searchable in ~30s." },
301
+ force: { type: "boolean", description: "Bypass duplicate detection. Set true to allow a second copy of identical content." },
302
+ },
303
+ required: ["content"],
304
+ },
305
+ },
306
+ {
307
+ name: "memory_search",
308
+ description: "Hybrid memory search - fuses semantic (embedding) + lexical (full-text BM25) retrieval via Reciprocal Rank Fusion. " +
309
+ "Catches both meaning matches ('low risk crypto yield' → 'conservative DeFi strategies') and exact-token lookups (env var names, contract addresses, IDs) that pure semantic search misses. " +
310
+ "90-day time-decay weighting on top so recent precise notes outrank stale ones. " +
311
+ "Falls back to semantic-only for memories added before v3.24 (no lexical mirror).",
312
+ inputSchema: {
313
+ type: "object",
314
+ properties: {
315
+ query: { type: "string", description: "Natural language query" },
316
+ limit: { type: "number", description: "Max results (default 10)" },
317
+ },
318
+ required: ["query"],
319
+ },
320
+ },
321
+ {
322
+ name: "memory_context",
323
+ description: "Retrieve the most semantically relevant memories for a topic, formatted as AI-ready context. " +
324
+ "Use at the start of research tasks to prime with everything stored about a topic. " +
325
+ "Uses vector search - finds semantically related content, not just exact keyword matches.",
326
+ inputSchema: {
327
+ type: "object",
328
+ properties: {
329
+ topic: { type: "string", description: "Topic to load context for, e.g. 'ETH liquid staking' or 'user DeFi preferences'" },
330
+ limit: { type: "number", description: "Max entries to include (default 8)" },
331
+ },
332
+ required: ["topic"],
333
+ },
334
+ },
335
+ {
336
+ name: "memory_profile",
337
+ description: "Show your semantic memory stats - total memories stored, your memory space, and connected sources. " +
338
+ "Useful for auditing what Finch knows about you.",
339
+ inputSchema: {
340
+ type: "object",
341
+ properties: {},
342
+ required: [],
343
+ },
344
+ },
345
+ {
346
+ name: "memory_list",
347
+ description: "List your most recent Finch memories without a search query. " +
348
+ "Useful to browse what's stored or audit before clearing. " +
349
+ "Sorted by most recently added.",
350
+ inputSchema: {
351
+ type: "object",
352
+ properties: {
353
+ limit: { type: "number", description: "Max memories to return (default 20)" },
354
+ tag: { type: "string", description: "Optional: filter by tag" },
355
+ },
356
+ required: [],
357
+ },
358
+ },
359
+ {
360
+ name: "memory_delete",
361
+ description: "PERMANENT. Delete a specific memory by its ID — it cannot be recovered. " +
362
+ "Get IDs from memory_search or memory_list. Requires confirm: true. " +
363
+ "Show the user which memory you are about to delete (title/content) and get their " +
364
+ "agreement first — IDs come from search results and are easy to mix up.",
365
+ inputSchema: {
366
+ type: "object",
367
+ properties: {
368
+ id: { type: "string", description: "Memory ID to delete (from memory_search or memory_list results)" },
369
+ confirm: { type: "boolean", description: "Must be true to delete. Guards against irreversible loss." },
370
+ },
371
+ required: ["id", "confirm"],
372
+ },
373
+ },
374
+ {
375
+ name: "memory_insight",
376
+ description: "Get a full intelligence report on any topic - combines semantic memory AND vault entries, " +
377
+ "then identifies knowledge gaps and suggests next actions. " +
378
+ "Use this before starting any research or trade decision to see everything Finch already knows. " +
379
+ "Returns: confidence level, what you know, coverage timeline, gaps, and recommended next steps.",
380
+ inputSchema: {
381
+ type: "object",
382
+ properties: {
383
+ topic: { type: "string", description: "Topic to analyze - token, protocol, strategy, or any concept" },
384
+ depth: { type: "string", enum: ["quick", "standard", "deep"], description: "How many sources to pull (default: standard)" },
385
+ },
386
+ required: ["topic"],
387
+ },
388
+ },
389
+ {
390
+ name: "memory_extract",
391
+ description: "Save discrete facts, preferences and decisions to semantic memory as individually searchable atoms " +
392
+ "instead of one wall of text. Two-pass, no API key needed. " +
393
+ "PASS 1 — call with `text`: returns the text with the extraction rubric. " +
394
+ "PASS 2 — call with `facts: [...]`: stores each fact separately, deduped. " +
395
+ "YOU decide what the facts are; this tool stores them. Best for chat logs, research notes, meeting summaries.",
396
+ inputSchema: {
397
+ type: "object",
398
+ properties: {
399
+ text: { type: "string", description: "PASS 1. Unstructured content to pull facts out of - notes, research, chat logs." },
400
+ facts: {
401
+ type: "array",
402
+ items: { type: "string" },
403
+ description: "PASS 2. The atomic facts you extracted. Each is stored as its own searchable memory. Supplying this skips pass 1 entirely.",
404
+ },
405
+ source: { type: "string", description: "Optional label for where this came from (e.g. 'telegram', 'research', 'meeting')" },
406
+ },
407
+ required: [],
408
+ },
409
+ },
410
+ {
411
+ name: "memory_publish",
412
+ description: "IRREVERSIBLE, PUBLIC. Publish a memory snippet to the Memory Marketplace — visible to " +
413
+ "ALL Finch users at /memory-marketplace. Reversible with vault_unpublish, but only for " +
414
+ "future discovery — anyone who already read it keeps what they saw. " +
415
+ "Requires confirm: true. Never call this on the user's behalf without them explicitly asking " +
416
+ "to publish; re-read the content for anything private (keys, addresses, personal details) first. " +
417
+ "Saved as a public vault entry (type=memory).",
418
+ inputSchema: {
419
+ type: "object",
420
+ properties: {
421
+ title: { type: "string", description: "Short title for the memory (shown publicly in the marketplace)" },
422
+ content: { type: "string", description: "The memory content to share — this becomes PUBLIC" },
423
+ tags: { type: "array", items: { type: "string" }, description: "Optional tags (e.g. ['DeFi', 'Base', 'research'])" },
424
+ authorName: { type: "string", description: "Public display name. Defaults to \"Anonymous\" — do NOT pass a wallet address unless the user asks to be identified." },
425
+ confirm: { type: "boolean", description: "Must be true to publish. Guards against accidental public disclosure." },
426
+ },
427
+ required: ["title", "content", "confirm"],
428
+ },
429
+ },
430
+ {
431
+ name: "memory_consolidate",
432
+ description: "Clean up fragmented knowledge after heavy research sessions. Two-pass, no API key needed. " +
433
+ "PASS 1 — call with `topic`: fetches every memory on that topic and returns them numbered, with the " +
434
+ "merge rubric. " +
435
+ "PASS 2 — call with `topic` + `summary`: saves your merged version as a new consolidated memory. " +
436
+ "Originals always remain intact.",
437
+ inputSchema: {
438
+ type: "object",
439
+ properties: {
440
+ topic: { type: "string", description: "Topic to consolidate memories for (e.g. 'ETH liquid staking', 'Base DeFi')" },
441
+ limit: { type: "number", description: "Max source memories to fetch (default 12)" },
442
+ summary: {
443
+ type: "string",
444
+ description: "PASS 2 only. Your merged summary. Supplying it switches this tool from 'return the memories' to 'save the result'.",
445
+ },
446
+ },
447
+ required: ["topic"],
448
+ },
449
+ },
450
+ ];
451
+ // ─── Zod schemas ─────────────────────────────────────────────────────────────
452
+ const AddSchema = zod_1.z.object({
453
+ content: zod_1.z.string().min(1),
454
+ title: zod_1.z.string().optional(),
455
+ tags: zod_1.z.array(zod_1.z.string()).optional(),
456
+ sourceUrl: zod_1.z.string().url().optional(),
457
+ force: zod_1.z.boolean().optional(),
458
+ });
459
+ const SearchSchema = zod_1.z.object({
460
+ query: zod_1.z.string().min(1),
461
+ limit: zod_1.z.number().optional(),
462
+ });
463
+ const ContextSchema = zod_1.z.object({
464
+ topic: zod_1.z.string().min(1),
465
+ limit: zod_1.z.number().optional(),
466
+ });
467
+ const ListSchema = zod_1.z.object({
468
+ limit: zod_1.z.number().optional(),
469
+ tag: zod_1.z.string().optional(),
470
+ });
471
+ const DeleteMemSchema = zod_1.z.object({ id: zod_1.z.string().min(1) });
472
+ const InsightSchema = zod_1.z.object({
473
+ topic: zod_1.z.string().min(1),
474
+ depth: zod_1.z.enum(["quick", "standard", "deep"]).optional(),
475
+ });
476
+ const ExtractSchema = zod_1.z.object({
477
+ text: zod_1.z.string().min(1).optional(),
478
+ facts: zod_1.z.array(zod_1.z.string().min(1)).min(1).max(50).optional(),
479
+ source: zod_1.z.string().optional(),
480
+ }).refine((v) => !!v.text || !!v.facts, { message: "pass `text` (pass 1) or `facts` (pass 2)" });
481
+ const ConsolidateSchema = zod_1.z.object({
482
+ topic: zod_1.z.string().min(1),
483
+ limit: zod_1.z.number().optional(),
484
+ summary: zod_1.z.string().min(1).optional(),
485
+ });
486
+ // ─── Handler ─────────────────────────────────────────────────────────────────
487
+ // ── Structured output builders (schemas in output-schemas.ts) ───────────────
488
+ function buildMemorySearch(query, decayed) {
489
+ return {
490
+ query,
491
+ count: decayed.length,
492
+ memories: decayed.map((r) => ({
493
+ id: r.id,
494
+ title: r.metadata?.title ?? null,
495
+ content: r.content,
496
+ score: r._decayedScore ?? r.fusedScore ?? null,
497
+ ageDays: r._ageDays != null ? Math.round(r._ageDays) : null,
498
+ pinned: !!r._pinned,
499
+ semanticRank: r.semanticRank ?? null,
500
+ lexicalRank: r.lexicalRank ?? null,
501
+ })),
502
+ };
503
+ }
504
+ function buildMemoryContext(topic, results) {
505
+ return {
506
+ topic,
507
+ count: results.length,
508
+ memories: results.map((r) => ({ title: r.metadata?.title ?? null, content: r.content })),
509
+ };
510
+ }
511
+ function buildMemoryProfile(data) {
512
+ return { space: data?.space ?? null, total: data?.total ?? 0, status: data?.status ?? "unknown" };
513
+ }
514
+ function buildMemoryList(tag, results) {
515
+ return {
516
+ tag: tag ?? null,
517
+ count: results.length,
518
+ memories: results.map((r) => ({ id: r.id, title: r.metadata?.title ?? null, content: r.content })),
519
+ };
520
+ }
521
+ async function handleMemoryTool(name, args) {
522
+ switch (name) {
523
+ case "memory_add": {
524
+ const parsed = AddSchema.safeParse(args);
525
+ if (!parsed.success)
526
+ return { content: [{ type: "text", text: `Invalid input: ${parsed.error.issues[0].message}` }], isError: true };
527
+ const { content, title, tags, sourceUrl, force } = parsed.data;
528
+ // `sourceUrl` is not stored, it is fetched and indexed — by the local
529
+ // memory server on the user's own machine when local mode is on. An
530
+ // unrestricted URL therefore pulls LAN and loopback content into memory,
531
+ // where memory_search reads it straight back out.
532
+ if (sourceUrl) {
533
+ const unsafe = await (0, public_url_js_1.assertPublicUrl)(sourceUrl);
534
+ if (unsafe) {
535
+ return { content: [{ type: "text", text: (0, public_url_js_1.refuseUrlText)(sourceUrl, unsafe) }], isError: true };
536
+ }
537
+ }
538
+ const hash = contentHash(content);
539
+ // Dedup: skip if an identical-content memory exists in recent history,
540
+ // unless caller passed `force: true`. URL-sourced memories skip dedup -
541
+ // the same URL may legitimately be re-indexed after content changes.
542
+ if (!force && !sourceUrl) {
543
+ const existing = await findDuplicateMemory(hash);
544
+ if (existing) {
545
+ const age = existing.addedAt ? Math.max(0, Math.round((Date.now() - existing.addedAt) / 60000)) : null;
546
+ return {
547
+ content: [{
548
+ type: "text",
549
+ text: [
550
+ `↩️ **Duplicate skipped** - identical content already stored.`,
551
+ `Existing ID: \`${existing.id}\`${existing.title ? ` · ${existing.title}` : ""}${age !== null ? ` · added ${age}m ago` : ""}`,
552
+ ``,
553
+ `Override with \`memory_add content: "…" force: true\` if you really want a second copy.`,
554
+ ].join("\n"),
555
+ }],
556
+ };
557
+ }
558
+ }
559
+ const addMetadata = { title, tags, source: "memory_add", addedAt: Date.now(), contentHash: hash };
560
+ const localAdd = (0, local_memory_js_1.getLocalMemoryConfig)();
561
+ const data = localAdd
562
+ ? await (0, local_memory_js_1.localMemoryAdd)(localAdd, content, addMetadata, sourceUrl).catch((err) => ({ error: err.message }))
563
+ : await (0, convex_js_1.callConvex)("/memory/add", "POST", { content, metadata: addMetadata, ...(sourceUrl ? { sourceUrl } : {}) }).catch((err) => ({ error: err.message }));
564
+ if (data?.error)
565
+ return { content: [{ type: "text", text: `Error: ${data.error}` }], isError: true };
566
+ // Cache the hash so an immediate second call with identical content
567
+ // dedupes even before supermemory has indexed the first one.
568
+ if (!sourceUrl)
569
+ rememberRecentHash(hash, data?.id ?? "saved", title);
570
+ return {
571
+ content: [{
572
+ type: "text",
573
+ text: [
574
+ `🧠 **Memory added** - ID: \`${data?.id ?? "saved"}\``,
575
+ title ? `Title: ${title}` : "",
576
+ sourceUrl ? `Source: ${sourceUrl} (indexing in background…)` : "",
577
+ tags?.length ? `Tags: ${tags.join(", ")}` : "",
578
+ ``,
579
+ `Find it with: \`memory_search query: "${(title ?? content).slice(0, 40)}"\``,
580
+ ].filter(Boolean).join("\n"),
581
+ }],
582
+ };
583
+ }
584
+ case "memory_search": {
585
+ const parsed = SearchSchema.safeParse(args);
586
+ if (!parsed.success)
587
+ return { content: [{ type: "text", text: `Invalid input: ${parsed.error.issues[0].message}` }], isError: true };
588
+ const { query, limit = 10 } = parsed.data;
589
+ // Over-fetch so post-decay ranking still has enough material.
590
+ const overfetch = Math.min(50, Math.max(limit * 2, 20));
591
+ // ─── Hybrid retrieval ────────────────────────────────────────────
592
+ // Fan out semantic (Supermemory embedding) + lexical (Convex full-text)
593
+ // in parallel, fuse via Reciprocal Rank Fusion. Falls back gracefully:
594
+ // if lexical is empty (e.g. user has only pre-v3.24 memories) the
595
+ // fused list equals semantic.
596
+ const fused = await hybridMemorySearch(query, overfetch);
597
+ const raw = fused;
598
+ if (!raw.length)
599
+ return { content: [{ type: "text", text: `No memories found for: "${query}"\nTry adding content with \`memory_add\` or \`vault_save\`.` }], structuredContent: buildMemorySearch(query, []) };
600
+ // ─── Time-decay weighting ─────────────────────────────────────────
601
+ // Apply an age-aware multiplier to the fused RRF score so a relevant
602
+ // old casual note doesn't outrank a recent precise one. Half-life of
603
+ // 90 days - a memory loses ~30% relevance over a quarter.
604
+ // Pinned memories (metadata.pinned) bypass decay.
605
+ const HALF_LIFE_DAYS = 90;
606
+ const decayed = raw.map((r) => {
607
+ const ageMs = Date.now() - (r.metadata?.addedAt ?? 0);
608
+ const ageDays = ageMs > 0 ? ageMs / 86400000 : 0;
609
+ const pinned = r.metadata?.pinned === true;
610
+ const decayMul = pinned ? 1 : Math.pow(0.5, ageDays / HALF_LIFE_DAYS);
611
+ return {
612
+ ...r,
613
+ _decayedScore: r.fusedScore * decayMul,
614
+ _ageDays: ageDays,
615
+ _pinned: pinned,
616
+ };
617
+ })
618
+ .sort((a, b) => b._decayedScore - a._decayedScore)
619
+ .slice(0, limit);
620
+ // Hybrid mode is on if any result was lexically ranked too.
621
+ const hybridHits = decayed.filter((r) => r.lexicalRank !== null).length;
622
+ const modeLabel = hybridHits > 0
623
+ ? `Hybrid (semantic + lexical), ${hybridHits} dual-ranked`
624
+ : `Semantic only (lexical empty for this query)`;
625
+ const header = `🔍 **Memory Search**: "${query}" - ${decayed.length} result(s) · ${modeLabel} · time-decay 90d half-life`;
626
+ const rows = decayed.map((r, i) => {
627
+ // Per-result rank annotation: shows whether each hit came from
628
+ // semantic, lexical, or both. Cheap signal of retrieval quality.
629
+ const tags = [];
630
+ if (r.semanticRank !== null)
631
+ tags.push(`sem#${r.semanticRank + 1}`);
632
+ if (r.lexicalRank !== null)
633
+ tags.push(`lex#${r.lexicalRank + 1}`);
634
+ const tagStr = tags.length ? ` (${tags.join(", ")})` : "";
635
+ const score = ` [${(r._decayedScore * 1000).toFixed(0)}]`;
636
+ const title = r.metadata?.title ?? "";
637
+ const ageNote = r._ageDays > 0 ? ` · ${Math.round(r._ageDays)}d` : "";
638
+ const pinBadge = r._pinned ? " 📌" : "";
639
+ const preview = r.content.slice(0, 200).replace(/\n/g, " ");
640
+ return [
641
+ `${i + 1}.${score}${pinBadge}${title ? ` **${title}**` : ""}${ageNote}${tagStr} \`${r.id}\``,
642
+ ` ${preview}${r.content.length > 200 ? "…" : ""}`,
643
+ ].join("\n");
644
+ });
645
+ // ─── Promotion hint when N+ memories cluster on a topic ───────────
646
+ // If 4+ memories surface for one query they're collectively load-
647
+ // bearing - suggest the user promote them to a single versioned vault
648
+ // entry so the knowledge becomes structured and citable.
649
+ let promotionHint = "";
650
+ if (decayed.length >= 4) {
651
+ const topic = query.toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 40);
652
+ promotionHint = [
653
+ ``,
654
+ `💡 **${decayed.length} memories on this topic.** Consider promoting to a vault entry for cleaner versioning + linking:`,
655
+ ``,
656
+ `\`\`\``,
657
+ `memory_consolidate topic="${query}" n=${Math.min(decayed.length, 8)}`,
658
+ `# Then: copy the summary and:`,
659
+ `vault_save type=research key=memory-cluster/${topic} content="<the consolidation>"`,
660
+ `\`\`\``,
661
+ ].join("\n");
662
+ }
663
+ return { content: [{ type: "text", text: [header, "", ...rows, promotionHint].filter(Boolean).join("\n") }], structuredContent: buildMemorySearch(query, decayed) };
664
+ }
665
+ case "memory_context": {
666
+ const parsed = ContextSchema.safeParse(args);
667
+ if (!parsed.success)
668
+ return { content: [{ type: "text", text: `Invalid input: ${parsed.error.issues[0].message}` }], isError: true };
669
+ const { topic, limit = 8 } = parsed.data;
670
+ // v3.25.1: use hybrid retrieval so context loading picks up exact-token
671
+ // matches (env var names, model IDs, contract addresses) that semantic-
672
+ // only would miss. Same fusion as memory_search.
673
+ const results = await hybridMemorySearch(topic, limit);
674
+ if (!results.length)
675
+ return { content: [{ type: "text", text: `No context found for: "${topic}"\nBuild your memory base with vault_save or memory_add.` }], structuredContent: buildMemoryContext(topic, []) };
676
+ const contextParts = results.map((r, i) => {
677
+ const title = r.metadata?.title ? `### ${r.metadata.title}` : `### Memory ${i + 1}`;
678
+ return `${title}\n${r.content}`;
679
+ });
680
+ const summary = results.map(r => r.metadata?.title ?? r.content.slice(0, 50)).join(", ");
681
+ return {
682
+ content: [{
683
+ type: "text",
684
+ text: [
685
+ `🧠 **Context** for: "${topic}" (hybrid retrieval)`,
686
+ `Loaded ${results.length} relevant memories: ${summary}`,
687
+ ``,
688
+ `---`,
689
+ ``,
690
+ contextParts.join("\n\n---\n\n"),
691
+ ].join("\n"),
692
+ }],
693
+ structuredContent: buildMemoryContext(topic, results),
694
+ };
695
+ }
696
+ case "memory_profile": {
697
+ const localProfileCfg = (0, local_memory_js_1.getLocalMemoryConfig)();
698
+ const data = localProfileCfg
699
+ ? await (0, local_memory_js_1.localMemoryProfile)(localProfileCfg).catch(() => null)
700
+ : await (0, convex_js_1.callConvex)("/memory/profile", "GET").catch(() => null);
701
+ const total = data?.total ?? 0;
702
+ const status = data?.status ?? "unknown";
703
+ const space = data?.space ?? "-";
704
+ return {
705
+ content: [{
706
+ type: "text",
707
+ text: [
708
+ `🧠 **Finch Semantic Memory**`,
709
+ ``,
710
+ `Space: \`${space}\``,
711
+ `Total memories: **${total}**`,
712
+ `Status: ${status === "ok" ? "✅ Active" : status === "not_configured" ? "⏳ Setting up" : "⚠️ " + status}`,
713
+ ``,
714
+ `**Auto-synced sources:**`,
715
+ `• vault_save - ✅`,
716
+ `• memory_add (URL indexing) - ✅`,
717
+ `• Google Drive / Gmail / Notion - connect at finchagentic.com`,
718
+ ``,
719
+ `**Capabilities:** Semantic search · Vector context · 81.6% LongMemEval`,
720
+ ].join("\n"),
721
+ }],
722
+ structuredContent: buildMemoryProfile(data),
723
+ };
724
+ }
725
+ case "memory_list": {
726
+ const parsed = ListSchema.safeParse(args ?? {});
727
+ if (!parsed.success)
728
+ return { content: [{ type: "text", text: `Invalid input: ${parsed.error.issues[0].message}` }], isError: true };
729
+ const { limit = 20, tag } = parsed.data;
730
+ const localList = (0, local_memory_js_1.getLocalMemoryConfig)();
731
+ let results;
732
+ if (localList) {
733
+ try {
734
+ results = await (0, local_memory_js_1.localMemoryList)(localList, limit, tag);
735
+ }
736
+ catch (err) {
737
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
738
+ }
739
+ }
740
+ else {
741
+ const data = await (0, convex_js_1.callConvex)("/memory/list", "POST", { n: limit, tag }).catch((err) => ({ error: err.message }));
742
+ if (data?.error)
743
+ return { content: [{ type: "text", text: `Error: ${data.error}` }], isError: true };
744
+ results = data?.results ?? [];
745
+ }
746
+ if (!results.length)
747
+ return { content: [{ type: "text", text: `No memories stored yet. Use \`memory_add\` to start building your knowledge base.` }], structuredContent: buildMemoryList(tag, []) };
748
+ const header = `🧠 **Memories** (${results.length} shown${tag ? `, tag: ${tag}` : ""})`;
749
+ const rows = results.map((r, i) => {
750
+ const title = r.metadata?.title ?? "";
751
+ const preview = r.content.slice(0, 100).replace(/\n/g, " ");
752
+ return `${i + 1}. \`${r.id}\`${title ? ` **${title}**` : ""}\n ${preview}${r.content.length > 100 ? "…" : ""}`;
753
+ });
754
+ return { content: [{ type: "text", text: [header, "", ...rows].join("\n") }], structuredContent: buildMemoryList(tag, results) };
755
+ }
756
+ case "memory_delete": {
757
+ const parsed = DeleteMemSchema.safeParse(args);
758
+ if (!parsed.success)
759
+ return { content: [{ type: "text", text: `Invalid input: ${parsed.error.issues[0].message}` }], isError: true };
760
+ if (args?.confirm !== true) {
761
+ return {
762
+ content: [{
763
+ type: "text",
764
+ text: "Refusing to delete: this permanently removes the memory and cannot be undone. " +
765
+ "Show the user the memory you intend to delete, then pass `confirm: true`.",
766
+ }],
767
+ isError: true,
768
+ };
769
+ }
770
+ const localDel = (0, local_memory_js_1.getLocalMemoryConfig)();
771
+ if (localDel) {
772
+ try {
773
+ await (0, local_memory_js_1.localMemoryDelete)(localDel, parsed.data.id);
774
+ }
775
+ catch (err) {
776
+ return { content: [{ type: "text", text: `Error: ${err.message}` }], isError: true };
777
+ }
778
+ }
779
+ else {
780
+ const data = await (0, convex_js_1.callConvex)("/memory/delete", "POST", { id: parsed.data.id }).catch((err) => ({ error: err.message }));
781
+ if (data?.error)
782
+ return { content: [{ type: "text", text: `Error: ${data.error}` }], isError: true };
783
+ }
784
+ return { content: [{ type: "text", text: `🗑️ Memory deleted: \`${parsed.data.id}\`` }] };
785
+ }
786
+ case "memory_insight": {
787
+ const parsed = InsightSchema.safeParse(args);
788
+ if (!parsed.success)
789
+ return { content: [{ type: "text", text: `Invalid input: ${parsed.error.issues[0].message}` }], isError: true };
790
+ const { topic, depth = "standard" } = parsed.data;
791
+ const memLimit = depth === "deep" ? 15 : depth === "quick" ? 5 : 8;
792
+ // v3.25.1: hybrid retrieval for memory side (was semantic-only) so the
793
+ // intelligence report surfaces exact-token matches alongside meaning
794
+ // matches. Vault side runs in parallel as before.
795
+ const [memResults, vaultData] = await Promise.all([
796
+ hybridMemorySearch(topic, memLimit),
797
+ (0, convex_js_1.callConvex)(`/vault/search?q=${encodeURIComponent(topic)}&limit=6`, "GET", undefined, "memory_insight").catch(() => ({ results: [] })),
798
+ ]);
799
+ const vaultResults = vaultData.results ?? [];
800
+ const total = memResults.length + vaultResults.length;
801
+ if (!total) {
802
+ return {
803
+ content: [{
804
+ type: "text",
805
+ text: [
806
+ `🔮 **Intelligence Report: "${topic}"**`,
807
+ ``,
808
+ `No knowledge found yet.`,
809
+ ``,
810
+ `**Start building:**`,
811
+ `• \`deep_research query: "${topic}" depth: "standard"\` - multi-agent research now`,
812
+ `• \`memory_add content: "..." \` - add a manual note`,
813
+ `• \`schedule_research topic: "${topic}"\` - recurring monitor that saves to vault`,
814
+ ].join("\n"),
815
+ }],
816
+ };
817
+ }
818
+ // Confidence tier
819
+ const confidence = total >= 10 ? "🟢 High" : total >= 4 ? "🟡 Medium" : "🔴 Low";
820
+ // Timeline from metadata timestamps
821
+ const timestamps = memResults.map(r => r.metadata?.addedAt).filter(Boolean);
822
+ const oldest = timestamps.length ? Math.min(...timestamps) : null;
823
+ const newest = timestamps.length ? Math.max(...timestamps) : null;
824
+ const daysSinceUpdate = newest ? Math.round((Date.now() - newest) / 86400000) : null;
825
+ // Knowledge summary lines
826
+ const memLines = memResults.slice(0, 6).map(r => {
827
+ const title = r.metadata?.title ?? r.content.slice(0, 70).replace(/\n/g, " ");
828
+ const score = r.score != null ? ` [${(r.score * 100).toFixed(0)}%]` : "";
829
+ return ` •${score} ${title}`;
830
+ });
831
+ const vaultLines = vaultResults.slice(0, 4).map((r) => ` • [vault/${r.type}] ${r.title} - v${r.version}`);
832
+ // Gap analysis
833
+ const gaps = [];
834
+ if (daysSinceUpdate !== null && daysSinceUpdate > 7) {
835
+ gaps.push(`Stale data - last update ${daysSinceUpdate} day${daysSinceUpdate !== 1 ? "s" : ""} ago`);
836
+ }
837
+ if (!vaultResults.some((r) => r.type === "research")) {
838
+ gaps.push("No formal research saved - only informal notes exist");
839
+ }
840
+ if (memResults.length < 3) {
841
+ gaps.push("Thin coverage - fewer than 3 semantic memories on this topic");
842
+ }
843
+ if (!vaultResults.some((r) => r.type === "execution")) {
844
+ gaps.push("No execution history - no trades or actions logged");
845
+ }
846
+ const lines = [
847
+ `🔮 **Intelligence Report: "${topic}"**`,
848
+ `Confidence: ${confidence} · ${memResults.length} semantic memories · ${vaultResults.length} vault entries`,
849
+ oldest ? `Coverage: ${new Date(oldest).toLocaleDateString("en-US")} – ${daysSinceUpdate === 0 ? "today" : daysSinceUpdate !== null ? `${daysSinceUpdate}d ago` : "unknown"}` : "",
850
+ ``,
851
+ `**What you know:**`,
852
+ ...memLines,
853
+ ...(vaultLines.length ? ["", "**Vault entries:**", ...vaultLines] : []),
854
+ ``,
855
+ ];
856
+ if (gaps.length) {
857
+ lines.push(`**⚠️ Knowledge gaps:**`);
858
+ gaps.forEach(g => lines.push(` • ${g}`));
859
+ lines.push("");
860
+ }
861
+ lines.push(`**Suggested actions:**`);
862
+ if (gaps.some(g => g.includes("research") || g.includes("Stale"))) {
863
+ lines.push(`• \`deep_research query: "${topic}" depth: "standard"\` - refresh with multi-agent research`);
864
+ }
865
+ lines.push(`• \`memory_context topic: "${topic}"\` - inject full context into your next prompt`);
866
+ lines.push(`• \`schedule_research topic: "${topic}"\` - monitor this topic continuously`);
867
+ return { content: [{ type: "text", text: lines.filter(l => l !== undefined).join("\n") }] };
868
+ }
869
+ case "memory_extract": {
870
+ const parsed = ExtractSchema.safeParse(args);
871
+ if (!parsed.success)
872
+ return { content: [{ type: "text", text: `Invalid input: ${parsed.error.issues[0].message}` }], isError: true };
873
+ const { text, facts, source = "extract" } = parsed.data;
874
+ // ── PASS 1: hand the text back with the extraction rubric ───────────
875
+ // Deciding what counts as a fact is judgement about *this user's*
876
+ // content - the caller has the conversation, the tool doesn't.
877
+ if (!facts) {
878
+ return {
879
+ content: [{
880
+ type: "text",
881
+ text: [
882
+ `📄 **Extract facts from this** (${text.length.toLocaleString()} chars)`,
883
+ ``,
884
+ `---`,
885
+ text.slice(0, 20000) + (text.length > 20000 ? `\n\n…[truncated ${text.length - 20000} chars]` : ""),
886
+ `---`,
887
+ ``,
888
+ `## Rules`,
889
+ ``,
890
+ `- One **atomic** fact per entry — independently true and independently searchable.`,
891
+ `- Self-contained: "prefers Aerodrome over Uniswap on Base for stable pairs", not "prefers it".`,
892
+ `- Keep facts, preferences and decisions. Drop pleasantries, restated questions and anything`,
893
+ ` already obvious from the user's other memories.`,
894
+ `- Preserve numbers, dates, addresses and names exactly. Never round or paraphrase them.`,
895
+ `- Do not infer beyond the text. If it wasn't stated, it isn't a fact.`,
896
+ `- 3-10 facts is typical. Fewer is fine — do not pad to hit a count.`,
897
+ ``,
898
+ `Then call \`memory_extract\` again with \`facts: ["…", "…"]\`${source !== "extract" ? ` and \`source: "${source}"\`` : ""} to store them.`,
899
+ ].join("\n"),
900
+ }],
901
+ };
902
+ }
903
+ // ── PASS 2: store each fact as its own memory ───────────────────────
904
+ const localExtractCfg = (0, local_memory_js_1.getLocalMemoryConfig)();
905
+ const metaFor = () => ({ source, addedAt: Date.now() });
906
+ const results = await Promise.allSettled(facts.map((fact) => localExtractCfg
907
+ ? (0, local_memory_js_1.localMemoryAdd)(localExtractCfg, fact, metaFor())
908
+ : (0, convex_js_1.callConvex)("/memory/add", "POST", { content: fact, metadata: metaFor() })));
909
+ const saved = results.filter((r) => r.status === "fulfilled").length;
910
+ const failed = results.length - saved;
911
+ return {
912
+ content: [{
913
+ type: "text",
914
+ text: [
915
+ `🧠 **Stored ${saved} of ${facts.length} facts**${failed ? ` — ${failed} failed` : ""}`,
916
+ ``,
917
+ ...facts.map((f, i) => `${results[i].status === "fulfilled" ? "✓" : "✗"} ${i + 1}. ${f}`),
918
+ ``,
919
+ failed
920
+ ? `Failed writes are usually auth (\`finch login\`) or an unreachable local memory server.`
921
+ : `All facts are now searchable via \`memory_search\`.`,
922
+ ].join("\n"),
923
+ }],
924
+ };
925
+ }
926
+ case "memory_consolidate": {
927
+ const parsed = ConsolidateSchema.safeParse(args);
928
+ if (!parsed.success)
929
+ return { content: [{ type: "text", text: `Invalid input: ${parsed.error.issues[0].message}` }], isError: true };
930
+ const { topic, limit = 12, summary } = parsed.data;
931
+ const localConsolidateCfg = (0, local_memory_js_1.getLocalMemoryConfig)();
932
+ // ── PASS 2: save the caller's merged version ────────────────────────
933
+ if (summary) {
934
+ const meta = { title: `Consolidated: ${topic}`, source: "memory_consolidate", addedAt: Date.now() };
935
+ const saved = localConsolidateCfg
936
+ ? await (0, local_memory_js_1.localMemoryAdd)(localConsolidateCfg, summary, meta).catch((err) => ({ error: err.message }))
937
+ : await (0, convex_js_1.callConvex)("/memory/add", "POST", { content: summary, metadata: meta }).catch((err) => ({ error: err.message }));
938
+ if (saved?.error) {
939
+ return { content: [{ type: "text", text: `Error saving consolidated memory: ${saved.error}` }], isError: true };
940
+ }
941
+ return {
942
+ content: [{
943
+ type: "text",
944
+ text: [
945
+ `🧠 **Consolidated "${topic}" saved** — ID: \`${saved?.id ?? "saved"}\``,
946
+ `The source memories were left intact.`,
947
+ ``,
948
+ `Find it with \`memory_search query: "${topic}"\`.`,
949
+ ].join("\n"),
950
+ }],
951
+ };
952
+ }
953
+ // ── PASS 1: fetch every memory on the topic, numbered ───────────────
954
+ const rows = await searchSupermemory(topic, limit);
955
+ if (rows.length === 0) {
956
+ return { content: [{ type: "text", text: `No memories found for "${topic}" to consolidate.` }], isError: true };
957
+ }
958
+ const numbered = rows.map((r, i) => {
959
+ const title = r.metadata?.title ? `**${r.metadata.title}** — ` : "";
960
+ const when = r.metadata?.addedAt ? ` _(${new Date(r.metadata.addedAt).toISOString().slice(0, 10)})_` : "";
961
+ return `${i + 1}. ${title}${r.content.trim()}${when}`;
962
+ });
963
+ return {
964
+ content: [{
965
+ type: "text",
966
+ text: [
967
+ `🧠 **${rows.length} memories on "${topic}"**`,
968
+ ``,
969
+ ...numbered,
970
+ ``,
971
+ `---`,
972
+ ``,
973
+ `## Merge them`,
974
+ ``,
975
+ `- Fold overlapping facts into one statement; drop verbatim duplicates.`,
976
+ `- **Where two memories conflict, keep both and say which is newer** — dates are shown above.`,
977
+ ` Silently dropping the older one destroys the record of a changed mind.`,
978
+ `- Preserve every number, date, address and name exactly as written.`,
979
+ `- Group by sub-theme if that makes the result easier to search later.`,
980
+ `- Add nothing that isn't in the list above.`,
981
+ ``,
982
+ `Then call \`memory_consolidate\` again with \`topic: "${topic}"\` and \`summary: "<your merged text>"\` ` +
983
+ `to save it. The ${rows.length} originals stay where they are.`,
984
+ ].join("\n"),
985
+ }],
986
+ };
987
+ }
988
+ case "memory_publish": {
989
+ const { title, content, tags, authorName, confirm } = args;
990
+ if (!title || !content)
991
+ return { content: [{ type: "text", text: "title and content are required" }], isError: true };
992
+ if (confirm !== true) {
993
+ return {
994
+ content: [{
995
+ type: "text",
996
+ text: "Refusing to publish: this makes the content **public to all Finch users**. " +
997
+ "It can be hidden again with `vault_unpublish`, but not un-read. Review the content " +
998
+ "for keys, addresses and personal details, then pass `confirm: true`.",
999
+ }],
1000
+ isError: true,
1001
+ };
1002
+ }
1003
+ if (authorName && /^0x[a-fA-F0-9]{40}$/.test(authorName.trim())) {
1004
+ return {
1005
+ content: [{
1006
+ type: "text",
1007
+ text: "Refusing to publish with a wallet address as the author name — that permanently " +
1008
+ "links your on-chain identity to this public entry. Use a handle, or omit authorName " +
1009
+ "to publish as \"Anonymous\".",
1010
+ }],
1011
+ isError: true,
1012
+ };
1013
+ }
1014
+ const data = await (0, convex_js_1.callConvex)("/vault/save", "POST", {
1015
+ type: "memory",
1016
+ title,
1017
+ content,
1018
+ tags: tags ?? [],
1019
+ isPublic: true,
1020
+ authorName: authorName ?? "Anonymous",
1021
+ commitMsg: "published to marketplace",
1022
+ }, "vault_save");
1023
+ if (data.error)
1024
+ return { content: [{ type: "text", text: `Error: ${data.error}` }], isError: true };
1025
+ return {
1026
+ content: [{
1027
+ type: "text",
1028
+ text: [
1029
+ `🧠 **Memory Published**`,
1030
+ ``,
1031
+ `**Title:** ${title}`,
1032
+ `**Key:** \`${data.key}\``,
1033
+ `**Version:** ${data.version ?? 1}`,
1034
+ ``,
1035
+ `Now visible at the Memory Marketplace in the Finch app.`,
1036
+ ``,
1037
+ `Make it private again: \`vault_unpublish key: "${data.key}"\``,
1038
+ `That stops future discovery. Anyone who already read it keeps what they saw.`,
1039
+ ].join("\n"),
1040
+ }],
1041
+ };
1042
+ }
1043
+ default:
1044
+ return null;
1045
+ }
1046
+ }