@finchagentic/mcp 4.7.1 → 4.7.3

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 (51) hide show
  1. package/README.md +74 -39
  2. package/dist/_zod-helpers.js +10 -0
  3. package/dist/cli-doctor.js +300 -0
  4. package/dist/cli-env.js +91 -0
  5. package/dist/cli-install.js +236 -0
  6. package/dist/cli-login.js +101 -0
  7. package/dist/cli-orders.js +175 -0
  8. package/dist/cli-setup.js +270 -0
  9. package/dist/cli-ui.js +168 -0
  10. package/dist/cli-vault.js +75 -0
  11. package/dist/cli.js +61 -1152
  12. package/dist/config.js +3 -2
  13. package/dist/enrichment-router.js +5 -18
  14. package/dist/finch-output.js +30 -29
  15. package/dist/finch-status.js +105 -40
  16. package/dist/index.js +0 -0
  17. package/dist/local-vault.js +5 -12
  18. package/dist/output-schemas.js +3 -12
  19. package/dist/project.js +4 -13
  20. package/dist/server.js +13 -35
  21. package/dist/tool-filter.js +4 -13
  22. package/dist/tools/_solidity-scan.js +5 -14
  23. package/dist/tools/agents.js +14 -26
  24. package/dist/tools/deep-research-constants.js +33 -0
  25. package/dist/tools/deep-research-firecrawl.js +88 -0
  26. package/dist/tools/deep-research-planning.js +249 -0
  27. package/dist/tools/deep-research-synthesis.js +343 -0
  28. package/dist/tools/deep-research-text.js +158 -0
  29. package/dist/tools/deep-research-tools.js +90 -0
  30. package/dist/tools/deep-research.js +47 -938
  31. package/dist/tools/defi.js +4 -3
  32. package/dist/tools/insider.js +4 -14
  33. package/dist/tools/insight.js +7 -6
  34. package/dist/tools/market.js +16 -15
  35. package/dist/tools/memory.js +57 -62
  36. package/dist/tools/monitor.js +7 -6
  37. package/dist/tools/os.js +7 -85
  38. package/dist/tools/research.js +7 -6
  39. package/dist/tools/rh-mcp-constants.js +65 -0
  40. package/dist/tools/rh-mcp-dex.js +107 -0
  41. package/dist/tools/rh-mcp-provider.js +127 -0
  42. package/dist/tools/rh-mcp-resolve.js +137 -0
  43. package/dist/tools/rh-mcp-risk.js +230 -0
  44. package/dist/tools/rh-mcp-safety.js +234 -0
  45. package/dist/tools/rh-mcp-swap.js +181 -0
  46. package/dist/tools/rh-mcp-tools.js +137 -0
  47. package/dist/tools/rh-mcp.js +75 -1191
  48. package/dist/tools/scanner.js +10 -9
  49. package/dist/tools/stake.js +7 -20
  50. package/dist/tools/vault.js +52 -59
  51. package/package.json +12 -9
@@ -1,919 +1,28 @@
1
1
  "use strict";
2
+ // deep_research orchestrator: plan → search → scrape → (draft → reflect →
3
+ // critic) → synthesize → validate → save/link. Each stage's logic lives in
4
+ // its own deep-research-*.ts sibling module; this file wires them together.
5
+ //
6
+ // Kept at this exact path/name so server.ts's `./tools/deep-research.js`
7
+ // import needs no change.
2
8
  Object.defineProperty(exports, "__esModule", { value: true });
3
9
  exports.DEEP_RESEARCH_TOOLS = void 0;
4
10
  exports.handleDeepResearch = handleDeepResearch;
5
- const zod_1 = require("zod");
6
11
  const llm_js_1 = require("../llm.js");
7
12
  const convex_js_1 = require("../convex.js");
8
13
  const signal_gate_js_1 = require("../signal-gate.js");
