@finchagentic/mcp 4.6.2 → 4.6.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 (59) hide show
  1. package/README.md +39 -74
  2. package/dist/_http-cache.js +96 -0
  3. package/dist/_text-search.js +39 -0
  4. package/dist/agent-loop.js +301 -0
  5. package/dist/annotations.js +122 -0
  6. package/dist/cli.js +1391 -0
  7. package/dist/clink-input.js +15 -0
  8. package/dist/config.js +132 -0
  9. package/dist/convex.js +175 -0
  10. package/dist/dex-pair.js +54 -0
  11. package/dist/enrichment-router.js +315 -0
  12. package/dist/index.js +258 -0
  13. package/dist/llm.js +298 -0
  14. package/dist/local-memory-file.js +150 -0
  15. package/dist/local-memory.js +135 -0
  16. package/dist/local-vault.js +456 -0
  17. package/dist/output-schemas.js +605 -0
  18. package/dist/project.js +36 -0
  19. package/dist/prompts.js +111 -0
  20. package/dist/public-url.js +107 -0
  21. package/dist/resources.js +111 -0
  22. package/dist/server.js +322 -0
  23. package/dist/signal-gate.js +57 -0
  24. package/dist/token-decimals.js +26 -0
  25. package/dist/token-gate.js +88 -0
  26. package/dist/tool-filter.js +53 -0
  27. package/dist/tools/_solidity-scan.js +313 -0
  28. package/dist/tools/agents.js +441 -0
  29. package/dist/tools/automation.js +354 -0
  30. package/dist/tools/base-mcp.js +466 -0
  31. package/dist/tools/base.js +283 -0
  32. package/dist/tools/chronicle.js +268 -0
  33. package/dist/tools/coder.js +94 -0
  34. package/dist/tools/deep-research.js +1421 -0
  35. package/dist/tools/defi.js +292 -0
  36. package/dist/tools/equity.js +372 -0
  37. package/dist/tools/events.js +182 -0
  38. package/dist/tools/github.js +564 -0
  39. package/dist/tools/insider.js +264 -0
  40. package/dist/tools/insight.js +630 -0
  41. package/dist/tools/market.js +555 -0
  42. package/dist/tools/memory.js +1059 -0
  43. package/dist/tools/miroshark.js +350 -0
  44. package/dist/tools/monitor.js +319 -0
  45. package/dist/tools/os.js +236 -0
  46. package/dist/tools/packets.js +296 -0
  47. package/dist/tools/research-chain.js +226 -0
  48. package/dist/tools/research-compare.js +280 -0
  49. package/dist/tools/research.js +188 -0
  50. package/dist/tools/rh-bridge.js +148 -0
  51. package/dist/tools/rh-mcp.js +1448 -0
  52. package/dist/tools/rh-orders.js +556 -0
  53. package/dist/tools/scanner.js +564 -0
  54. package/dist/tools/stake.js +369 -0
  55. package/dist/tools/vault.js +1020 -0
  56. package/dist/tools/wallet.js +200 -0
  57. package/dist/types.js +2 -0
  58. package/dist/wallet.js +372 -0
  59. package/package.json +4 -7
