@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,15 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.dedupClinkInput = dedupClinkInput;
4
+ // Deduplicates doubled input from a known Clink v1.7.6 terminal bug (garbled/
5
+ // doubled keystrokes, e.g. "finch_sk_xxfinch_sk_xx" -> "finch_sk_xx"). Used by
6
+ // every raw-input prompt in cli.ts (login, setup wizard provider/URL prompts)
7
+ // before validating what the user typed.
8
+ function dedupClinkInput(s) {
9
+ if (s.length > 0 && s.length % 2 === 0) {
10
+ const half = s.length / 2;
11
+ if (s.slice(0, half) === s.slice(half))
12
+ return s.slice(0, half);
13
+ }
14
+ return s;
15
+ }
package/dist/config.js ADDED
@@ -0,0 +1,132 @@
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.env = env;
37
+ exports.readConfig = readConfig;
38
+ exports.writeConfig = writeConfig;
39
+ exports.getSavedToken = getSavedToken;
40
+ exports.isApiKey = isApiKey;
41
+ exports.hydrateEnvFromConfig = hydrateEnvFromConfig;
42
+ const fs = __importStar(require("fs"));
43
+ const os = __importStar(require("os"));
44
+ const path = __importStar(require("path"));
45
+ const CONFIG_DIR = path.join(os.homedir(), ".finch");
46
+ const LEGACY_DIR = path.join(os.homedir(), ".noelclaw");
47
+ const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
48
+ const LEGACY_FILE = path.join(LEGACY_DIR, "config.json");
49
+ /** Prefer FINCH_* env, fall back to legacy NOELCLAW_* during rebrand. */
50
+ function env(name, legacy) {
51
+ const primary = process.env[name];
52
+ if (primary !== undefined && primary !== "")
53
+ return primary;
54
+ if (legacy) {
55
+ const v = process.env[legacy];
56
+ if (v !== undefined && v !== "")
57
+ return v;
58
+ }
59
+ // Auto-map FINCH_X โ†’ NOELCLAW_X when legacy not passed
60
+ if (name.startsWith("FINCH_")) {
61
+ const auto = "NOELCLAW_" + name.slice("FINCH_".length);
62
+ const v = process.env[auto];
63
+ if (v !== undefined && v !== "")
64
+ return v;
65
+ }
66
+ return undefined;
67
+ }
68
+ function readConfig() {
69
+ try {
70
+ if (fs.existsSync(CONFIG_FILE)) {
71
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
72
+ }
73
+ // migrate-read: still pick up old ~/.noelclaw/config.json
74
+ if (fs.existsSync(LEGACY_FILE)) {
75
+ return JSON.parse(fs.readFileSync(LEGACY_FILE, "utf8"));
76
+ }
77
+ }
78
+ catch {
79
+ // Missing/corrupt config file - treat as no saved config.
80
+ }
81
+ return {};
82
+ }
83
+ function writeConfig(patch) {
84
+ const current = readConfig();
85
+ const updated = { ...current, ...patch };
86
+ if (!fs.existsSync(CONFIG_DIR))
87
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
88
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(updated, null, 2), { mode: 0o600 });
89
+ }
90
+ function getSavedToken() {
91
+ // env var always wins over saved config
92
+ return env("FINCH_SESSION_TOKEN", "NOELCLAW_SESSION_TOKEN") ?? readConfig().sessionToken;
93
+ }
94
+ /** Accept finch_sk_* (new) and noel_sk_* (backend still issues these). */
95
+ function isApiKey(value) {
96
+ if (!value)
97
+ return false;
98
+ return value.startsWith("finch_sk_") || value.startsWith("noel_sk_");
99
+ }
100
+ // Injects provider keys saved via `finch setup` into process.env, so both
101
+ // the interactive CLI and the MCP server process (spawned fresh by Claude
102
+ // Desktop/Cursor/etc, which never sees ~/.finch/config.json otherwise)
103
+ // pick them up the same way. An env var already set by the parent process
104
+ // always wins - this only fills gaps, never overrides.
105
+ function hydrateEnvFromConfig() {
106
+ const cfg = readConfig();
107
+ if (!process.env.BANKR_API_KEY && cfg.bankrApiKey)
108
+ process.env.BANKR_API_KEY = cfg.bankrApiKey;
109
+ if (!process.env.ANTHROPIC_API_KEY && cfg.anthropicApiKey)
110
+ process.env.ANTHROPIC_API_KEY = cfg.anthropicApiKey;
111
+ if (!process.env.OPENAI_API_KEY && cfg.openaiApiKey)
112
+ process.env.OPENAI_API_KEY = cfg.openaiApiKey;
113
+ if (!process.env.OPENAI_BASE_URL && cfg.openaiBaseUrl)
114
+ process.env.OPENAI_BASE_URL = cfg.openaiBaseUrl;
115
+ // Bridge legacy env into FINCH_* so the rest of the codebase can read one name.
116
+ const bridges = [
117
+ ["FINCH_SESSION_TOKEN", "NOELCLAW_SESSION_TOKEN"],
118
+ ["FINCH_API_KEY", "NOELCLAW_API_KEY"],
119
+ ["FINCH_CONVEX_URL", "NOELCLAW_CONVEX_URL"],
120
+ ["FINCH_TOOLS", "NOELCLAW_TOOLS"],
121
+ ["FINCH_PROVIDER", "NOELCLAW_PROVIDER"],
122
+ ["FINCH_MODEL", "NOELCLAW_MODEL"],
123
+ ["FINCH_RPC_URL", "NOELCLAW_RPC_URL"],
124
+ ["FINCH_BROADCAST_RPC", "NOELCLAW_BROADCAST_RPC"],
125
+ ["FINCH_WALLET_PASSPHRASE", "NOELCLAW_WALLET_PASSPHRASE"],
126
+ ["FINCH_PAYMENT_HEADER", "NOELCLAW_PAYMENT_HEADER"],
127
+ ];
128
+ for (const [fin, leg] of bridges) {
129
+ if (!process.env[fin] && process.env[leg])
130
+ process.env[fin] = process.env[leg];
131
+ }
132
+ }
package/dist/convex.js ADDED
@@ -0,0 +1,151 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.PaymentRequiredError = exports.CONVEX_SITE = void 0;
4
+ exports.buildPaymentHeader = buildPaymentHeader;
5
+ exports.callConvex = callConvex;
6
+ exports.callConvexRaw = callConvexRaw;
7
+ exports.notifyTelegram = notifyTelegram;
8
+ const wallet_js_1 = require("./wallet.js");
9
+ const config_js_1 = require("./config.js");
10
+ exports.CONVEX_SITE = process.env.FINCH_CONVEX_URL ?? "https://befitting-porcupine-276.convex.site";
11
+ const RETRY_STATUSES = new Set([429, 500, 502, 503, 504]);
12
+ const RETRY_DELAYS = [500, 1000, 2000];
13
+ class PaymentRequiredError extends Error {
14
+ constructor(details) {
15
+ super("Payment required");
16
+ this.name = "PaymentRequiredError";
17
+ this.details = details;
18
+ }
19
+ }
20
+ exports.PaymentRequiredError = PaymentRequiredError;
21
+ function buildPaymentHeader(txHash, requestId) {
22
+ return Buffer.from(`${txHash}:${requestId}`).toString("base64");
23
+ }
24
+ async function attemptConvex(url, method, headers, body, timeoutMs = 30000) {
25
+ return fetch(url, {
26
+ method,
27
+ headers,
28
+ body: body ? JSON.stringify(body) : undefined,
29
+ signal: AbortSignal.timeout(timeoutMs),
30
+ });
31
+ }
32
+ async function callConvex(path, method, body, toolName = "unknown", timeoutMs = 30000) {
33
+ const url = `${exports.CONVEX_SITE}${path}`;
34
+ const headers = { "Content-Type": "application/json" };
35
+ const apiKey = process.env.FINCH_API_KEY;
36
+ const sessionToken = (0, config_js_1.getSavedToken)(); // env var โ†’ saved config fallback
37
+ // Prefer session token (resolved by backend) over API key for Convex API calls
38
+ const authHeader = sessionToken
39
+ ? `Bearer ${sessionToken}`
40
+ : apiKey
41
+ ? `Bearer ${apiKey}`
42
+ : null;
43
+ if (authHeader) {
44
+ headers["Authorization"] = authHeader;
45
+ }
46
+ else {
47
+ try {
48
+ const { address, signature, timestamp } = await (0, wallet_js_1.signRequest)(toolName);
49
+ headers["X-Wallet-Address"] = address;
50
+ headers["X-Wallet-Signature"] = signature;
51
+ headers["X-Wallet-Timestamp"] = timestamp;
52
+ }
53
+ catch {
54
+ // continue without wallet headers - server will respond with 401/402
55
+ }
56
+ }
57
+ const paymentHeader = process.env.FINCH_PAYMENT_HEADER;
58
+ if (paymentHeader)
59
+ headers["X-Payment"] = paymentHeader;
60
+ // BYOK headers - user pays for their own AI/service costs
61
+ if (process.env.ANTHROPIC_API_KEY)
62
+ headers["X-User-Anthropic-Key"] = process.env.ANTHROPIC_API_KEY;
63
+ if (process.env.OPENAI_API_KEY)
64
+ headers["X-User-OpenAI-Key"] = process.env.OPENAI_API_KEY;
65
+ if (process.env.GROK_API_KEY)
66
+ headers["X-User-Grok-Key"] = process.env.GROK_API_KEY;
67
+ if (process.env.BANKR_API_KEY)
68
+ headers["X-User-Bankr-Key"] = process.env.BANKR_API_KEY;
69
+ if (process.env.TELEGRAM_BOT_TOKEN)
70
+ headers["X-User-Telegram-Token"] = process.env.TELEGRAM_BOT_TOKEN;
71
+ if (process.env.TELEGRAM_CHAT_ID)
72
+ headers["X-User-Telegram-Chat"] = process.env.TELEGRAM_CHAT_ID;
73
+ let lastError = null;
74
+ for (let attempt = 0; attempt < RETRY_DELAYS.length; attempt++) {
75
+ if (attempt > 0) {
76
+ await new Promise((r) => setTimeout(r, RETRY_DELAYS[attempt - 1]));
77
+ }
78
+ let res;
79
+ try {
80
+ res = await attemptConvex(url, method, headers, body, timeoutMs);
81
+ }
82
+ catch (err) {
83
+ lastError = err;
84
+ continue;
85
+ }
86
+ if (res.status === 402) {
87
+ const b = await res.json().catch(() => ({}));
88
+ throw new PaymentRequiredError(b);
89
+ }
90
+ if (res.status === 401) {
91
+ const b = await res.json().catch(() => ({}));
92
+ throw new Error(`๐Ÿ”‘ ${b.message || "Authentication required"}\n\n` +
93
+ `โ†’ Sign in at: ${b.url || "https://finchagentic.com"}\n\n` +
94
+ `Hint: ${b.hint || 'Add FINCH_SESSION_TOKEN=โ€ฆ to the env block in your MCP config'}\n\n` +
95
+ `${b.alternative ? `Alternative: ${b.alternative}` : ""}`);
96
+ }
97
+ if (RETRY_STATUSES.has(res.status) && attempt < RETRY_DELAYS.length) {
98
+ lastError = new Error(`Finch API error: ${res.status}`);
99
+ continue;
100
+ }
101
+ if (!res.ok)
102
+ throw new Error(`Finch API error: ${res.status} ${await res.text()}`);
103
+ return res.json();
104
+ }
105
+ throw lastError ?? new Error("Request failed after retries");
106
+ }
107
+ // Variant that returns the response body as raw text. Used for endpoints
108
+ // that stream non-JSON content like /vault/blob (large vault entries that
109
+ // were offloaded to Convex File Storage).
110
+ async function callConvexRaw(path, toolName = "unknown", timeoutMs = 60000) {
111
+ const url = `${exports.CONVEX_SITE}${path}`;
112
+ const headers = {};
113
+ const apiKey = process.env.FINCH_API_KEY;
114
+ const sessionToken = (0, config_js_1.getSavedToken)();
115
+ const authHeader = apiKey
116
+ ? `Bearer ${apiKey}`
117
+ : sessionToken
118
+ ? `Bearer ${sessionToken}`
119
+ : null;
120
+ if (authHeader) {
121
+ headers["Authorization"] = authHeader;
122
+ }
123
+ else {
124
+ try {
125
+ const { address, signature, timestamp } = await (0, wallet_js_1.signRequest)(toolName);
126
+ headers["X-Wallet-Address"] = address;
127
+ headers["X-Wallet-Signature"] = signature;
128
+ headers["X-Wallet-Timestamp"] = timestamp;
129
+ }
130
+ catch {
131
+ // No local wallet available to sign with - continue without wallet
132
+ // headers, server will respond with 401/402 if auth was required.
133
+ }
134
+ }
135
+ const res = await fetch(url, {
136
+ method: "GET",
137
+ headers,
138
+ signal: AbortSignal.timeout(timeoutMs),
139
+ });
140
+ if (!res.ok)
141
+ throw new Error(`Finch API error: ${res.status}`);
142
+ return res.text();
143
+ }
144
+ async function notifyTelegram(userId, message) {
145
+ try {
146
+ return await callConvex("/user/telegram/notify", "POST", { userId, message }, "set_telegram");
147
+ }
148
+ catch (error) {
149
+ return { sent: false, reason: error.message ?? String(error) };
150
+ }
151
+ }
@@ -0,0 +1,54 @@
1
+ "use strict";
2
+ /**
3
+ * Pick the DexScreener pair whose numbers actually describe the token asked for.
4
+ *
5
+ * `priceUsd`, `marketCap`, `fdv`, `priceChange` and `txns` on a pair all describe
6
+ * the BASE token. Taking the deepest pair regardless of side therefore reports a
7
+ * different token entirely, and does it convincingly โ€” the caller's own contract
8
+ * address is still printed alongside. Two real cases:
9
+ *
10
+ * - Canonical USDC on Base. Its deepest pair is `AERO/USDC`, so asking for
11
+ * USDC returned Aerodrome at $0.4332 with an $840M FDV.
12
+ * - NVDA on Robinhood Chain. Its deepest pair is `AI/NVDA`, so it reported AI
13
+ * at $0.0063 as though that were NVDA at $205 โ€” and that price fed the
14
+ * take-profit and stop-loss triggers.
15
+ *
16
+ * Base-side pairs are preferred by depth. When a token only ever appears as the
17
+ * quote, its price is still derivable as `basePriceUsd / basePriceInOurToken`,
18
+ * so the pair is rebuilt with the sides swapped and the fields that describe the
19
+ * other token's flow dropped rather than passed off as ours.
20
+ */
21
+ Object.defineProperty(exports, "__esModule", { value: true });
22
+ exports.pickTokenPair = pickTokenPair;
23
+ const depth = (p) => p.liquidity?.usd ?? 0;
24
+ function pickTokenPair(pairs, tokenAddress) {
25
+ const want = tokenAddress.toLowerCase();
26
+ const all = pairs ?? [];
27
+ const baseSide = all
28
+ .filter((p) => p.baseToken?.address?.toLowerCase() === want)
29
+ .sort((a, b) => depth(b) - depth(a));
30
+ if (baseSide.length)
31
+ return baseSide[0];
32
+ const q = all
33
+ .filter((p) => p.quoteToken?.address?.toLowerCase() === want)
34
+ .sort((a, b) => depth(b) - depth(a))[0];
35
+ if (!q)
36
+ return null;
37
+ const baseUsd = Number(q.priceUsd);
38
+ const baseInOurs = Number(q.priceNative);
39
+ if (!isFinite(baseUsd) || !isFinite(baseInOurs) || baseInOurs <= 0)
40
+ return null;
41
+ return {
42
+ ...q,
43
+ baseToken: q.quoteToken,
44
+ quoteToken: q.baseToken,
45
+ priceUsd: String(baseUsd / baseInOurs),
46
+ priceNative: undefined,
47
+ // Supply-based and direction-based figures belong to the other token.
48
+ marketCap: undefined,
49
+ fdv: undefined,
50
+ txns: undefined,
51
+ priceChange: undefined,
52
+ derivedFromQuoteSide: true,
53
+ };
54
+ }
@@ -0,0 +1,315 @@
1
+ "use strict";
2
+ // Generalized enrichment router for deep_research synthesis.
3
+ //
4
+ // Old behavior: Firecrawl scraped 5โ€“10 pages and the LLM synthesized. When
5
+ // pages were thin or outdated, the LLM filled gaps with plausible fiction.
6
+ //
7
+ // New behavior: detect the topic domain and hit primary-source APIs in
8
+ // parallel before synthesis. Inject results as "AUTHORITATIVE LIVE DATA"
9
+ // the LLM is instructed to lead with.
10
+ //
11
+ // All APIs used here are free, public, no auth required:
12
+ // - DefiLlama, CoinGecko (crypto)
13
+ // - HackerNews Algolia (tech news, ~real-time)
14
+ // - GitHub search (repos, no auth needed for public read)
15
+ // - arXiv (academic papers)
16
+ // - Wikipedia REST (foundational facts)
17
+ //
18
+ // All calls are best-effort with short timeouts - if any fail, the
19
+ // synthesis runs without that block.
20
+ Object.defineProperty(exports, "__esModule", { value: true });
21
+ exports.enrichQuery = enrichQuery;
22
+ exports.todayContext = todayContext;
23
+ // โ”€โ”€โ”€ Topic detection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
24
+ const CRYPTO_RE = /\b(tvl|apy|yield|vault|defi|stablecoin|usdc|usdt|eth|btc|sol|bitcoin|ethereum|solana|base|aerodrome|uniswap|morpho|moonwell|aave|lido|ethena|pendle|curve|gauntlet|steakhouse|base chain|arbitrum|optimism|polygon|onchain|on-chain|wallet|swap|amm|perpetual|perp|liquid staking)\b/i;
25
+ const TECH_RE = /\b(ai|llm|gpt|claude|anthropic|openai|gemini|machine learning|deep learning|neural|transformer|frontier model|agent|mcp|api|framework|library|sdk|github|repo|repository|startup|y combinator|yc|saas|vc|funding round|seed|series [a-d]|launches|launched|release|shipped|build|launch)\b/i;
26
+ const ACADEMIC_RE = /\b(paper|research paper|arxiv|study|publication|preprint|peer.review|citation|abstract|methodology|hypothesis|empirical|experiment|finding[s]?|literature review|systematic review|meta.analysis|theorem|proof)\b/i;
27
+ const FACTUAL_RE = /\b(history of|founded|established|definition|what is|who is|when did|where is|biography|encyclopedia|background|origin|first invented)\b/i;
28
+ function detectDomains(query) {
29
+ const q = query.toLowerCase();
30
+ const domains = [];
31
+ if (CRYPTO_RE.test(q))
32
+ domains.push("crypto");
33
+ if (TECH_RE.test(q))
34
+ domains.push("tech");
35
+ if (ACADEMIC_RE.test(q))
36
+ domains.push("academic");
37
+ if (FACTUAL_RE.test(q))
38
+ domains.push("general");
39
+ return domains;
40
+ }
41
+ // โ”€โ”€โ”€ Formatting helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
42
+ function formatUsd(n) {
43
+ if (typeof n !== "number" || !isFinite(n))
44
+ return "n/a";
45
+ if (n >= 1e9)
46
+ return `$${(n / 1e9).toFixed(2)}B`;
47
+ if (n >= 1e6)
48
+ return `$${(n / 1e6).toFixed(2)}M`;
49
+ if (n >= 1e3)
50
+ return `$${(n / 1e3).toFixed(2)}K`;
51
+ return `$${n.toFixed(2)}`;
52
+ }
53
+ function clipText(s, n) {
54
+ return s.length > n ? s.slice(0, n - 1) + "โ€ฆ" : s;
55
+ }
56
+ function ageInDays(iso) {
57
+ return Math.floor((Date.now() - new Date(iso).getTime()) / 86400000);
58
+ }
59
+ // โ”€โ”€โ”€ Crypto enrichment (DefiLlama + CoinGecko) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
60
+ const DEFI_PROTOCOLS = [
61
+ "aerodrome", "uniswap", "morpho", "moonwell", "aave", "compound", "lido",
62
+ "rocket pool", "rocketpool", "ethena", "pendle", "curve", "balancer",
63
+ "convex", "frax", "spark", "fluid", "kamino", "marginfi", "jupiter",
64
+ "raydium", "orca", "drift", "gmx", "synthetix", "dydx", "vertex",
65
+ "hyperliquid", "yearn", "gauntlet", "steakhouse",
66
+ ];
67
+ const CHAIN_KEYWORDS = [
68
+ "ethereum", "base", "arbitrum", "optimism", "polygon", "solana", "avalanche",
69
+ "blast", "sonic", "linea", "scroll", "zksync", "berachain", "monad",
70
+ ];
71
+ const TOP_TOKENS = [
72
+ "btc", "bitcoin", "eth", "ethereum", "usdc", "usdt", "dai", "weth",
73
+ "sol", "solana", "matic", "avax", "link", "uni", "aave", "ldo",
74
+ ];
75
+ async function cryptoEnrich(query) {
76
+ const q = query.toLowerCase();
77
+ const protocols = DEFI_PROTOCOLS.filter((p) => q.includes(p));
78
+ const chains = CHAIN_KEYWORDS.filter((c) => q.includes(c));
79
+ const tokens = TOP_TOKENS.filter((t) => new RegExp(`\\b${t}\\b`, "i").test(q));
80
+ const blocks = [];
81
+ // Protocol TVL
82
+ if (protocols.length > 0) {
83
+ try {
84
+ const slug = protocols[0].toLowerCase().replace(/\s+/g, "-");
85
+ const res = await fetch(`https://api.llama.fi/protocol/${slug}`, { signal: AbortSignal.timeout(8000) });
86
+ if (res.ok) {
87
+ const d = (await res.json());
88
+ if (d?.name) {
89
+ const chainBreakdown = d.currentChainTvls
90
+ ? Object.entries(d.currentChainTvls)
91
+ .sort(([, a], [, b]) => b - a)
92
+ .slice(0, 5)
93
+ .map(([c, v]) => `${c}: ${formatUsd(v)}`)
94
+ .join(" ยท ")
95
+ : "";
96
+ blocks.push([
97
+ `### ๐Ÿ“Š DefiLlama - ${d.name}`,
98
+ `- TVL: **${formatUsd(d.tvl)}** | 24h: ${d.change_1d?.toFixed(2) ?? "n/a"}% | 7d: ${d.change_7d?.toFixed(2) ?? "n/a"}%`,
99
+ d.mcap ? `- Market cap: ${formatUsd(d.mcap)}` : "",
100
+ chainBreakdown ? `- Top chains: ${chainBreakdown}` : "",
101
+ `- Source: https://defillama.com/protocol/${slug}`,
102
+ ].filter(Boolean).join("\n"));
103
+ }
104
+ }
105
+ }
106
+ catch { /* ignore */ }
107
+ }
108
+ // Yields
109
+ if (q.match(/\b(yield|vault|apy|lp)\b/)) {
110
+ try {
111
+ const res = await fetch("https://yields.llama.fi/pools", { signal: AbortSignal.timeout(8000) });
112
+ if (res.ok) {
113
+ const d = (await res.json());
114
+ let pools = (d?.data ?? []);
115
+ if (chains[0])
116
+ pools = pools.filter((p) => (p.chain ?? "").toLowerCase() === chains[0]);
117
+ if (tokens[0])
118
+ pools = pools.filter((p) => (p.symbol ?? "").toLowerCase().includes(tokens[0]));
119
+ pools = pools.filter((p) => (p.tvlUsd ?? 0) > 1000000 && (p.apy ?? 0) < 100);
120
+ pools = pools.sort((a, b) => (b.tvlUsd ?? 0) - (a.tvlUsd ?? 0)).slice(0, 8);
121
+ if (pools.length > 0) {
122
+ const rows = pools.map((p) => `- **${p.project}** ${p.symbol} on ${p.chain}: ${(p.apy ?? 0).toFixed(2)}% APY ยท TVL ${formatUsd(p.tvlUsd)}`).join("\n");
123
+ blocks.push(`### ๐Ÿ’ฐ DefiLlama Yields (TVL > $1M)\n${rows}\n- Source: https://yields.llama.fi/`);
124
+ }
125
+ }
126
+ }
127
+ catch { /* ignore */ }
128
+ }
129
+ // Prices
130
+ if (tokens.length > 0) {
131
+ try {
132
+ const idMap = {
133
+ btc: "bitcoin", eth: "ethereum", sol: "solana", matic: "matic-network",
134
+ usdc: "usd-coin", usdt: "tether", dai: "dai", weth: "weth",
135
+ avax: "avalanche-2", link: "chainlink", uni: "uniswap", aave: "aave",
136
+ ldo: "lido-dao",
137
+ };
138
+ const ids = tokens.map((t) => idMap[t.toLowerCase()] ?? t.toLowerCase()).join(",");
139
+ const res = await fetch(`https://api.coingecko.com/api/v3/simple/price?ids=${ids}&vs_currencies=usd&include_24hr_change=true&include_market_cap=true`, { signal: AbortSignal.timeout(8000) });
140
+ if (res.ok) {
141
+ const d = (await res.json());
142
+ const rows = Object.entries(d).map(([id, p]) => {
143
+ const change = typeof p.usd_24h_change === "number"
144
+ ? ` (${p.usd_24h_change >= 0 ? "+" : ""}${p.usd_24h_change.toFixed(2)}% 24h)`
145
+ : "";
146
+ const mcap = p.usd_market_cap ? ` ยท mcap ${formatUsd(p.usd_market_cap)}` : "";
147
+ return `- **${id}**: $${(p.usd ?? 0).toLocaleString()}${change}${mcap}`;
148
+ }).join("\n");
149
+ if (rows)
150
+ blocks.push(`### ๐Ÿ’ต CoinGecko\n${rows}\n- Source: https://www.coingecko.com/`);
151
+ }
152
+ }
153
+ catch { /* ignore */ }
154
+ }
155
+ return blocks.length > 0 ? blocks.join("\n\n") : null;
156
+ }
157
+ // โ”€โ”€โ”€ Tech enrichment (HackerNews Algolia + GitHub search) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
158
+ async function techEnrich(query) {
159
+ const blocks = [];
160
+ // HackerNews - last 30 days, sorted by relevance + popularity
161
+ try {
162
+ const since = Math.floor((Date.now() - 30 * 86400000) / 1000);
163
+ const url = `https://hn.algolia.com/api/v1/search?query=${encodeURIComponent(query)}&numericFilters=created_at_i>${since}&hitsPerPage=8`;
164
+ const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
165
+ if (res.ok) {
166
+ const d = (await res.json());
167
+ const hits = (d?.hits ?? []);
168
+ if (hits.length > 0) {
169
+ const rows = hits.slice(0, 6).map((h) => {
170
+ const title = h.title ?? h.story_title ?? "(no title)";
171
+ const points = h.points != null ? `${h.points} pts` : "";
172
+ const comments = h.num_comments != null ? `${h.num_comments} comments` : "";
173
+ const age = h.created_at ? `${ageInDays(h.created_at)}d ago` : "";
174
+ const meta = [points, comments, age].filter(Boolean).join(" ยท ");
175
+ const link = h.url ?? `https://news.ycombinator.com/item?id=${h.objectID}`;
176
+ return `- **${clipText(title, 140)}** ยท ${meta}\n ${link}`;
177
+ }).join("\n");
178
+ blocks.push(`### ๐Ÿ“ฐ HackerNews (last 30d, top hits)\n${rows}\n- Source: https://hn.algolia.com/`);
179
+ }
180
+ }
181
+ }
182
+ catch { /* ignore */ }
183
+ // GitHub search - public repos matching query
184
+ try {
185
+ const url = `https://api.github.com/search/repositories?q=${encodeURIComponent(query)}&sort=stars&order=desc&per_page=6`;
186
+ const res = await fetch(url, {
187
+ signal: AbortSignal.timeout(8000),
188
+ headers: { Accept: "application/vnd.github+json", "User-Agent": "finch-mcp" },
189
+ });
190
+ if (res.ok) {
191
+ const d = (await res.json());
192
+ const repos = (d?.items ?? []);
193
+ if (repos.length > 0) {
194
+ const rows = repos.slice(0, 5).map((r) => {
195
+ const stars = r.stargazers_count?.toLocaleString() ?? "?";
196
+ const desc = clipText(r.description ?? "", 100);
197
+ return `- **${r.full_name}** (โ˜…${stars}) - ${desc}\n ${r.html_url}`;
198
+ }).join("\n");
199
+ blocks.push(`### ๐Ÿ™ GitHub (top repos by stars)\n${rows}\n- Source: https://github.com/search?q=${encodeURIComponent(query)}`);
200
+ }
201
+ }
202
+ }
203
+ catch { /* ignore */ }
204
+ return blocks.length > 0 ? blocks.join("\n\n") : null;
205
+ }
206
+ // โ”€โ”€โ”€ Academic enrichment (arXiv) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
207
+ async function academicEnrich(query) {
208
+ try {
209
+ const url = `http://export.arxiv.org/api/query?search_query=all:${encodeURIComponent(query)}&start=0&max_results=5&sortBy=submittedDate&sortOrder=descending`;
210
+ const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
211
+ if (!res.ok)
212
+ return null;
213
+ const xml = await res.text();
214
+ const entries = xml.match(/<entry>[\s\S]*?<\/entry>/g) ?? [];
215
+ if (entries.length === 0)
216
+ return null;
217
+ const rows = entries.slice(0, 5).map((e) => {
218
+ const title = (e.match(/<title>([\s\S]*?)<\/title>/)?.[1] ?? "").replace(/\s+/g, " ").trim();
219
+ const summary = (e.match(/<summary>([\s\S]*?)<\/summary>/)?.[1] ?? "").replace(/\s+/g, " ").trim();
220
+ const published = e.match(/<published>([\s\S]*?)<\/published>/)?.[1] ?? "";
221
+ const link = e.match(/<id>([\s\S]*?)<\/id>/)?.[1] ?? "";
222
+ const date = published ? `${published.slice(0, 10)} (${ageInDays(published)}d ago)` : "";
223
+ return `- **${clipText(title, 140)}** ยท ${date}\n ${clipText(summary, 200)}\n ${link}`;
224
+ }).join("\n\n");
225
+ return `### ๐Ÿ“š arXiv (recent papers)\n${rows}\n- Source: https://arxiv.org/`;
226
+ }
227
+ catch {
228
+ return null;
229
+ }
230
+ }
231
+ // โ”€โ”€โ”€ General enrichment (Wikipedia REST) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
232
+ async function generalEnrich(query) {
233
+ try {
234
+ // Wikipedia search - find best matching article
235
+ const searchRes = await fetch(`https://en.wikipedia.org/w/api.php?action=opensearch&format=json&search=${encodeURIComponent(query)}&limit=3`, { signal: AbortSignal.timeout(6000) });
236
+ if (!searchRes.ok)
237
+ return null;
238
+ const [, titles, descs, urls] = (await searchRes.json());
239
+ if (!titles || titles.length === 0)
240
+ return null;
241
+ // Fetch summary for top hit
242
+ const top = titles[0];
243
+ const sumRes = await fetch(`https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(top)}`, { signal: AbortSignal.timeout(6000) });
244
+ if (!sumRes.ok)
245
+ return null;
246
+ const summary = (await sumRes.json());
247
+ const lines = [
248
+ `### ๐ŸŒ Wikipedia - ${summary.title ?? top}`,
249
+ summary.extract ? clipText(summary.extract, 600) : descs[0] ?? "",
250
+ `- Source: ${urls[0] ?? `https://en.wikipedia.org/wiki/${encodeURIComponent(top)}`}`,
251
+ ];
252
+ return lines.filter(Boolean).join("\n");
253
+ }
254
+ catch {
255
+ return null;
256
+ }
257
+ }
258
+ // โ”€โ”€โ”€ Public entry point โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
259
+ /**
260
+ * Run enrichment in parallel for all detected domains. Pure best-effort: if
261
+ * no domain matches or all APIs fail, returns hasData=false and synthesis
262
+ * runs normally.
263
+ */
264
+ async function enrichQuery(query) {
265
+ const domains = detectDomains(query);
266
+ if (domains.length === 0) {
267
+ return { context: "", authoritative: [], hasData: false, domains: [] };
268
+ }
269
+ const promises = [];
270
+ if (domains.includes("crypto"))
271
+ promises.push(cryptoEnrich(query));
272
+ if (domains.includes("tech"))
273
+ promises.push(techEnrich(query));
274
+ if (domains.includes("academic"))
275
+ promises.push(academicEnrich(query));
276
+ if (domains.includes("general"))
277
+ promises.push(generalEnrich(query));
278
+ const results = await Promise.all(promises);
279
+ const blocks = results.filter((b) => !!b);
280
+ if (blocks.length === 0) {
281
+ return { context: "", authoritative: [], hasData: false, domains };
282
+ }
283
+ const authoritative = [];
284
+ if (domains.includes("crypto"))
285
+ authoritative.push("defillama.com", "coingecko.com");
286
+ if (domains.includes("tech"))
287
+ authoritative.push("news.ycombinator.com", "github.com");
288
+ if (domains.includes("academic"))
289
+ authoritative.push("arxiv.org");
290
+ if (domains.includes("general"))
291
+ authoritative.push("wikipedia.org");
292
+ const todayISO = new Date().toISOString().slice(0, 10);
293
+ const context = [
294
+ `---`,
295
+ `## ๐Ÿ”’ AUTHORITATIVE LIVE DATA - fetched ${todayISO}`,
296
+ ``,
297
+ `The blocks below come from primary-source APIs called at query time. Treat as ground truth. When figures here conflict with the scraped sources below, prefer these and cite them by source name (e.g. \`[DefiLlama]\`, \`[HackerNews]\`, \`[arXiv]\`, \`[GitHub]\`, \`[Wikipedia]\`).`,
298
+ ``,
299
+ blocks.join("\n\n"),
300
+ `---`,
301
+ ].join("\n");
302
+ return { context, authoritative, hasData: true, domains };
303
+ }
304
+ /**
305
+ * Returns the current date in ISO format. Used by the synthesis prompt to
306
+ * anchor the LLM in time - prevents "Q2 2026 catalysts in July" style
307
+ * calendar hallucinations.
308
+ */
309
+ function todayContext() {
310
+ const now = new Date();
311
+ const iso = now.toISOString().slice(0, 10);
312
+ const weekday = now.toLocaleDateString("en-US", { weekday: "long" });
313
+ const quarter = `Q${Math.floor(now.getUTCMonth() / 3) + 1}`;
314
+ return `Today is ${weekday}, ${iso}. This is ${quarter} ${now.getUTCFullYear()}. Treat any date after today as a future/projected event, not a confirmed one.`;
315
+ }