9
- const enrichment_router_js_1 = require("../enrichment-router.js");
10
- const memory_js_1 = require("./memory.js");
11
- // ─── Constants ────────────────────────────────────────────────────────────────
12
- const FC_BASE = "https://api.firecrawl.dev/v1";
13
- const MAX_PER_DOMAIN = 2; // source diversity - cap hits per domain
14
- const EXCERPT_CHARS_PER_CHUNK = 600;
15
- const EXCERPT_TOP_CHUNKS = 4; // pick top N relevant chunks per source
16
- // Quality scoring - higher = more trustworthy primary source
17
- const DOMAIN_TIER_BONUS = [
18
- [/\.gov(\b|\/|$)/i, 4],
19
- [/\.edu(\b|\/|$)/i, 3],
20
- [/(?:nature|science|nih|arxiv|acm|ieee|sciencedirect)\.(?:org|com)/i, 3],
21
- // Crypto primary-source boost - these are the authoritative data sources
22
- // for protocol TVL, yields, prices, and on-chain analytics. Rank above
23
- // generic news for crypto queries.
24
- [/(?:defillama|tokenterminal|coingecko|coinmarketcap|dune|messari|artemis)\.(?:com|fi)/i, 3],
25
- [/(?:etherscan|basescan|arbiscan|solscan|polygonscan|optimistic\.etherscan)\.(?:io|com)/i, 2],
26
- [/(?:reuters|apnews|bbc|economist|ft|wsj|bloomberg)\.com/i, 2],
27
- [/(?:coindesk|theblock|cointelegraph|decrypt|theinformation)\.(?:co|com|io)/i, 1],
28
- [/(?:wikipedia|github|stackoverflow)\.(?:org|com)/i, 1],
29
- [/(?:medium|substack|reddit|twitter|x)\.com/i, -1],
30
- ];
31
- // News-domain bonus - applied additionally in fresh mode
32
- const NEWS_DOMAIN_BOOST_RE = /(?:reuters|apnews|bbc|economist|ft|wsj|bloomberg|cnbc|theverge|techcrunch|axios|coindesk|theinformation|nytimes|guardian|aljazeera)\.com/i;
33
- // Keywords that signal a time-sensitive query - trigger fresh mode auto.
34
- const FRESH_TRIGGER_RE = /\b(today|tonight|tomorrow|yesterday|this week|last week|past week|latest|breaking|just now|recent|currently|now|live|happening|this month|last month|past month|past \d+ days?|last \d+ days?|q[1-4]|h[12]|202[6-9])\b/i;
35
- // ─── Tool schema ──────────────────────────────────────────────────────────────
36
- exports.DEEP_RESEARCH_TOOLS = [
37
- {
38
- name: "deep_research",
39
- description: "Web research engine: searches, scrapes, ranks and de-duplicates sources, then returns them " +
40
- "as a numbered, citable evidence pack for YOU to synthesise. This is the default (mode='sources') " +
41
- "and needs no API key. " +
42
- "You are the analyst: pass your own sub-queries via `queries` for full control over the angles " +
43
- "covered — otherwise they are derived from the topic. " +
44
- "Set mode='report' only if you want the server to write the prose itself (requires an LLM key, " +
45
- "and you cannot steer the result). " +
46
- "Profile-aware, auto-saves to vault, auto-links to related past reports.",
47
- inputSchema: {
48
- type: "object",
49
- properties: {
50
- query: {
51
- type: "string",
52
- description: "Research question. Be specific: 'state of Base chain TVL Q2 2026' beats 'Base chain'.",
53
- },
54
- queries: {
55
- type: "array",
56
- items: { type: "string" },
57
- description: "YOUR sub-queries to search (recommended). You know the topic and the user's intent, so plan " +
58
- "the angles yourself — 3-6 specific queries beat a generic decomposition. Omit to derive them.",
59
- },
60
- mode: {
61
- type: "string",
62
- enum: ["sources", "report"],
63
- description: "'sources' (default) returns the ranked evidence pack for you to synthesise — no API key needed. " +
64
- "'report' makes the server write the prose (needs an LLM key; you cannot steer it).",
65
- },
66
- depth: {
67
- type: "string",
68
- enum: ["fast", "standard", "deep"],
69
- description: "fast=flat planner, 3 sub-Qs, ~10 sources (~45s). standard=3 specialist angles, ~14 sources, reflection round (~90s). deep=5 angles + adversarial critic + reflection, ~20 sources (~180s). Default standard.",
70
- },
71
- focus: {
72
- type: "string",
73
- description: "Optional angle hint - 'technical', 'investment', 'news', 'comparison'. Steers planning.",
74
- },
75
- continueFrom: {
76
- type: "string",
77
- description: "Vault key of a previous deep_research report to build on. When provided, the planner focuses on UPDATES, GAPS, and NEW developments since that report - not re-treading covered ground. The new report explicitly references and extends the prior findings. Format: 'research/...' (use vault_list type:research to find candidates). This is the multi-session research feature - Perplexity / ChatGPT Deep Research don't have an equivalent.",
78
- },
79
- freshMode: {
80
- type: "boolean",
81
- description: "Force time-sensitive research mode: planner appends recency hints to sub-queries, source ranking boosts news domains (Reuters, AP, Bloomberg, etc.), and the synthesizer is told to prioritize current/recent claims. Auto-enabled when the query contains time-sensitive keywords (today, latest, breaking, this week, etc.).",
82
- },
83
- freshDays: {
84
- type: "number",
85
- description: "When freshMode is on, restrict to results from the last N days. Default 14 days. Capped at 90.",
86
- },
87
- liveSearch: {
88
- type: "boolean",
89
- description: "Enable Grok Live Search - pulls real-time results from X (Twitter), news, web, RSS during synthesis. Only works when Grok is the active LLM provider. Adds ~5-15s per Grok call. Default: auto (on when Grok is active).",
90
- },
91
- liveSearchSources: {
92
- type: "array",
93
- items: { type: "string", enum: ["web", "x", "news", "rss"] },
94
- description: "Which Live Search sources to pull from. Default: ['web', 'x', 'news']. Only respected when liveSearch is true and Grok is active.",
95
- },
96
- liveSearchDays: {
97
- type: "number",
98
- description: "Restrict Live Search to results from the last N days (max 365). Useful for time-sensitive queries. Default: no date filter.",
99
- },
100
- saveToVault: { type: "boolean", description: "Auto-save report to vault (default true)" },
101
- },
102
- required: ["query"],
103
- },
104
- },
105
- ];
106
- const InputSchema = zod_1.z.object({
107
- query: zod_1.z.string().min(3).max(500),
108
- queries: zod_1.z.array(zod_1.z.string().min(3).max(300)).max(12).optional(),
109
- mode: zod_1.z.enum(["sources", "report"]).optional(),
110
- depth: zod_1.z.enum(["fast", "standard", "deep"]).optional(),
111
- focus: zod_1.z.string().max(80).optional(),
112
- continueFrom: zod_1.z.string().max(200).optional(),
113
- freshMode: zod_1.z.boolean().optional(),
114
- freshDays: zod_1.z.number().int().min(1).max(90).optional(),
115
- liveSearch: zod_1.z.boolean().optional(),
116
- liveSearchSources: zod_1.z.array(zod_1.z.enum(["web", "x", "news", "rss"])).optional(),
117
- liveSearchDays: zod_1.z.number().int().min(1).max(365).optional(),
118
- saveToVault: zod_1.z.boolean().optional(),
119
- });
120
- function classifySource(score) {
121
- if (score >= 3)
122
- return "primary";
123
- if (score === 2)
124
- return "expert";
125
- if (score === 1)
126
- return "secondary";
127
- if (score < 0)
128
- return "market";
129
- return "unclassified";
130
- }
131
- // ─── Helpers ──────────────────────────────────────────────────────────────────
132
- function safeParseJson(raw, fallback) {
133
- try {
134
- return JSON.parse(raw);
135
- }
136
- catch { /* try the next parse strategy */ }
137
- const stripped = raw.replace(/^```(?:json)?\n?/m, "").replace(/\n?```$/m, "").trim();
138
- try {
139
- return JSON.parse(stripped);
140
- }
141
- catch { /* try the next parse strategy */ }
142
- const arrMatch = stripped.match(/\[[\s\S]*\]/);
143
- if (arrMatch) {
144
- try {
145
- return JSON.parse(arrMatch[0]);
146
- }
147
- catch { /* try the next parse strategy */ }
148
- }
149
- const objMatch = stripped.match(/\{[\s\S]*\}/);
150
- if (objMatch) {
151
- try {
152
- return JSON.parse(objMatch[0]);
153
- }
154
- catch { /* try the next parse strategy */ }
155
- }
156
- return fallback;
157
- }
158
- function domainOf(url) {
159
- try {
160
- return new URL(url).hostname.replace(/^www\./, "");
161
- }
162
- catch {
163
- return "unknown";
164
- }
165
- }
166
- function tierBonus(url) {
167
- for (const [re, bonus] of DOMAIN_TIER_BONUS)
168
- if (re.test(url))
169
- return bonus;
170
- return 0;
171
- }
172
- // Split markdown into ~600-char chunks at paragraph boundaries.
173
- function chunkMarkdown(md, chunkSize = EXCERPT_CHARS_PER_CHUNK) {
174
- const paragraphs = md.split(/\n{2,}/).map((p) => p.trim()).filter(Boolean);
175
- const chunks = [];
176
- let current = "";
177
- for (const p of paragraphs) {
178
- if (current.length + p.length + 2 <= chunkSize) {
179
- current = current ? `${current}\n\n${p}` : p;
180
- }
181
- else {
182
- if (current)
183
- chunks.push(current);
184
- current = p.slice(0, chunkSize * 2); // very long single paragraph → cap
185
- }
186
- }
187
- if (current)
188
- chunks.push(current);
189
- return chunks;
190
- }
191
- // Score a chunk against the query using term overlap. Cheap, no LLM call.
192
- function chunkRelevance(chunk, queryTerms) {
193
- const lower = chunk.toLowerCase();
194
- let score = 0;
195
- for (const term of queryTerms) {
196
- const occurrences = lower.split(term).length - 1;
197
- score += Math.min(occurrences, 5); // cap each term so a spammy page can't win
198
- }
199
- return score;
200
- }
201
- function pickBestExcerpt(md, queryTerms) {
202
- const chunks = chunkMarkdown(md);
203
- if (chunks.length === 0)
204
- return md.slice(0, 1500);
205
- const scored = chunks.map((c, i) => ({ c, i, score: chunkRelevance(c, queryTerms) }));
206
- scored.sort((a, b) => b.score - a.score);
207
- const top = scored.slice(0, EXCERPT_TOP_CHUNKS).sort((a, b) => a.i - b.i);
208
- return top.map((t) => t.c).join("\n\n---\n\n");
209
- }
210
- function extractQueryTerms(query) {
211
- return Array.from(new Set(query
212
- .toLowerCase()
213
- .replace(/[^a-z0-9\s]/g, " ")
214
- .split(/\s+/)
215
- .filter((t) => t.length >= 3 && !STOPWORDS.has(t)))).slice(0, 10);
216
- }
217
- const STOPWORDS = new Set([
218
- "the", "and", "for", "with", "that", "this", "what", "when", "where", "how", "why",
219
- "have", "has", "had", "are", "was", "were", "will", "would", "could", "should", "does",
220
- "did", "being", "been", "from", "into", "over", "under", "about", "into", "than", "then",
221
- "your", "yours", "their", "they", "them", "there", "here", "just", "also", "more", "most",
222
- ]);
223
- function todayISO() {
224
- return new Date().toISOString().slice(0, 10);
225
- }
226
- function currentYearMonth() {
227
- const now = new Date();
228
- const months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
229
- return `${months[now.getMonth()]} ${now.getFullYear()}`;
230
- }
231
- /**
232
- * Firecrawl returns either a flat array or a keyed object of result groups
233
- * (`{ web: [...], news: [...] }`). The proxy path assumed the flat shape and
234
- * handed an object to `.forEach`, which threw — invisible until now because
235
- * without an API key the run always died at the planner first.
236
- */
237
- function normalizeSearchHits(raw) {
238
- if (Array.isArray(raw))
239
- return raw;
240
- if (raw && typeof raw === "object") {
241
- const out = [];
242
- for (const group of Object.values(raw)) {
243
- if (Array.isArray(group))
244
- out.push(...group);
245
- }
246
- return out;
247
- }
248
- return [];
249
- }
250
- async function fcSearch(query, limit) {
251
- // BYOK path - direct call to Firecrawl with user's key. Fastest, no proxy hop.
252
- const key = process.env.FIRECRAWL_API_KEY;
253
- if (key) {
254
- try {
255
- const res = await fetch(`${FC_BASE}/search`, {
256
- method: "POST",
257
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
258
- body: JSON.stringify({ query, limit }),
259
- signal: AbortSignal.timeout(15000),
260
- });
261
- if (res.ok) {
262
- const data = (await res.json());
263
- return normalizeSearchHits(data.data);
264
- }
265
- }
266
- catch { /* fall through */ }
267
- }
268
- // Backend-proxy path - session-authed; Finch covers Firecrawl cost.
269
- try {
270
- const data = await (0, convex_js_1.callConvex)("/research/firecrawl-search", "POST", { query, limit }, "web_search", 20000);
271
- return normalizeSearchHits(data?.results);
272
- }
273
- catch {
274
- return [];
275
- }
276
- }
277
- async function fcScrape(url) {
278
- const key = process.env.FIRECRAWL_API_KEY;
279
- if (key) {
280
- try {
281
- const res = await fetch(`${FC_BASE}/scrape`, {
282
- method: "POST",
283
- headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` },
284
- body: JSON.stringify({ url, formats: ["markdown"], onlyMainContent: true }),
285
- signal: AbortSignal.timeout(20000),
286
- });
287
- if (res.ok) {
288
- const data = (await res.json());
289
- const md = data.data?.markdown;
290
- if (md) {
291
- const meta = data.data?.metadata;
292
- const publishedAt = meta?.publishedAt ?? meta?.ogPublishedTime ?? meta?.["article:published_time"];
293
- return { markdown: md, publishedAt };
294
- }
295
- }
296
- }
297
- catch { /* fall through */ }
298
- }
299
- // Backend-proxy path. Returns markdown only - no metadata extraction yet,
300
- // which means continueFrom can't auto-date proxied scrapes. Acceptable
301
- // tradeoff for now; markdown is the primary signal.
302
- try {
303
- const data = await (0, convex_js_1.callConvex)("/research/firecrawl-scrape", "POST", { url }, "web_scrape", 25000);
304
- if (data?.markdown)
305
- return { markdown: data.markdown };
306
- }
307
- catch { /* swallow */ }
308
- return null;
309
- }
310
- // ─── LLM stages ───────────────────────────────────────────────────────────────
311
- /**
312
- * Sub-queries without an LLM.
313
- *
314
- * The caller should normally pass `queries` — it is a model and plans better
315
- * than any heuristic. This exists so the evidence-pack path still works with
316
- * zero configuration, rather than failing when no API key is set.
317
- */
318
- function deriveSubQueries(query, n, focus, freshMode) {
319
- const base = query.trim().replace(/\?+$/, "");
320
- const recency = freshMode ? ` ${new Date().getFullYear()} latest` : "";
321
- const lenses = focus
322
- ? [focus, `${focus} risks`, `${focus} data`]
323
- : ["overview", "latest developments", "risks and criticism", "data and numbers", "expert analysis"];
324
- const out = [`${base}${recency}`];
325
- for (const lens of lenses) {
326
- if (out.length >= Math.max(2, n))
327
- break;
328
- out.push(`${base} ${lens}${recency}`);
329
- }
330
- return out;
331
- }
332
- async function planQueries(query, n, focus, priorContext, freshMode) {
333
- const focusNote = focus ? ` Focus angle: ${focus}.` : "";
334
- const sys = "You are a research planner. Output strict JSON only - no preamble, no markdown.";
335
- const freshNote = freshMode
336
- ? `
337
-
338
- ⏱ FRESH MODE - last ${freshMode.days} days:
339
- - Add a recency hint to most sub-questions: "in ${currentYearMonth()}", "last ${freshMode.days} days", "this week", "as of ${todayISO()}", etc.
340
- - Prefer queries that surface news / press releases / X posts over evergreen background.
341
- - Skip generic background - the user wants what's CURRENT, not historical.
342
- - At least 70% of sub-questions must include a date or recency token.`
343
- : "";
344
- const continuationNote = priorContext
345
- ? `
346
-
347
- ⚠️ CONTINUATION MODE - there is a PRIOR research report on this topic:
348
-
349
- """
350
- ${priorContext.slice(0, 2500)}
351
- """
352
-
353
- Your sub-questions must focus on:
354
- 1. UPDATES - what has changed since the prior report (new releases, news, data revisions)
355
- 2. GAPS - angles the prior report explicitly listed as open questions or follow-ups
356
- 3. NEW developments - entities/events the prior report doesn't mention
357
- 4. VERIFICATION - claims the prior report flagged as low-confidence or single-source
358
-
359
- DO NOT re-tread material already well-covered in the prior report. The user already has those answers.`
360
- : "";
361
- const user = `Decompose this research question into ${n} sub-questions that together cover the topic from different angles.${focusNote}${continuationNote}${freshNote}
362
-
363
- Rules:
364
- - Each sub-question must be a standalone web search query, under 90 chars.
365
- - Cover different facets: definition, current state, key actors, comparisons, counterarguments, recent news, forward outlook.
366
- - No duplicates, no near-paraphrases.
367
-
368
- ENTITY-HUNTING - at least HALF of your sub-questions must target queries likely to surface:
369
- - Specific company / product / framework names (e.g., "LangGraph adoption stats", "Manus orchestration funding")
370
- - Dollar amounts (acquisitions, funding rounds, revenue, ARR, market size)
371
- - Benchmark numbers (% adoption, latency ms, accuracy scores, MMLU/HumanEval/SWE-bench results)
372
- - Specific dates and timeline events (when X launched, when Y reached scale)
373
- - Named studies / surveys / reports (e.g., "Anthropic Economic Index 2026", "a16z AI infrastructure report")
374
-
375
- Bad: "what is X" → too generic, returns Wikipedia
376
- Good: "X adoption rate enterprise 2026 survey" → returns concrete stats
377
-
378
- Question: "${query}"
379
-
380
- Return: {"queries": ["...", "..."]} - exactly ${n} items.`;
381
- let raw = "";
382
- try {
383
- raw = await (0, llm_js_1.callLLM)(sys, user, 500, [], 30000);
384
- }
385
- catch {
386
- return [query];
387
- }
388
- const parsed = safeParseJson(raw, {});
389
- if (!parsed.queries || !Array.isArray(parsed.queries))
390
- return [query];
391
- const queries = parsed.queries
392
- .filter((s) => typeof s === "string")
393
- .map((s) => s.trim())
394
- .filter((s) => s.length > 0 && s.length <= 200)
395
- .slice(0, n);
396
- return queries.length > 0 ? queries : [query];
397
- }
398
- async function planAngles(query, n, focus, priorContext, freshMode) {
399
- const focusNote = focus ? ` Focus angle: ${focus}.` : "";
400
- const freshNote = freshMode
401
- ? `\n\n⏱ FRESH MODE - last ${freshMode.days} days: bias each angle's queries toward news/recent press/dated reports.`
402
- : "";
403
- const continuationNote = priorContext
404
- ? `\n\n⚠️ CONTINUATION - prior report exists. Angles must focus on UPDATES, GAPS, NEW developments since:\n"""\n${priorContext.slice(0, 1500)}\n"""`
405
- : "";
406
- const sys = "You are a research planner that decomposes topics into specialist angles. Output strict JSON only.";
407
- const user = `Break this research topic into ${n} DIFFERENT specialist angles. Each angle gets its own 2-3 search queries.${focusNote}${continuationNote}${freshNote}
408
-
409
- Topic: "${query}"
410
-
411
- Pick angles that are GENUINELY different - not paraphrases of the same question. Good angle diversity examples:
412
- - data / quantitative metrics
413
- - competitive landscape
414
- - team / governance / actors
415
- - recent news / catalysts
416
- - counterarguments / risks / criticism
417
- - forward outlook / projections
418
- - historical context / origins
419
- - regulatory / policy angle
420
- - technical / mechanism
421
- - ecosystem partners
422
-
423
- Pick the ${n} angles that most fit THIS specific topic. Each angle should have a label (3-6 words) and rationale (1 sentence). Queries must be standalone, ≤90 chars, entity-rich (specific names, dollar amounts, dates, benchmark numbers - NOT generic background).
424
-
425
- Return strict JSON:
426
- {
427
- "angles": [
428
- {
429
- "label": "...",
430
- "rationale": "...",
431
- "queries": ["...", "..."]
432
- }
433
- ]
434
- }
435
-
436
- Exactly ${n} angles. 2-3 queries per angle. No duplicate queries across angles.`;
437
- let raw = "";
438
- try {
439
- raw = await (0, llm_js_1.callLLM)(sys, user, 1200, [], 30000);
440
- }
441
- catch {
442
- return [];
443
- }
444
- const parsed = safeParseJson(raw, {});
445
- if (!parsed.angles || !Array.isArray(parsed.angles))
446
- return [];
447
- const angles = [];
448
- for (const a of parsed.angles) {
449
- if (typeof a !== "object" || a === null)
450
- continue;
451
- const o = a;
452
- if (typeof o.label !== "string" || typeof o.rationale !== "string")
453
- continue;
454
- if (!Array.isArray(o.queries))
455
- continue;
456
- const queries = o.queries
457
- .filter((q) => typeof q === "string")
458
- .map((q) => q.trim())
459
- .filter((q) => q.length > 0 && q.length <= 200)
460
- .slice(0, 3);
461
- if (queries.length === 0)
462
- continue;
463
- angles.push({
464
- label: o.label.trim().slice(0, 80),
465
- rationale: o.rationale.trim().slice(0, 200),
466
- queries,
467
- });
468
- if (angles.length >= n)
469
- break;
470
- }
471
- return angles;
472
- }
473
- // Adversarial critic - only runs for depth=deep. Reads the draft + sources
474
- // already gathered and produces a structured challenge block: single-source
475
- // claims, contradictions across angles, speculation framed as fact. The
476
- // final synthesizer is told to incorporate or refute these challenges
477
- // explicitly, lifting the quality floor.
478
- async function runCritic(query, draft, angles, sourceCount) {
479
- const sys = "You are an adversarial research critic. Be terse, specific, and unsparing. Output markdown.";
480
- const angleLabels = angles.map((a, i) => `${i + 1}. ${a.label}`).join("\n");
481
- const user = `Original question: "${query}"
482
-
483
- Specialist angles investigated:
484
- ${angleLabels}
485
-
486
- Total sources gathered: ${sourceCount}
487
-
488
- Draft report:
489
- """
490
- ${draft.slice(0, 5000)}
491
- """
492
-
493
- Audit the draft for quality problems. Be specific and quote spans where possible.
494
-
495
- Identify:
496
- 1. **Single-source claims** - major assertions resting on one [N] citation that aren't widely corroborated
497
- 2. **Contradictions** - places where different sources disagree but the draft doesn't surface the disagreement
498
- 3. **Speculation framed as fact** - confident statements about future/intent/cause without evidence
499
- 4. **Coverage gaps** - angles from the list above that got shallow treatment
500
- 5. **Stale or weak sources** - dated material treated as current, blog posts cited as primary data
501
-
502
- Output format:
503
- ## Critic notes
504
- - **[type]**: specific issue + relevant quote or claim. (1-2 sentences each)
505
- - Skip categories with no findings - don't pad.
506
-
507
- End with one line: "Net recommendation: [accept|revise|reject]"
508
-
509
- If the draft is solid, say so plainly - don't manufacture issues.`;
510
- try {
511
- const notes = await (0, llm_js_1.callLLM)(sys, user, 1500, [], 60000);
512
- return notes.trim();
513
- }
514
- catch {
515
- return ""; // critic failure shouldn't block final synth
516
- }
517
- }
518
- async function reflectAndExtend(query, draft, existingQueries, n) {
519
- const sys = "You are a research auditor. Find gaps in a draft report and propose follow-up search queries. Output strict JSON.";
520
- const user = `Original question: "${query}"
521
-
522
- Queries already run:
523
- ${existingQueries.map((q, i) => `${i + 1}. ${q}`).join("\n")}
524
-
525
- Draft report:
526
- """
527
- ${draft.slice(0, 4000)}
528
- """
529
-
530
- Identify ${n} GAPS in the draft - angles missing, claims that need verification, counter-perspectives not represented, or recent developments not covered. For each gap, give ONE web search query (≤90 chars) that would fill it.
531
-
532
- Return: {"gap_queries": ["...", "..."]} - exactly ${n} items, no duplicates of existing queries.`;
533
- let raw = "";
534
- try {
535
- raw = await (0, llm_js_1.callLLM)(sys, user, 500, [], 30000);
536
- }
537
- catch {
538
- return [];
539
- }
540
- const parsed = safeParseJson(raw, {});
541
- if (!parsed.gap_queries || !Array.isArray(parsed.gap_queries))
542
- return [];
543
- return parsed.gap_queries
544
- .filter((s) => typeof s === "string")
545
- .map((s) => s.trim())
546
- .filter((s) => s.length > 0 && s.length <= 200)
547
- .slice(0, n);
548
- }
549
- async function synthesize(query, sources, isFinal, liveSearch, priorContext, freshMode, angles, criticNotes) {
550
- const sourceBlocks = sources
551
- .map((s) => {
552
- const dateNote = s.publishedAt ? ` (published ${s.publishedAt.slice(0, 10)})` : "";
553
- const classNote = s.class !== "unclassified" ? ` [${s.class}]` : "";
554
- return `[${s.n}]${classNote} ${s.title} - ${s.domain}${dateNote}\nURL: ${s.url}\n\n${s.excerpt}`;
555
- })
556
- .join("\n\n---\n\n");
557
- // Multi-domain enrichment - auto-detect topic (crypto/tech/academic/general)
558
- // and pull live primary-source data in parallel. Crypto routes to DefiLlama
559
- // + CoinGecko; tech to HackerNews + GitHub; academic to arXiv; general to
560
- // Wikipedia. The combined block prepends to source blocks and is tagged
561
- // AUTHORITATIVE LIVE DATA so the LLM treats those numbers as ground truth.
562
- const enrichment = isFinal ? await (0, enrichment_router_js_1.enrichQuery)(query) : { context: "", hasData: false, domains: [] };
563
- // Profile + memory auto-injection - only on final synthesis.
564
- // Profile: persistent identity/business/state from vault.
565
- // Memory: top relevant memories for this query (personalizes report framing).
566
- // Both run in parallel; either can fail without blocking synthesis.
567
- let profileContext = "";
568
- let memoryContext = "";
569
- if (isFinal) {
570
- const [profileData, memHits] = await Promise.allSettled([
571
- (0, convex_js_1.callConvex)("/vault/profile-context?maxChars=1200", "GET", undefined, "vault_read"),
572
- (0, memory_js_1.searchSupermemory)(query, 4),
573
- ]);
574
- if (profileData.status === "fulfilled") {
575
- profileContext = (profileData.value?.context ?? "").trim();
576
- }
577
- if (memHits.status === "fulfilled" && memHits.value.length > 0) {
578
- memoryContext = memHits.value
579
- .map((r) => {
580
- const title = r.metadata?.title ? `[${r.metadata.title}] ` : "";
581
- return `- ${title}${r.content.slice(0, 200).replace(/\n/g, " ")}`;
582
- })
583
- .join("\n");
584
- }
585
- }
586
- const liveSearchNote = liveSearch
587
- ? `\n\nIMPORTANT - Real-time augmentation:
588
- You have Live Search enabled. In addition to the numbered sources above, you have access to **real-time results from ${liveSearch.sources.join(", ")}**. Use them to:
589
- 1. Verify recent claims (last ${liveSearch.fromDate ? "from " + liveSearch.fromDate : "few weeks"})
590
- 2. Add fresh data points the static sources may have missed
591
- 3. Surface X (Twitter) posts when the topic is moving quickly
592
- 4. Pull current numbers when the static sources are dated
593
-
594
- CITATION RULE for Live Search content:
595
- - Numbered sources [N] = static scraped sources at top of prompt
596
- - Real-time content from Live Search: cite inline as **(X post)** or **(news, [outlet])** WITHOUT [N] numbering - they'll be appended to the Sources section automatically
597
- - If a Live Search result contradicts a static source, FLAG it as "(real-time conflicts with [N])"`
598
- : "";
599
- const finalSections = isFinal
600
- ? `## TL;DR
601
- 2-3 sentences answering the question directly. No hedging unless evidence demands it.
602
-
603
- ## At a Glance
604
- A Markdown table summarizing 4-8 key metrics, dimensions, or status indicators from the sources. Format:
605
- | Dimension | Value / Status | Source |
606
- |---|---|---|
607
- | (example) Production adoption | 57% have agents in production | [3] |
608
-
609
- Use tables whenever you have:
610
- - Comparison data (X vs Y vs Z)
611
- - Status snapshots (multiple metrics at one point in time)
612
- - Rankings or scorecards
613
- - Dollar amounts, percentages, dates side-by-side
614
-
615
- This section is REQUIRED whenever the sources contain quantitative data. Skip ONLY if the topic is purely qualitative.
616
-
617
- ## Key Findings
618
- - 6-10 substantive bullets, each citing source numbers like [1] or [2,4]
619
- - Lead with specific named entities, dollar amounts, percentages, dates - not generalities
620
- - Mix angles - definition, current state, comparisons, criticisms
621
- - Tag each bullet with a confidence level at the end: \`(high)\` / \`(medium)\` / \`(low)\`
622
- - "high" means primary sources or strong consensus; "low" means single source or contested
623
-
624
- ## Analysis
625
- 3-5 paragraphs synthesizing the sources. Connect findings, note tensions and gaps, distinguish correlation from causation. Use inline citations throughout - every numerical claim or named entity must carry a [N].
626
-
627
- ## Counterevidence & Limitations
628
- - 2-4 bullets listing what could change the conclusion: weak sources, missing data, conflicting findings, age of evidence
629
- - This section is required - never skip it
630
-
631
- ## Follow-up Questions
632
- - 3-5 questions a curious reader would ask after reading this report
633
- - Make them concrete and answerable, not philosophical`
634
- : `## Draft Summary
635
- Single paragraph synthesis covering the main findings from sources, with inline citations.`;
636
- const profileBlock = profileContext
637
- ? `\n\n<user_profile>\n${profileContext}\n</user_profile>\n`
638
- : "";
639
- const memoryBlock = memoryContext
640
- ? `\n\n<user_memory>\nStored knowledge about this user — use to frame the report toward their known interests, not to fabricate facts:\n${memoryContext}\n</user_memory>\n`
641
- : "";
642
- const sys = `You are a senior analyst writing a structured research report from numbered web sources.
643
-
644
- ${(0, enrichment_router_js_1.todayContext)()}${profileBlock}${memoryBlock}
645
-
646
- OUTPUT FORMAT (strict - exact Markdown sections, in this order):
647
-
648
- # {short concrete title - max 10 words}
649
-
650
- ${finalSections}
651
-
652
- SOURCE CLASS TAGGING (mandatory in Key Findings):
653
- Each source in the list above is labeled [primary] / [expert] / [secondary] / [market].
654
- - [primary]: official docs, .gov/.edu, protocol data (DefiLlama, CoinGecko, etherscan), peer-reviewed
655
- - [expert]: named researchers, audit reports, tier-1 financial press (Reuters, FT, Bloomberg)
656
- - [secondary]: crypto media, newsletters, Wikipedia, GitHub
657
- - [market]: X/Twitter, Reddit, Substack, prediction markets - sentiment, not fact
658
-
659
- TAG RULES:
660
- - Every Key Findings bullet must end with the source class of its strongest citation, e.g. "[primary]" or "[market]"
661
- - Example: "Aerodrome TVL reached $2.1B in June 2026 [3] [primary]"
662
- - If a bullet's evidence mixes classes, use the LOWEST class: one Reddit citation drags the whole bullet to "[market]"
663
-
664
- CONTRADICTION RULES (mandatory):
665
- - When two sources disagree on a fact, do NOT average them. Surface the conflict explicitly:
666
- "Source [N] says X; source [M] says not-X - reconciliation: ..."
667
- - Put irreconcilable contradictions in Counterevidence & Limitations, not Key Findings
668
- - A single uncontested source is flagged: "(single source [N])"
669
-
670
- CITATION DENSITY RULES:
671
- - Every numerical claim (percentage, dollar amount, count, date) MUST carry [N]
672
- - Every named entity (company, product, framework, person) MUST carry [N] on first mention
673
- - Target: at least 1 citation per 50 words in Key Findings and Analysis
674
- - Note source dates when relevant - older sources may be stale
675
-
676
- STYLE RULES:
677
- - Be specific: numbers, names, dates over vague claims
678
- - Lead with concrete entities, not abstract concepts
679
- - Tables > bullets when comparing dimensions
680
- - No filler ("it is important to note", "in conclusion", "in today's world", "navigate the landscape")
681
- - No hedging when evidence is strong; no false confidence when it's weak
682
- - Don't write a Sources section - that gets appended automatically`;
683
- const freshBlock = freshMode && isFinal
684
- ? `
685
-
686
- ⏱ FRESH MODE active - today is ${todayISO()}:
687
- - Prioritize claims dated within the last 30 days. If a source is older, only cite it if it's primary evidence (data, official statement).
688
- - In the At a Glance table, include a "Date" column showing the publish date for each metric.
689
- - In Key Findings, prefix each bullet with the source's publish date in brackets: [2026-MM-DD] Finding text [N].
690
- - In Counterevidence, flag any claim whose evidence is older than 60 days as "(potentially stale).".`
691
- : "";
692
- const continuationBlock = priorContext && isFinal
693
- ? `
694
-
695
- PRIOR REPORT (you are CONTINUING this research, not starting fresh):
696
-
697
- \`\`\`
698
- ${priorContext.content.slice(0, 3500)}
699
- \`\`\`
700
-
701
- CONTINUATION RULES:
702
- - Start the TL;DR with: "Update to prior report \`${priorContext.key}\` -" followed by the new takeaway.
703
- - The "At a Glance" table must include a column "Δ since prior" showing what changed.
704
- - Key Findings must mark each bullet with: \`(NEW)\` for genuinely new info, \`(UPDATED)\` for changed numbers/positions, or \`(CONFIRMED)\` for points the new sources reinforce.
705
- - Counterevidence section must explicitly say which prior claims are now weaker.
706
- - Follow-up Questions must build on the prior report's open questions if they're still relevant.
707
-
708
- Do not re-explain background already in the prior report. Assume the reader read it.`
709
- : "";
710
- const enrichmentBlock = enrichment.hasData
711
- ? `\n\n${enrichment.context}\n\n`
712
- : "";
713
- // Specialist angles - when present, tell the synthesizer to mirror this
714
- // structure in the report (Key Findings organized by angle, At a Glance
715
- // table dimensions match the angles). Without this, the model blends
716
- // every angle into a homogeneous narrative.
717
- const anglesBlock = isFinal && angles && angles.length > 0
718
- ? `\n\nSPECIALIST ANGLES INVESTIGATED:
719
- ${angles.map((a, i) => `${i + 1}. **${a.label}** - ${a.rationale}`).join("\n")}
720
-
721
- Mirror this structure: Key Findings should group bullets by angle (use the angle label as a sub-header), and the At a Glance table dimensions should match the angles.`
722
- : "";
723
- // Critic notes (deep mode only) - explicit weaknesses surfaced during
724
- // the audit pass. The synthesizer must address each one rather than
725
- // simply ignoring it. This is the strongest single quality lever.
726
- const criticBlock = isFinal && criticNotes && criticNotes.length > 50
727
- ? `\n\nCRITIC AUDIT - concrete weaknesses found in the draft. You MUST address each one in the final report (either by revising the claim, surfacing the uncertainty, or providing additional support):
728
-
729
- ${criticNotes}
730
-
731
- If a critic concern can't be resolved with the sources you have, surface it in Counterevidence & Limitations rather than burying it.`
732
- : "";
733
- const user = `RESEARCH QUESTION: ${query}
734
- ${enrichmentBlock}
735
- SOURCES:
736
- ${sourceBlocks}${liveSearchNote}${continuationBlock}${freshBlock}${anglesBlock}${criticBlock}
737
-
738
- Write the ${isFinal ? "final" : "draft"} report now. Markdown only - no preamble, no postamble.${enrichment.hasData
739
- ? `\n\nIMPORTANT: The AUTHORITATIVE LIVE DATA block at the top contains current numbers from primary APIs (DefiLlama, CoinGecko). Lead with these numbers when they exist - they override any conflicting figures in the scraped sources below. Cite them as [DefiLlama] or [CoinGecko].`
740
- : ""}`;
741
- // Research synthesis uses FINCH_RESEARCH_MODEL when set. Default is
742
- // `grok-4.3` - when Bankr is the active gateway this routes to Grok 4.3
743
- // through Bankr (Grok handles fresh data better; Claude is the safer
744
- // pick for reasoning, JSON, code). Override via env or pass the same
745
- // model string to FINCH_MODEL to bypass.
746
- const researchModel = process.env.FINCH_RESEARCH_MODEL ?? "grok-4.3";
747
- // Deep mode (critic notes present, or the 5-angle planner ran) produces a
748
- // longer report than the standard 6-section template - a fixed 4000-token
749
- // budget was cutting deep reports off mid-sentence around risk #6-7 of 13+.
750
- const isDeepMode = !!criticNotes || (angles?.length ?? 0) >= 5;
751
- const finalTokens = isDeepMode ? 7000 : 4000;
752
- const raw = await (0, llm_js_1.callLLM)(sys, user, isFinal ? finalTokens : 2000, [], 90000, { liveSearch, model: researchModel });
753
- const { content: report, liveCitations } = extractLiveCitations(raw);
754
- // Citation density check - only for final reports. If the report has many
755
- // numerical claims but very few [N] citations, retry once with a stricter
756
- // instruction. Cheap insurance against lazy synthesis.
757
- if (!isFinal)
758
- return { report, liveCitations };
759
- const density = measureCitationDensity(report);
760
- if (density.numericalClaims >= 5 && density.citations < Math.max(3, density.numericalClaims / 2)) {
761
- const retryUser = `${user}
762
-
763
- ⚠️ Your previous draft had ${density.numericalClaims} numerical claims but only ${density.citations} [N] citations. That ratio is too low. Rewrite with stricter citation density: every percentage, dollar amount, count, date, and named entity must carry [N]. Use the At a Glance table to anchor the key metrics.`;
764
- try {
765
- const rawRetry = await (0, llm_js_1.callLLM)(sys, retryUser, finalTokens, [], 90000, { liveSearch, model: researchModel });
766
- const { content: retryReport, liveCitations: retryCitations } = extractLiveCitations(rawRetry);
767
- return { report: retryReport, liveCitations: retryCitations.length > 0 ? retryCitations : liveCitations };
768
- }
769
- catch {
770
- return { report, liveCitations };
771
- }
772
- }
773
- return { report, liveCitations };
774
- }
775
- // Strip the GROK_LIVE_CITATIONS sentinel block (added by callGrok when Live
776
- // Search ran) and return the citation URLs separately.
777
- function extractLiveCitations(raw) {
778
- const match = raw.match(/<!--GROK_LIVE_CITATIONS\n([\s\S]*?)\nGROK_LIVE_CITATIONS-->/);
779
- if (!match)
780
- return { content: raw, liveCitations: [] };
781
- const urls = match[1].split("\n").map((u) => u.trim()).filter(Boolean);
782
- return { content: raw.replace(match[0], "").trimEnd(), liveCitations: urls };
783
- }
784
- function measureCitationDensity(report) {
785
- // Numerical claims: percentages, dollar amounts, large counts, years
786
- const percentages = report.match(/\d+(?:\.\d+)?\s*%/g) ?? [];
787
- const dollars = report.match(/\$\s*\d+(?:\.\d+)?\s*(?:[KkMmBbTt]|million|billion|trillion)?/g) ?? [];
788
- const counts = report.match(/\b\d{1,3}(?:,\d{3})+\b/g) ?? [];
789
- const years = report.match(/\b(?:19|20)\d{2}\b/g) ?? [];
790
- const numericalClaims = percentages.length + dollars.length + counts.length + years.length;
791
- // Inline citations
792
- const citationsMatches = report.match(/\[\d+(?:\s*,\s*\d+)*\]/g) ?? [];
793
- const citations = citationsMatches.length;
794
- // Capitalized multi-word entities (proper nouns) - proxy for named entities
795
- const namedEntities = (report.match(/\b[A-Z][a-z]+(?:[A-Z][a-z]+|\s+[A-Z][a-z]+)\b/g) ?? []).length;
796
- return { numericalClaims, citations, namedEntities };
797
- }
798
- // Output structure validation - returns the names of any failed checks.
799
- // Used to decide whether the synthesis output is worth retrying.
800
- function validateReportStructure(report) {
801
- const issues = [];
802
- // 1. "At a Glance" section with a Markdown table
803
- const atGlance = report.match(/##\s*At a Glance[\s\S]*?(?=\n##|\n#|$)/i);
804
- if (!atGlance) {
805
- issues.push("missing-at-a-glance");
806
- }
807
- else {
808
- // Markdown table = at least 2 lines that start with `|`
809
- const tableLines = (atGlance[0].match(/^\|.+\|.+$/gm) ?? []).length;
810
- if (tableLines < 2)
811
- issues.push("at-a-glance-no-table");
812
- }
813
- // 2. Counterevidence section must exist and be non-trivial
814
- const counter = report.match(/##\s*Counterevidence[\s\S]*?(?=\n##|\n#|$)/i);
815
- if (!counter) {
816
- issues.push("missing-counterevidence");
817
- }
818
- else {
819
- const counterText = counter[0].replace(/##.*$/m, "").trim();
820
- if (counterText.length < 80)
821
- issues.push("counterevidence-too-short");
822
- }
823
- // 3. Citation density - every 200 words should have at least 1 [N] citation
824
- // in the Key Findings + Analysis sections
825
- const findingsAndAnalysis = report
826
- .replace(/^#.+$/m, "") // strip title
827
- .replace(/##\s*(TL;DR|At a Glance|Sources|Follow-up Questions)[\s\S]*?(?=\n##|\n#|$)/gi, "")
828
- .replace(/##\s*Counterevidence[\s\S]*?(?=\n##|\n#|$)/gi, "");
829
- const wordCount = findingsAndAnalysis.split(/\s+/).filter(Boolean).length;
830
- const citationCount = (findingsAndAnalysis.match(/\[\d+(?:\s*,\s*\d+)*\]/g) ?? []).length;
831
- if (wordCount >= 200 && citationCount < Math.floor(wordCount / 200)) {
832
- issues.push("low-citation-density");
833
- }
834
- // 4. Follow-up Questions section present
835
- if (!report.match(/##\s*Follow-up Questions/i)) {
836
- issues.push("missing-followups");
837
- }
838
- return issues;
839
- }
840
- // Build a search query for finding related vault entries. Prefer high-signal
841
- // terms from the user query, dropping common research filler words.
842
- function buildSearchTermsForLinking(query) {
843
- const terms = extractQueryTerms(query);
844
- return terms.length > 0 ? terms.join(" ") : query;
845
- }
846
- // Extract a vault key from a search hit. The /vault/search endpoint returns
847
- // semantic memory documents whose metadata may or may not contain a vault key.
848
- function extractVaultKeyFromHit(hit) {
849
- // Heuristic 1: metadata.vaultKey or metadata.key
850
- if (hit.metadata?.vaultKey && typeof hit.metadata.vaultKey === "string")
851
- return hit.metadata.vaultKey;
852
- if (hit.metadata?.key && typeof hit.metadata.key === "string")
853
- return hit.metadata.key;
854
- // Heuristic 2: content first line matches the vault key path pattern
855
- const firstLine = (hit.content ?? "").split("\n", 1)[0];
856
- const m = firstLine.match(/^(research|memory|workflow|prompt|execution|file|credential)\/[a-z0-9\-/]+/i);
857
- if (m)
858
- return m[0];
859
- return null;
860
- }
861
- // Group Live Search citations by source type (X, news, web) for readability.
862
- function formatLiveCitations(urls) {
863
- const groups = { "X (Twitter)": [], "News": [], "Web": [] };
864
- for (const url of urls) {
865
- if (/x\.com|twitter\.com/i.test(url))
866
- groups["X (Twitter)"].push(url);
867
- else if (/(reuters|apnews|bbc|cnbc|bloomberg|theverge|techcrunch|wsj|ft|nytimes|coindesk|axios|economist)\.com/i.test(url))
868
- groups["News"].push(url);
869
- else
870
- groups["Web"].push(url);
871
- }
872
- const lines = [];
873
- for (const [label, list] of Object.entries(groups)) {
874
- if (list.length === 0)
875
- continue;
876
- lines.push(`**${label}** (${list.length}):`);
877
- for (const url of list.slice(0, 8)) {
878
- lines.push(`- ${url}`);
879
- }
880
- if (list.length > 8)
881
- lines.push(`- _…and ${list.length - 8} more_`);
882
- lines.push("");
883
- }
884
- return lines.join("\n").trimEnd();
885
- }
886
- // ─── Source ranking ───────────────────────────────────────────────────────────
887
- function rankAndDedupe(candidates, freshMode) {
888
- // Score: search-rank inverse + domain tier bonus. Lower queryRank = higher.
889
- // In fresh mode, give news domains an extra +2 boost so recent reporting
890
- // ranks above evergreen content.
891
- const scored = candidates.map((c) => ({
892
- ...c,
893
- score: -c.queryRank + tierBonus(c.url) + (freshMode && NEWS_DOMAIN_BOOST_RE.test(c.url) ? 2 : 0),
894
- domain: domainOf(c.url),
895
- }));
896
- scored.sort((a, b) => b.score - a.score);
897
- // Domain diversity - cap MAX_PER_DOMAIN sources from same domain
898
- const seenDomain = new Map();
899
- const seenUrl = new Set();
900
- const result = [];
901
- for (const c of scored) {
902
- if (seenUrl.has(c.url))
903
- continue;
904
- const count = seenDomain.get(c.domain) ?? 0;
905
- if (count >= MAX_PER_DOMAIN)
906
- continue;
907
- seenUrl.add(c.url);
908
- seenDomain.set(c.domain, count + 1);
909
- result.push(c);
910
- }
911
- return result;
912
- }
14
+ const deep_research_constants_js_1 = require("./deep-research-constants.js");
15
+ const deep_research_tools_js_1 = require("./deep-research-tools.js");
16
+ const deep_research_text_js_1 = require("./deep-research-text.js");
17
+ const deep_research_firecrawl_js_1 = require("./deep-research-firecrawl.js");
18
+ const deep_research_planning_js_1 = require("./deep-research-planning.js");
19
+ const deep_research_synthesis_js_1 = require("./deep-research-synthesis.js");
20
+ var deep_research_tools_js_2 = require("./deep-research-tools.js");
21
+ Object.defineProperty(exports, "DEEP_RESEARCH_TOOLS", { enumerable: true, get: function () { return deep_research_tools_js_2.DEEP_RESEARCH_TOOLS; } });
913
22
  async function handleDeepResearch(name, args, onProgress) {
914
23
  if (name !== "deep_research")
915
24
  return null;
916
- const parsed = InputSchema.safeParse(args);
25
+ const parsed = deep_research_tools_js_1.InputSchema.safeParse(args);
917
26
  if (!parsed.success) {
918
27
  return { content: [{ type: "text", text: `${parsed.error.issues[0].message}` }], isError: true };
919
28
  }
@@ -925,7 +34,7 @@ async function handleDeepResearch(name, args, onProgress) {
925
34
  const depth = parsed.data.depth ?? "standard";
926
35
  const saveToVault = parsed.data.saveToVault ?? true;
927
36
  // Fresh mode: auto-detect from query unless explicitly set.
928
- const freshModeAuto = FRESH_TRIGGER_RE.test(query);
37
+ const freshModeAuto = deep_research_constants_js_1.FRESH_TRIGGER_RE.test(query);
929
38
  const freshMode = parsed.data.freshMode ?? freshModeAuto;
930
39
  const freshDays = parsed.data.freshDays ?? 14;
931
40
  const freshConfig = freshMode ? { days: freshDays } : undefined;
@@ -960,7 +69,7 @@ async function handleDeepResearch(name, args, onProgress) {
960
69
  const useReflection = depth !== "fast";
961
70
  const useCritic = depth === "deep";
962
71
  const useAngles = depth !== "fast";
963
- const queryTerms = extractQueryTerms(query);
72
+ const queryTerms = (0, deep_research_text_js_1.extractQueryTerms)(query);
964
73
  const progress = [];
965
74
  // log() retains backwards-compat (collects messages for the final report
966
75
  // footer), and also emits MCP progress notifications when the client
@@ -1013,21 +122,21 @@ async function handleDeepResearch(name, args, onProgress) {
1013
122
  log(`🧭 Using ${subQs.length} caller-supplied queries (no planner call).`);
1014
123
  }
1015
124
  else if (!(0, llm_js_1.hasDirectLLMKey)() && !sourcesMode) {
1016
- subQs = deriveSubQueries(query, subN, focus, freshConfig);
125
+ subQs = (0, deep_research_planning_js_1.deriveSubQueries)(query, subN, focus, freshConfig);
1017
126
  log(`🧭 Derived ${subQs.length} sub-queries without a planner (no LLM key).`);
1018
127
  }
1019
128
  else if (sourcesMode) {
1020
129
  // Evidence-pack mode must never require a key. Derive locally.
1021
- subQs = deriveSubQueries(query, subN, focus, freshConfig);
130
+ subQs = (0, deep_research_planning_js_1.deriveSubQueries)(query, subN, focus, freshConfig);
1022
131
  log(`🧭 Derived ${subQs.length} sub-queries. Pass \`queries\` to control the angles yourself.`);
1023
132
  }
1024
133
  else if (useAngles) {
1025
134
  log(`🧭 Planning ${angleN} specialist angles${priorContext ? " (continuation mode)" : ""}${freshMode ? " (fresh mode)" : ""}...`);
1026
- angles = await planAngles(query, angleN, focus, priorContext?.content, freshConfig);
135
+ angles = await (0, deep_research_planning_js_1.planAngles)(query, angleN, focus, priorContext?.content, freshConfig);
1027
136
  if (angles.length === 0) {
1028
137
  // Planner returned nothing usable - fall back to flat sub-query mode
1029
138
  log(`⚠️ Angle planner failed, falling back to flat sub-query planning.`);
1030
- subQs = await planQueries(query, subN, focus, priorContext?.content, freshConfig);
139
+ subQs = await (0, deep_research_planning_js_1.planQueries)(query, subN, focus, priorContext?.content, freshConfig);
1031
140
  }
1032
141
  else {
1033
142
  const seen = new Set();
@@ -1046,11 +155,11 @@ async function handleDeepResearch(name, args, onProgress) {
1046
155
  }
1047
156
  else {
1048
157
  log(`🧭 Planning ${subN} sub-questions${priorContext ? " (continuation mode)" : ""}${freshMode ? " (fresh mode)" : ""}...`);
1049
- subQs = await planQueries(query, subN, focus, priorContext?.content, freshConfig);
158
+ subQs = await (0, deep_research_planning_js_1.planQueries)(query, subN, focus, priorContext?.content, freshConfig);
1050
159
  }
1051
160
  // ── Stage 2: parallel search ──
1052
161
  log(`🔎 Searching ${subQs.length} queries × ${searchLimit} results each...`);
1053
- const searchResults = await Promise.all(subQs.map((q) => fcSearch(q, searchLimit)));
162
+ const searchResults = await Promise.all(subQs.map((q) => (0, deep_research_firecrawl_js_1.fcSearch)(q, searchLimit)));
1054
163
  // Flatten with rank info
1055
164
  const allCandidates = [];
1056
165
  for (const results of searchResults) {
@@ -1062,8 +171,8 @@ async function handleDeepResearch(name, args, onProgress) {
1062
171
  if (allCandidates.length === 0) {
1063
172
  return { content: [{ type: "text", text: `No sources found for: "${query}". Try a more specific query or a different focus angle.` }], isError: true };
1064
173
  }
1065
- const ranked = rankAndDedupe(allCandidates, freshMode).slice(0, maxScrape);
1066
- log(`📊 Ranked ${allCandidates.length} candidates → ${ranked.length} after domain dedup (max ${MAX_PER_DOMAIN}/domain)${freshMode ? " + news-domain boost" : ""}.`);
174
+ const ranked = (0, deep_research_text_js_1.rankAndDedupe)(allCandidates, freshMode).slice(0, maxScrape);
175
+ log(`📊 Ranked ${allCandidates.length} candidates → ${ranked.length} after domain dedup (max ${deep_research_constants_js_1.MAX_PER_DOMAIN}/domain)${freshMode ? " + news-domain boost" : ""}.`);
1067
176
  // ── Stage 3: parallel scrape ──
1068
177
  log(`📥 Scraping ${ranked.length} sources in parallel...`);
1069
178
  // Track why sources drop out. Silently returning fewer sources makes a thin
@@ -1072,29 +181,29 @@ async function handleDeepResearch(name, args, onProgress) {
1072
181
  let unreachable = 0;
1073
182
  let tooThin = 0;
1074
183
  const scraped = await Promise.all(ranked.map(async (c) => {
1075
- const r = await fcScrape(c.url);
184
+ const r = await (0, deep_research_firecrawl_js_1.fcScrape)(c.url);
1076
185
  if (!r) {
1077
186
  unreachable++;
1078
187
  return null;
1079
188
  }
1080
- const excerpt = pickBestExcerpt(r.markdown, queryTerms);
189
+ const excerpt = (0, deep_research_text_js_1.pickBestExcerpt)(r.markdown, queryTerms);
1081
190
  if (excerpt.length < 150) {
1082
191
  tooThin++;
1083
192
  return null;
1084
193
  }
1085
194
  return {
1086
195
  url: c.url,
1087
- domain: domainOf(c.url),
196
+ domain: (0, deep_research_text_js_1.domainOf)(c.url),
1088
197
  title: c.title,
1089
198
  excerpt,
1090
199
  publishedAt: r.publishedAt,
1091
- score: tierBonus(c.url),
200
+ score: (0, deep_research_text_js_1.tierBonus)(c.url),
1092
201
  };
1093
202
  }));
1094
203
  let sources = scraped
1095
204
  .filter((s) => s !== null)
1096
205
  .slice(0, maxSources)
1097
- .map((s, i) => ({ n: i + 1, ...s, class: classifySource(s.score) }));
206
+ .map((s, i) => ({ n: i + 1, ...s, class: (0, deep_research_synthesis_js_1.classifySource)(s.score) }));
1098
207
  const dropped = unreachable + tooThin;
1099
208
  if (dropped > 0) {
1100
209
  const parts = [];
@@ -1161,7 +270,7 @@ async function handleDeepResearch(name, args, onProgress) {
1161
270
  if (useReflection) {
1162
271
  log(`✍️ Drafting initial report (will reflect & refine)...`);
1163
272
  try {
1164
- const draftResult = await synthesize(query, sources, false, liveSearch);
273
+ const draftResult = await (0, deep_research_synthesis_js_1.synthesize)(query, sources, false, liveSearch);
1165
274
  draft = draftResult.report;
1166
275
  }
1167
276
  catch {
@@ -1172,10 +281,10 @@ async function handleDeepResearch(name, args, onProgress) {
1172
281
  // ── Stage 5: reflection - find gaps, search again ──
1173
282
  if (useReflection && draft) {
1174
283
  log(`🤔 Auditing draft for gaps...`);
1175
- const gapQs = await reflectAndExtend(query, draft, subQs, depth === "deep" ? 3 : 2);
284
+ const gapQs = await (0, deep_research_planning_js_1.reflectAndExtend)(query, draft, subQs, depth === "deep" ? 3 : 2);
1176
285
  if (gapQs.length > 0) {
1177
286
  log(`🔁 Running ${gapQs.length} gap-search(es)...`);
1178
- const gapResults = await Promise.all(gapQs.map((q) => fcSearch(q, 3)));
287
+ const gapResults = await Promise.all(gapQs.map((q) => (0, deep_research_firecrawl_js_1.fcSearch)(q, 3)));
1179
288
  const gapCandidates = [];
1180
289
  const existingUrls = new Set(sources.map((s) => s.url));
1181
290
  for (const results of gapResults) {
@@ -1185,23 +294,23 @@ async function handleDeepResearch(name, args, onProgress) {
1185
294
  }
1186
295
  });
1187
296
  }
1188
- const gapRanked = rankAndDedupe(gapCandidates, freshMode).slice(0, gapQs.length * 2);
297
+ const gapRanked = (0, deep_research_text_js_1.rankAndDedupe)(gapCandidates, freshMode).slice(0, gapQs.length * 2);
1189
298
  const gapScraped = await Promise.all(gapRanked.map(async (c) => {
1190
- const r = await fcScrape(c.url);
299
+ const r = await (0, deep_research_firecrawl_js_1.fcScrape)(c.url);
1191
300
  if (!r || r.markdown.length < 200)
1192
301
  return null;
1193
302
  return {
1194
303
  url: c.url,
1195
- domain: domainOf(c.url),
304
+ domain: (0, deep_research_text_js_1.domainOf)(c.url),
1196
305
  title: c.title,
1197
- excerpt: pickBestExcerpt(r.markdown, queryTerms),
306
+ excerpt: (0, deep_research_text_js_1.pickBestExcerpt)(r.markdown, queryTerms),
1198
307
  publishedAt: r.publishedAt,
1199
- score: tierBonus(c.url),
308
+ score: (0, deep_research_text_js_1.tierBonus)(c.url),
1200
309
  };
1201
310
  }));
1202
311
  const newSources = gapScraped.filter((s) => s !== null);
1203
312
  const startN = sources.length;
1204
- const appended = newSources.map((s, i) => ({ n: startN + i + 1, ...s, class: classifySource(s.score) }));
313
+ const appended = newSources.map((s, i) => ({ n: startN + i + 1, ...s, class: (0, deep_research_synthesis_js_1.classifySource)(s.score) }));
1205
314
  sources = [...sources, ...appended].slice(0, maxSources + 4);
1206
315
  log(`✅ Added ${appended.length} gap-fill source(s).`);
1207
316
  }
@@ -1215,7 +324,7 @@ async function handleDeepResearch(name, args, onProgress) {
1215
324
  let criticNotes = "";
1216
325
  if (useCritic && draft) {
1217
326
  log(`⚖️ Critic audit - challenging draft for single-source claims & contradictions...`);
1218
- criticNotes = await runCritic(query, draft, angles, sources.length);
327
+ criticNotes = await (0, deep_research_planning_js_1.runCritic)(query, draft, angles, sources.length);
1219
328
  if (criticNotes) {
1220
329
  // Tolerant verdict extraction - the critic LLM doesn't always honor
1221
330
  // the "Net recommendation: X" sentinel exactly. Look for any of the
@@ -1239,7 +348,7 @@ async function handleDeepResearch(name, args, onProgress) {
1239
348
  let report;
1240
349
  let liveCitations = [];
1241
350
  try {
1242
- const finalResult = await synthesize(query, sources, true, liveSearch, priorContext, freshMode, angles, criticNotes);
351
+ const finalResult = await (0, deep_research_synthesis_js_1.synthesize)(query, sources, true, liveSearch, priorContext, freshMode, angles, criticNotes);
1243
352
  report = finalResult.report;
1244
353
  liveCitations = finalResult.liveCitations;
1245
354
  }
@@ -1249,12 +358,12 @@ async function handleDeepResearch(name, args, onProgress) {
1249
358
  // ── Stage 6b: structural output validation ──
1250
359
  // Verify the report contains the required sections + adequate citation
1251
360
  // density. If 2+ checks fail, retry synthesis once with stricter prompt.
1252
- const issues = validateReportStructure(report);
361
+ const issues = (0, deep_research_synthesis_js_1.validateReportStructure)(report);
1253
362
  if (issues.length >= 2) {
1254
363
  log(`🔧 Output validation found ${issues.length} issues (${issues.join(", ")}). Retrying synthesis with stricter prompt...`);
1255
364
  try {
1256
- const retryResult = await synthesize(query, sources, true, liveSearch, priorContext, freshMode, angles, criticNotes);
1257
- const retryIssues = validateReportStructure(retryResult.report);
365
+ const retryResult = await (0, deep_research_synthesis_js_1.synthesize)(query, sources, true, liveSearch, priorContext, freshMode, angles, criticNotes);
366
+ const retryIssues = (0, deep_research_synthesis_js_1.validateReportStructure)(retryResult.report);
1258
367
  if (retryIssues.length < issues.length) {
1259
368
  report = retryResult.report;
1260
369
  if (retryResult.liveCitations.length > 0)
@@ -1285,7 +394,7 @@ async function handleDeepResearch(name, args, onProgress) {
1285
394
  .join("\n\n");
1286
395
  // Group live citations by source type for readability
1287
396
  const liveSection = liveCitations.length > 0
1288
- ? `\n\n### 🛰 Real-time sources (Live Search)\n\n${formatLiveCitations(liveCitations)}`
397
+ ? `\n\n### 🛰 Real-time sources (Live Search)\n\n${(0, deep_research_synthesis_js_1.formatLiveCitations)(liveCitations)}`
1289
398
  : "";
1290
399
  const fullReport = `${report.trim()}\n\n## Sources\n\n${sourcesSection}${liveSection}`;
1291
400
  // ── Stage 6.5: signal gate ──
@@ -1359,7 +468,7 @@ async function handleDeepResearch(name, args, onProgress) {
1359
468
  const linkedKeys = [];
1360
469
  if (vaultKey && saveToVault) {
1361
470
  try {
1362
- const searchTerms = buildSearchTermsForLinking(query);
471
+ const searchTerms = (0, deep_research_text_js_1.buildSearchTermsForLinking)(query);
1363
472
  log(`🔗 Searching vault for related research (terms: ${searchTerms.slice(0, 60)}...)`);
1364
473
  const searchResult = (await (0, convex_js_1.callConvex)("/vault/search", "POST", {
1365
474
  q: searchTerms,
@@ -1370,7 +479,7 @@ async function handleDeepResearch(name, args, onProgress) {
1370
479
  // The /vault/search endpoint returns documents from the semantic
1371
480
  // memory layer, not vault keys directly. We need to extract vault
1372
481
  // keys from the metadata when present.
1373
- key: extractVaultKeyFromHit(r),
482
+ key: (0, deep_research_text_js_1.extractVaultKeyFromHit)(r),
1374
483
  title: r.metadata?.title ?? "(untitled)",
1375
484
  }))
1376
485
  .filter((h) => !!h.key && h.key !== vaultKey)