@@ -0,0 +1,1421 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEEP_RESEARCH_TOOLS = void 0;
4
+ exports.handleDeepResearch = handleDeepResearch;
5
+ const zod_1 = require("zod");
6
+ const llm_js_1 = require("../llm.js");
7
+ const convex_js_1 = require("../convex.js");
8
+ 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
+ }
913
+ async function handleDeepResearch(name, args, onProgress) {
914
+ if (name !== "deep_research")
915
+ return null;
916
+ const parsed = InputSchema.safeParse(args);
917
+ if (!parsed.success) {
918
+ return { content: [{ type: "text", text: `${parsed.error.issues[0].message}` }], isError: true };
919
+ }
920
+ const { query, focus, continueFrom } = parsed.data;
921
+ const clientQueries = parsed.data.queries ?? [];
922
+ // Default to the evidence pack: it always works (no key) and lets the caller
923
+ // do the synthesis it is better at. 'report' is the opt-in legacy path.
924
+ const sourcesMode = (parsed.data.mode ?? "sources") === "sources";
925
+ const depth = parsed.data.depth ?? "standard";
926
+ const saveToVault = parsed.data.saveToVault ?? true;
927
+ // Fresh mode: auto-detect from query unless explicitly set.
928
+ const freshModeAuto = FRESH_TRIGGER_RE.test(query);
929
+ const freshMode = parsed.data.freshMode ?? freshModeAuto;
930
+ const freshDays = parsed.data.freshDays ?? 14;
931
+ const freshConfig = freshMode ? { days: freshDays } : undefined;
932
+ // Live Search resolution - opt-in only when Grok is the active provider.
933
+ // Default: enable when Grok is active (smart default - they paid for the
934
+ // feature, use it), disable otherwise (other providers ignore it anyway).
935
+ const grokActive = (0, llm_js_1.isGrokActive)();
936
+ const liveSearchEnabled = (parsed.data.liveSearch ?? grokActive) && grokActive;
937
+ const liveSearch = liveSearchEnabled
938
+ ? {
939
+ mode: "on",
940
+ sources: parsed.data.liveSearchSources ?? ["web", "x", "news"],
941
+ maxResults: depth === "fast" ? 8 : depth === "deep" ? 18 : 12,
942
+ fromDate: parsed.data.liveSearchDays
943
+ ? new Date(Date.now() - parsed.data.liveSearchDays * 86400000).toISOString().slice(0, 10)
944
+ : undefined,
945
+ }
946
+ : undefined;
947
+ // No early FIRECRAWL_API_KEY gate - fcSearch/fcScrape transparently fall
948
+ // through to the Finch backend proxy when the user is signed in but
949
+ // has no local key. If both paths fail, the search loop returns an empty
950
+ // source list and the synthesis stage produces the "insufficient signal"
951
+ // skip report instead of a fake summary.
952
+ // Depth knobs
953
+ // Multi-agent mode (standard/deep): N angles × 2-3 queries each = effective
954
+ // sub-query count. Fast mode keeps the original flat-planner behavior.
955
+ const angleN = depth === "deep" ? 5 : 3;
956
+ const subN = depth === "fast" ? 3 : depth === "deep" ? 7 : 5;
957
+ const searchLimit = depth === "fast" ? 4 : depth === "deep" ? 6 : 5;
958
+ const maxScrape = depth === "fast" ? 10 : depth === "deep" ? 22 : 16;
959
+ const maxSources = depth === "fast" ? 10 : depth === "deep" ? 20 : 14;
960
+ const useReflection = depth !== "fast";
961
+ const useCritic = depth === "deep";
962
+ const useAngles = depth !== "fast";
963
+ const queryTerms = extractQueryTerms(query);
964
+ const progress = [];
965
+ // log() retains backwards-compat (collects messages for the final report
966
+ // footer), and also emits MCP progress notifications when the client
967
+ // opted in via _meta.progressToken (handled at the server.ts layer).
968
+ // Estimated total steps for fast/standard/deep - used as `total` in the
969
+ // notification so progress UIs can render a percentage.
970
+ const totalSteps = depth === "deep" ? 9 : depth === "standard" ? 8 : 6;
971
+ const log = (line) => {
972
+ progress.push(line);
973
+ if (onProgress) {
974
+ // Fire-and-forget - never let progress emission slow the pipeline.
975
+ Promise.resolve(onProgress(line.replace(/^[^\w]+/, "").trim(), totalSteps)).catch(() => { });
976
+ }
977
+ };
978
+ if (liveSearch) {
979
+ log(`🛰 Live Search: ON · sources=[${liveSearch.sources.join(", ")}] · max=${liveSearch.maxResults}${liveSearch.fromDate ? ` · since=${liveSearch.fromDate}` : ""}`);
980
+ }
981
+ if (freshMode) {
982
+ log(`⏱ Fresh mode: ON · last ${freshDays} days · news-domain boost active${freshModeAuto && !parsed.data.freshMode ? " (auto-detected from query)" : ""}`);
983
+ }
984
+ // ── Stage 0: load prior report (continueFrom) ──
985
+ let priorContext;
986
+ if (continueFrom) {
987
+ log(`📚 Loading prior report \`${continueFrom}\` to continue research thread...`);
988
+ try {
989
+ const prior = await (0, convex_js_1.callConvex)(`/vault/entry?key=${encodeURIComponent(continueFrom)}`, "GET", undefined, "vault_read");
990
+ if (prior?.content && prior.content.length > 100) {
991
+ priorContext = { key: prior.key ?? continueFrom, content: prior.content };
992
+ log(`✅ Loaded prior report (${prior.content.length} chars) - planner & synth will build on it.`);
993
+ }
994
+ else {
995
+ log(`⚠️ Prior report \`${continueFrom}\` not found or empty - proceeding as fresh research.`);
996
+ }
997
+ }
998
+ catch (err) {
999
+ log(`⚠️ Could not load prior report (${err.message ?? "error"}) - proceeding as fresh research.`);
1000
+ }
1001
+ }
1002
+ // ── Stage 1: plan ──
1003
+ // Multi-agent mode (standard/deep) decomposes into labeled angles. Each
1004
+ // angle has 2-3 sub-queries - flatten them for the search stage but keep
1005
+ // the angle structure for synthesis & critic prompts.
1006
+ let angles = [];
1007
+ let subQs;
1008
+ if (clientQueries.length > 0) {
1009
+ // The client is itself a strong model and knows the user's intent — when it
1010
+ // supplies the angles we skip the planner entirely. No LLM call, no key,
1011
+ // and the user can actually steer what gets researched.
1012
+ subQs = clientQueries;
1013
+ log(`🧭 Using ${subQs.length} caller-supplied queries (no planner call).`);
1014
+ }
1015
+ else if (!(0, llm_js_1.hasDirectLLMKey)() && !sourcesMode) {
1016
+ subQs = deriveSubQueries(query, subN, focus, freshConfig);
1017
+ log(`🧭 Derived ${subQs.length} sub-queries without a planner (no LLM key).`);
1018
+ }
1019
+ else if (sourcesMode) {
1020
+ // Evidence-pack mode must never require a key. Derive locally.
1021
+ subQs = deriveSubQueries(query, subN, focus, freshConfig);
1022
+ log(`🧭 Derived ${subQs.length} sub-queries. Pass \`queries\` to control the angles yourself.`);
1023
+ }
1024
+ else if (useAngles) {
1025
+ log(`🧭 Planning ${angleN} specialist angles${priorContext ? " (continuation mode)" : ""}${freshMode ? " (fresh mode)" : ""}...`);
1026
+ angles = await planAngles(query, angleN, focus, priorContext?.content, freshConfig);
1027
+ if (angles.length === 0) {
1028
+ // Planner returned nothing usable - fall back to flat sub-query mode
1029
+ log(`⚠️ Angle planner failed, falling back to flat sub-query planning.`);
1030
+ subQs = await planQueries(query, subN, focus, priorContext?.content, freshConfig);
1031
+ }
1032
+ else {
1033
+ const seen = new Set();
1034
+ const flat = [];
1035
+ for (const a of angles) {
1036
+ for (const q of a.queries) {
1037
+ if (!seen.has(q.toLowerCase())) {
1038
+ seen.add(q.toLowerCase());
1039
+ flat.push(q);
1040
+ }
1041
+ }
1042
+ }
1043
+ subQs = flat;
1044
+ log(`📐 Angles: ${angles.map((a) => `"${a.label}"`).join(" · ")} → ${subQs.length} unique queries.`);
1045
+ }
1046
+ }
1047
+ else {
1048
+ log(`🧭 Planning ${subN} sub-questions${priorContext ? " (continuation mode)" : ""}${freshMode ? " (fresh mode)" : ""}...`);
1049
+ subQs = await planQueries(query, subN, focus, priorContext?.content, freshConfig);
1050
+ }
1051
+ // ── Stage 2: parallel search ──
1052
+ log(`🔎 Searching ${subQs.length} queries × ${searchLimit} results each...`);
1053
+ const searchResults = await Promise.all(subQs.map((q) => fcSearch(q, searchLimit)));
1054
+ // Flatten with rank info
1055
+ const allCandidates = [];
1056
+ for (const results of searchResults) {
1057
+ results.forEach((r, idx) => {
1058
+ if (r.url)
1059
+ allCandidates.push({ url: r.url, title: r.title ?? r.url, desc: r.description ?? "", queryRank: idx });
1060
+ });
1061
+ }
1062
+ if (allCandidates.length === 0) {
1063
+ return { content: [{ type: "text", text: `No sources found for: "${query}". Try a more specific query or a different focus angle.` }], isError: true };
1064
+ }
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" : ""}.`);
1067
+ // ── Stage 3: parallel scrape ──
1068
+ log(`📥 Scraping ${ranked.length} sources in parallel...`);
1069
+ // Track why sources drop out. Silently returning fewer sources makes a thin
1070
+ // report look like a complete one — the user can't tell a well-supported
1071
+ // finding from one built on two pages that happened to load.
1072
+ let unreachable = 0;
1073
+ let tooThin = 0;
1074
+ const scraped = await Promise.all(ranked.map(async (c) => {
1075
+ const r = await fcScrape(c.url);
1076
+ if (!r) {
1077
+ unreachable++;
1078
+ return null;
1079
+ }
1080
+ const excerpt = pickBestExcerpt(r.markdown, queryTerms);
1081
+ if (excerpt.length < 150) {
1082
+ tooThin++;
1083
+ return null;
1084
+ }
1085
+ return {
1086
+ url: c.url,
1087
+ domain: domainOf(c.url),
1088
+ title: c.title,
1089
+ excerpt,
1090
+ publishedAt: r.publishedAt,
1091
+ score: tierBonus(c.url),
1092
+ };
1093
+ }));
1094
+ let sources = scraped
1095
+ .filter((s) => s !== null)
1096
+ .slice(0, maxSources)
1097
+ .map((s, i) => ({ n: i + 1, ...s, class: classifySource(s.score) }));
1098
+ const dropped = unreachable + tooThin;
1099
+ if (dropped > 0) {
1100
+ const parts = [];
1101
+ if (unreachable)
1102
+ parts.push(`${unreachable} unreachable/blocked`);
1103
+ if (tooThin)
1104
+ parts.push(`${tooThin} too thin to quote`);
1105
+ log(`⚠️ ${dropped} of ${ranked.length} sources dropped (${parts.join(", ")}) — ` +
1106
+ `report is based on ${sources.length}.`);
1107
+ }
1108
+ if (sources.length === 0) {
1109
+ return { content: [{ type: "text", text: `Could not scrape any usable sources for: "${query}". Sites may be blocking or paywalled.` }], isError: true };
1110
+ }
1111
+ // ── Evidence-pack mode: hand the sources back and stop ──
1112
+ // Everything below this point is the server writing prose. The caller is a
1113
+ // model with the user's full context; it synthesises better, it can be
1114
+ // steered mid-conversation, and it needs no API key to do it.
1115
+ if (sourcesMode) {
1116
+ // X / news via Grok Live Search — the one input the caller's model cannot
1117
+ // fetch for itself. Retrieved verbatim and appended as ordinary numbered
1118
+ // sources so the caller can weigh them (and discount anonymous accounts)
1119
+ // rather than receiving a pre-filtered opinion. Skipped silently with no key.
1120
+ let liveHits = [];
1121
+ const liveSources = liveSearch?.sources ?? ["x", "news", "web"];
1122
+ if (liveSearch) {
1123
+ log(`📡 Grok Live Search (${liveSources.join(", ")}) for real-time X/news...`);
1124
+ liveHits = await (0, llm_js_1.grokLiveSearchHits)(query, liveSources, parsed.data.liveSearchDays, depth === "fast" ? 6 : 10);
1125
+ log(liveHits.length ? `📡 ${liveHits.length} live item(s) retrieved.` : `📡 Live Search returned nothing usable.`);
1126
+ }
1127
+ log(`📦 Returning ${sources.length + liveHits.length} sources as an evidence pack.`);
1128
+ const pack = [
1129
+ `# Research evidence — "${query}"`,
1130
+ ``,
1131
+ `**${sources.length} sources** · queries run: ${subQs.length}${dropped > 0 ? ` · ${dropped} dropped` : ""}`,
1132
+ `_Synthesis is yours: cite with [N], and say if the evidence is thin or one-sided._`,
1133
+ ``,
1134
+ `**Queries searched:**`,
1135
+ ...subQs.map((q) => `- ${q}`),
1136
+ ``,
1137
+ `---`,
1138
+ ``,
1139
+ ];
1140
+ for (const s of sources) {
1141
+ pack.push(`### [${s.n}] ${s.title}`, `${s.url}`, `_${s.domain}${s.publishedAt ? ` · ${String(s.publishedAt).slice(0, 10)}` : ""} · ${s.class}_`, ``, s.excerpt, ``);
1142
+ }
1143
+ if (liveHits.length) {
1144
+ pack.push(`---`, ``, `## Real-time (${liveSources.join(" / ")}) — retrieved verbatim`, `_Unvetted social/news chatter. Weigh it yourself: check who is speaking, whether the claim is`, `corroborated by a numbered source above, and discount anonymous or promotional accounts._`, ``);
1145
+ liveHits.forEach((h, idx) => {
1146
+ pack.push(`### [L${idx + 1}] ${h.url || "(no url)"}`, ``, h.excerpt || "_(no excerpt returned)_`", ``);
1147
+ });
1148
+ }
1149
+ pack.push(`---`, ``);
1150
+ // Announce the capability rather than silently omitting it — a missing key
1151
+ // should look like an unconfigured option, not like the feature never existed.
1152
+ if (!liveSearch) {
1153
+ pack.push(`ℹ️ **No real-time X / news in this pack.** Web sources only. Set \`GROK_API_KEY\` to add ` +
1154
+ `live X and news items — it is optional, and everything above works without it.`, ``);
1155
+ }
1156
+ pack.push(`_Pass \`queries\` next time to control the angles. Use \`mode: "report"\` only if you want the server to write the prose (needs an LLM key)._`);
1157
+ return { content: [{ type: "text", text: pack.join("\n") }] };
1158
+ }
1159
+ // ── Stage 4: draft synthesis (only if reflection enabled) ──
1160
+ let draft = "";
1161
+ if (useReflection) {
1162
+ log(`✍️ Drafting initial report (will reflect & refine)...`);
1163
+ try {
1164
+ const draftResult = await synthesize(query, sources, false, liveSearch);
1165
+ draft = draftResult.report;
1166
+ }
1167
+ catch {
1168
+ // If draft fails, fall through to final synth with what we have
1169
+ draft = "";
1170
+ }
1171
+ }
1172
+ // ── Stage 5: reflection - find gaps, search again ──
1173
+ if (useReflection && draft) {
1174
+ log(`🤔 Auditing draft for gaps...`);
1175
+ const gapQs = await reflectAndExtend(query, draft, subQs, depth === "deep" ? 3 : 2);
1176
+ if (gapQs.length > 0) {
1177
+ log(`🔁 Running ${gapQs.length} gap-search(es)...`);
1178
+ const gapResults = await Promise.all(gapQs.map((q) => fcSearch(q, 3)));
1179
+ const gapCandidates = [];
1180
+ const existingUrls = new Set(sources.map((s) => s.url));
1181
+ for (const results of gapResults) {
1182
+ results.forEach((r, idx) => {
1183
+ if (r.url && !existingUrls.has(r.url)) {
1184
+ gapCandidates.push({ url: r.url, title: r.title ?? r.url, desc: r.description ?? "", queryRank: idx });
1185
+ }
1186
+ });
1187
+ }
1188
+ const gapRanked = rankAndDedupe(gapCandidates, freshMode).slice(0, gapQs.length * 2);
1189
+ const gapScraped = await Promise.all(gapRanked.map(async (c) => {
1190
+ const r = await fcScrape(c.url);
1191
+ if (!r || r.markdown.length < 200)
1192
+ return null;
1193
+ return {
1194
+ url: c.url,
1195
+ domain: domainOf(c.url),
1196
+ title: c.title,
1197
+ excerpt: pickBestExcerpt(r.markdown, queryTerms),
1198
+ publishedAt: r.publishedAt,
1199
+ score: tierBonus(c.url),
1200
+ };
1201
+ }));
1202
+ const newSources = gapScraped.filter((s) => s !== null);
1203
+ const startN = sources.length;
1204
+ const appended = newSources.map((s, i) => ({ n: startN + i + 1, ...s, class: classifySource(s.score) }));
1205
+ sources = [...sources, ...appended].slice(0, maxSources + 4);
1206
+ log(`✅ Added ${appended.length} gap-fill source(s).`);
1207
+ }
1208
+ else {
1209
+ log(`✅ No gaps found - draft is complete.`);
1210
+ }
1211
+ }
1212
+ // ── Stage 5b: critic (deep mode only) ──
1213
+ // Adversarial pass over the draft. Critic notes get prepended to the
1214
+ // final synthesizer's prompt so the model addresses concrete weaknesses.
1215
+ let criticNotes = "";
1216
+ if (useCritic && draft) {
1217
+ log(`⚖️ Critic audit - challenging draft for single-source claims & contradictions...`);
1218
+ criticNotes = await runCritic(query, draft, angles, sources.length);
1219
+ if (criticNotes) {
1220
+ // Tolerant verdict extraction - the critic LLM doesn't always honor
1221
+ // the "Net recommendation: X" sentinel exactly. Look for any of the
1222
+ // three verdict tokens near the end of the notes.
1223
+ const tail = criticNotes.slice(-400).toLowerCase();
1224
+ let verdict = "delivered";
1225
+ if (/\brevise|\brewrite|\bweak|\bfix/i.test(tail))
1226
+ verdict = "revise";
1227
+ if (/\breject|\binsufficient|\bcannot/i.test(tail))
1228
+ verdict = "reject";
1229
+ if (/\baccept|\bsolid|\bsound|\bgood|\bcorrect/i.test(tail))
1230
+ verdict = "accept";
1231
+ log(`✅ Critic ${verdict} (${criticNotes.length} chars of notes).`);
1232
+ }
1233
+ else {
1234
+ log(`⚠️ Critic returned no notes (LLM error or empty response).`);
1235
+ }
1236
+ }
1237
+ // ── Stage 6: final synthesis ──
1238
+ log(`📝 Writing final report from ${sources.length} sources${angles.length > 0 ? ` across ${angles.length} angle(s)` : ""}${criticNotes ? " (critic-audited)" : ""}${liveSearch ? " + Live Search" : ""}${priorContext ? ` (continuing \`${priorContext.key}\`)` : ""}${freshMode ? " (fresh)" : ""}...`);
1239
+ let report;
1240
+ let liveCitations = [];
1241
+ try {
1242
+ const finalResult = await synthesize(query, sources, true, liveSearch, priorContext, freshMode, angles, criticNotes);
1243
+ report = finalResult.report;
1244
+ liveCitations = finalResult.liveCitations;
1245
+ }
1246
+ catch (err) {
1247
+ return { content: [{ type: "text", text: `Synthesis failed: ${err.message ?? err}` }], isError: true };
1248
+ }
1249
+ // ── Stage 6b: structural output validation ──
1250
+ // Verify the report contains the required sections + adequate citation
1251
+ // density. If 2+ checks fail, retry synthesis once with stricter prompt.
1252
+ const issues = validateReportStructure(report);
1253
+ if (issues.length >= 2) {
1254
+ log(`🔧 Output validation found ${issues.length} issues (${issues.join(", ")}). Retrying synthesis with stricter prompt...`);
1255
+ try {
1256
+ const retryResult = await synthesize(query, sources, true, liveSearch, priorContext, freshMode, angles, criticNotes);
1257
+ const retryIssues = validateReportStructure(retryResult.report);
1258
+ if (retryIssues.length < issues.length) {
1259
+ report = retryResult.report;
1260
+ if (retryResult.liveCitations.length > 0)
1261
+ liveCitations = retryResult.liveCitations;
1262
+ log(`✅ Retry improved - ${retryIssues.length} issues remaining.`);
1263
+ }
1264
+ else {
1265
+ log(`⚠️ Retry didn't improve - keeping original.`);
1266
+ }
1267
+ }
1268
+ catch {
1269
+ log(`⚠️ Retry failed - keeping original output.`);
1270
+ }
1271
+ }
1272
+ else if (issues.length > 0) {
1273
+ log(`⚠️ Output has minor issues: ${issues.join(", ")}.`);
1274
+ }
1275
+ else {
1276
+ log(`✅ Output structure validated.`);
1277
+ }
1278
+ // Append sources list with snippets
1279
+ const sourcesSection = sources
1280
+ .map((s) => {
1281
+ const date = s.publishedAt ? ` (${s.publishedAt.slice(0, 10)})` : "";
1282
+ const cls = s.class !== "unclassified" ? ` · ${s.class}` : "";
1283
+ return `[${s.n}] **${s.title}** - ${s.domain}${date}${cls}\n ${s.url}`;
1284
+ })
1285
+ .join("\n\n");
1286
+ // Group live citations by source type for readability
1287
+ const liveSection = liveCitations.length > 0
1288
+ ? `\n\n### 🛰 Real-time sources (Live Search)\n\n${formatLiveCitations(liveCitations)}`
1289
+ : "";
1290
+ const fullReport = `${report.trim()}\n\n## Sources\n\n${sourcesSection}${liveSection}`;
1291
+ // ── Stage 6.5: signal gate ──
1292
+ // If the synthesis is thin (LLM admitted "search returned directory pages"
1293
+ // or content has no concrete data), return an honest "insufficient signal"
1294
+ // result instead of saving and citing a fake summary. The user can rerun
1295
+ // with a sharper query.
1296
+ const signal = (0, signal_gate_js_1.checkSignal)(report);
1297
+ if (!signal.ok) {
1298
+ const skipMsg = [
1299
+ `## ⚠️ Insufficient signal`,
1300
+ ``,
1301
+ `**Query:** ${query}`,
1302
+ `**Reason:** ${signal.reason}`,
1303
+ `**Signal score:** ${signal.score.toFixed(2)} / 1.00`,
1304
+ ``,
1305
+ `The search results were too thin to produce a substantive report. Try a narrower or more recent query, or scope to a specific domain.`,
1306
+ ``,
1307
+ `<details><summary>Raw synthesis (saved for audit, not vault)</summary>`,
1308
+ ``,
1309
+ "```",
1310
+ report.slice(0, 1500),
1311
+ "```",
1312
+ ``,
1313
+ `</details>`,
1314
+ ].join("\n");
1315
+ return { content: [{ type: "text", text: skipMsg }] };
1316
+ }
1317
+ // ── Stage 7: vault save ──
1318
+ let vaultKey = null;
1319
+ if (saveToVault) {
1320
+ try {
1321
+ const r = (await (0, convex_js_1.callConvex)("/vault/save", "POST", {
1322
+ type: "research",
1323
+ title: priorContext
1324
+ ? `Deep Research (cont.): ${query.slice(0, 80)}`
1325
+ : `Deep Research: ${query.slice(0, 80)}`,
1326
+ content: fullReport,
1327
+ tags: [
1328
+ "deep-research",
1329
+ depth,
1330
+ ...(focus ? [focus] : []),
1331
+ ...(priorContext ? ["continuation"] : []),
1332
+ ],
1333
+ agentId: "research",
1334
+ commitMsg: priorContext ? `deep_research continues ${priorContext.key}` : "deep_research run",
1335
+ }, "vault_save"));
1336
+ vaultKey = r?.key ?? null;
1337
+ }
1338
+ catch { /* keep going - return report inline */ }
1339
+ }
1340
+ // ── Stage 7b: link as continuation when continueFrom was used ──
1341
+ if (vaultKey && priorContext) {
1342
+ try {
1343
+ await (0, convex_js_1.callConvex)("/vault/link", "POST", {
1344
+ fromKey: vaultKey,
1345
+ toKey: priorContext.key,
1346
+ relation: "continues",
1347
+ }, "vault_link");
1348
+ log(`🧬 Linked new report as \`continues\` → \`${priorContext.key}\`.`);
1349
+ }
1350
+ catch {
1351
+ log(`⚠️ Could not create continuation link.`);
1352
+ }
1353
+ }
1354
+ // ── Stage 8: vault auto-linking - connect this report to related research ──
1355
+ // This is what makes Finch deep_research compound over time. Every new
1356
+ // report finds related past reports in your vault and creates typed links,
1357
+ // so your knowledge base grows into a connected graph (vault_related to
1358
+ // explore it). Best-effort - failure here never blocks the report.
1359
+ const linkedKeys = [];
1360
+ if (vaultKey && saveToVault) {
1361
+ try {
1362
+ const searchTerms = buildSearchTermsForLinking(query);
1363
+ log(`🔗 Searching vault for related research (terms: ${searchTerms.slice(0, 60)}...)`);
1364
+ const searchResult = (await (0, convex_js_1.callConvex)("/vault/search", "POST", {
1365
+ q: searchTerms,
1366
+ n: 8,
1367
+ }, "vault_search"));
1368
+ const hits = (searchResult?.results ?? [])
1369
+ .map((r) => ({
1370
+ // The /vault/search endpoint returns documents from the semantic
1371
+ // memory layer, not vault keys directly. We need to extract vault
1372
+ // keys from the metadata when present.
1373
+ key: extractVaultKeyFromHit(r),
1374
+ title: r.metadata?.title ?? "(untitled)",
1375
+ }))
1376
+ .filter((h) => !!h.key && h.key !== vaultKey)
1377
+ .slice(0, 3);
1378
+ if (hits.length > 0) {
1379
+ for (const hit of hits) {
1380
+ try {
1381
+ await (0, convex_js_1.callConvex)("/vault/link", "POST", {
1382
+ fromKey: vaultKey,
1383
+ toKey: hit.key,
1384
+ relation: "related",
1385
+ }, "vault_link");
1386
+ linkedKeys.push(hit.key);
1387
+ }
1388
+ catch { /* skip individual link failures */ }
1389
+ }
1390
+ log(`✅ Linked to ${linkedKeys.length} related vault entr${linkedKeys.length === 1 ? "y" : "ies"}.`);
1391
+ }
1392
+ else {
1393
+ log(`✅ No related vault entries found - this is a fresh research thread.`);
1394
+ }
1395
+ }
1396
+ catch {
1397
+ // Vault auto-linking is purely additive - silent failure is fine
1398
+ log(`⚠️ Vault auto-link skipped (search unavailable).`);
1399
+ }
1400
+ }
1401
+ const linkedSection = linkedKeys.length > 0
1402
+ ? `🔗 Auto-linked to ${linkedKeys.length} related research entr${linkedKeys.length === 1 ? "y" : "ies"} in your vault:\n${linkedKeys.map((k) => ` • \`${k}\``).join("\n")}`
1403
+ : "";
1404
+ const continuationSection = priorContext
1405
+ ? `🧬 **Continuation** of \`${priorContext.key}\` - linked as relation:continues`
1406
+ : "";
1407
+ const header = [
1408
+ `🔬 **Deep Research v3** - depth: ${depth} · ${subQs.length} planned + ${useReflection ? "reflection" : "no reflection"} · ${sources.length} scraped sources${liveCitations.length > 0 ? ` · ${liveCitations.length} live` : ""}${liveSearch ? ` · 🛰 Live Search [${liveSearch.sources.join(",")}]` : ""}${freshMode ? ` · ⏱ fresh:${freshDays}d` : ""}`,
1409
+ vaultKey ? `📁 Saved to vault: \`${vaultKey}\`` : (saveToVault ? `⚠️ Vault save skipped (not authenticated - sign in with \`finch login\`)` : ""),
1410
+ continuationSection,
1411
+ linkedSection,
1412
+ ``,
1413
+ `<details><summary>📋 Process log</summary>`,
1414
+ ``,
1415
+ progress.map((p) => `- ${p}`).join("\n"),
1416
+ ``,
1417
+ `</details>`,
1418
+ ``,
1419
+ ].filter(Boolean).join("\n");
1420
+ return { content: [{ type: "text", text: `${header}\n${fullReport}` }] };
1421
+ }