@finchagentic/mcp 4.6.0 → 4.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -7
- package/package.json +5 -6
- package/dist/_http-cache.js +0 -96
- package/dist/agent-loop.js +0 -301
- package/dist/annotations.js +0 -122
- package/dist/cli.js +0 -1391
- package/dist/clink-input.js +0 -15
- package/dist/config.js +0 -132
- package/dist/convex.js +0 -175
- package/dist/dex-pair.js +0 -54
- package/dist/enrichment-router.js +0 -315
- package/dist/index.js +0 -258
- package/dist/llm.js +0 -298
- package/dist/local-memory-file.js +0 -147
- package/dist/local-memory.js +0 -135
- package/dist/local-vault.js +0 -454
- package/dist/output-schemas.js +0 -605
- package/dist/project.js +0 -36
- package/dist/prompts.js +0 -111
- package/dist/public-url.js +0 -107
- package/dist/resources.js +0 -111
- package/dist/server.js +0 -322
- package/dist/signal-gate.js +0 -57
- package/dist/token-decimals.js +0 -26
- package/dist/token-gate.js +0 -88
- package/dist/tool-filter.js +0 -53
- package/dist/tools/_solidity-scan.js +0 -313
- package/dist/tools/agents.js +0 -441
- package/dist/tools/automation.js +0 -354
- package/dist/tools/base-mcp.js +0 -466
- package/dist/tools/base.js +0 -283
- package/dist/tools/chronicle.js +0 -268
- package/dist/tools/coder.js +0 -94
- package/dist/tools/deep-research.js +0 -1421
- package/dist/tools/defi.js +0 -292
- package/dist/tools/equity.js +0 -372
- package/dist/tools/events.js +0 -182
- package/dist/tools/github.js +0 -564
- package/dist/tools/insider.js +0 -264
- package/dist/tools/insight.js +0 -630
- package/dist/tools/market.js +0 -555
- package/dist/tools/memory.js +0 -1044
- package/dist/tools/miroshark.js +0 -350
- package/dist/tools/monitor.js +0 -319
- package/dist/tools/os.js +0 -236
- package/dist/tools/packets.js +0 -296
- package/dist/tools/research-chain.js +0 -226
- package/dist/tools/research-compare.js +0 -280
- package/dist/tools/research.js +0 -188
- package/dist/tools/rh-bridge.js +0 -148
- package/dist/tools/rh-mcp.js +0 -1448
- package/dist/tools/rh-orders.js +0 -556
- package/dist/tools/scanner.js +0 -564
- package/dist/tools/stake.js +0 -369
- package/dist/tools/vault.js +0 -1020
- package/dist/tools/wallet.js +0 -200
- package/dist/types.js +0 -2
- package/dist/wallet.js +0 -372
|
@@ -1,1421 +0,0 @@
|
|
|
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
|
-
}
|