@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.
- package/LICENSE +21 -0
- package/README.md +345 -0
- package/dist/_http-cache.js +96 -0
- package/dist/agent-loop.js +231 -0
- package/dist/annotations.js +113 -0
- package/dist/cli.js +1195 -0
- package/dist/clink-input.js +15 -0
- package/dist/config.js +132 -0
- package/dist/convex.js +151 -0
- package/dist/dex-pair.js +54 -0
- package/dist/enrichment-router.js +315 -0
- package/dist/index.js +256 -0
- package/dist/llm.js +323 -0
- package/dist/local-memory.js +102 -0
- package/dist/local-vault.js +454 -0
- package/dist/output-schemas.js +551 -0
- package/dist/prompts.js +111 -0
- package/dist/public-url.js +107 -0
- package/dist/resources.js +116 -0
- package/dist/server.js +300 -0
- package/dist/signal-gate.js +57 -0
- package/dist/token-decimals.js +26 -0
- package/dist/token-gate.js +88 -0
- package/dist/tool-filter.js +44 -0
- package/dist/tools/_solidity-scan.js +313 -0
- package/dist/tools/agents.js +729 -0
- package/dist/tools/automation.js +314 -0
- package/dist/tools/base-mcp.js +478 -0
- package/dist/tools/base.js +269 -0
- package/dist/tools/chronicle.js +268 -0
- package/dist/tools/coder.js +94 -0
- package/dist/tools/deep-research.js +1416 -0
- package/dist/tools/defi.js +291 -0
- package/dist/tools/equity.js +364 -0
- package/dist/tools/events.js +182 -0
- package/dist/tools/framework.js +150 -0
- package/dist/tools/github.js +514 -0
- package/dist/tools/insider.js +264 -0
- package/dist/tools/insight.js +634 -0
- package/dist/tools/market.js +555 -0
- package/dist/tools/memory.js +1046 -0
- package/dist/tools/miroshark.js +343 -0
- package/dist/tools/monitor.js +319 -0
- package/dist/tools/os.js +226 -0
- package/dist/tools/packets.js +296 -0
- package/dist/tools/research-chain.js +226 -0
- package/dist/tools/research-compare.js +280 -0
- package/dist/tools/research.js +188 -0
- package/dist/tools/rh-bridge.js +148 -0
- package/dist/tools/rh-mcp.js +1411 -0
- package/dist/tools/rh-orders.js +471 -0
- package/dist/tools/scanner.js +534 -0
- package/dist/tools/vault.js +764 -0
- package/dist/tools/wallet.js +200 -0
- package/dist/types.js +2 -0
- package/dist/wallet.js +184 -0
- 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
|
+
}
|
package/dist/dex-pair.js
ADDED
|
@@ -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
|
+
}
|