@finchagentic/mcp 4.6.1 → 4.6.2

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