@finchagentic/mcp 4.7.1 → 4.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -39
- package/dist/_zod-helpers.js +10 -0
- package/dist/cli-doctor.js +300 -0
- package/dist/cli-env.js +91 -0
- package/dist/cli-install.js +236 -0
- package/dist/cli-login.js +101 -0
- package/dist/cli-orders.js +175 -0
- package/dist/cli-setup.js +270 -0
- package/dist/cli-ui.js +168 -0
- package/dist/cli-vault.js +75 -0
- package/dist/cli.js +61 -1152
- package/dist/config.js +3 -2
- package/dist/enrichment-router.js +5 -18
- package/dist/finch-output.js +30 -29
- package/dist/finch-status.js +105 -40
- package/dist/index.js +0 -0
- package/dist/local-vault.js +5 -12
- package/dist/output-schemas.js +3 -12
- package/dist/project.js +4 -13
- package/dist/server.js +13 -35
- package/dist/tool-filter.js +4 -13
- package/dist/tools/_solidity-scan.js +5 -14
- package/dist/tools/agents.js +14 -26
- package/dist/tools/deep-research-constants.js +33 -0
- package/dist/tools/deep-research-firecrawl.js +88 -0
- package/dist/tools/deep-research-planning.js +249 -0
- package/dist/tools/deep-research-synthesis.js +343 -0
- package/dist/tools/deep-research-text.js +158 -0
- package/dist/tools/deep-research-tools.js +90 -0
- package/dist/tools/deep-research.js +47 -938
- package/dist/tools/defi.js +4 -3
- package/dist/tools/insider.js +4 -14
- package/dist/tools/insight.js +7 -6
- package/dist/tools/market.js +16 -15
- package/dist/tools/memory.js +57 -62
- package/dist/tools/monitor.js +7 -6
- package/dist/tools/os.js +7 -85
- package/dist/tools/research.js +7 -6
- package/dist/tools/rh-mcp-constants.js +65 -0
- package/dist/tools/rh-mcp-dex.js +107 -0
- package/dist/tools/rh-mcp-provider.js +127 -0
- package/dist/tools/rh-mcp-resolve.js +137 -0
- package/dist/tools/rh-mcp-risk.js +230 -0
- package/dist/tools/rh-mcp-safety.js +234 -0
- package/dist/tools/rh-mcp-swap.js +181 -0
- package/dist/tools/rh-mcp-tools.js +137 -0
- package/dist/tools/rh-mcp.js +75 -1191
- package/dist/tools/scanner.js +10 -9
- package/dist/tools/stake.js +7 -20
- package/dist/tools/vault.js +52 -59
- package/package.json +12 -9
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Report synthesis: source classification, the main LLM synthesis call,
|
|
3
|
+
// citation-density retry, and the structural/citation quality checks that
|
|
4
|
+
// gate whether a report ships as-is or gets one retry.
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.classifySource = classifySource;
|
|
7
|
+
exports.synthesize = synthesize;
|
|
8
|
+
exports.extractLiveCitations = extractLiveCitations;
|
|
9
|
+
exports.measureCitationDensity = measureCitationDensity;
|
|
10
|
+
exports.validateReportStructure = validateReportStructure;
|
|
11
|
+
exports.formatLiveCitations = formatLiveCitations;
|
|
12
|
+
const llm_js_1 = require("../llm.js");
|
|
13
|
+
const convex_js_1 = require("../convex.js");
|
|
14
|
+
const enrichment_router_js_1 = require("../enrichment-router.js");
|
|
15
|
+
const memory_js_1 = require("./memory.js");
|
|
16
|
+
const deep_research_text_js_1 = require("./deep-research-text.js");
|
|
17
|
+
function classifySource(score) {
|
|
18
|
+
if (score >= 3)
|
|
19
|
+
return "primary";
|
|
20
|
+
if (score === 2)
|
|
21
|
+
return "expert";
|
|
22
|
+
if (score === 1)
|
|
23
|
+
return "secondary";
|
|
24
|
+
if (score < 0)
|
|
25
|
+
return "market";
|
|
26
|
+
return "unclassified";
|
|
27
|
+
}
|
|
28
|
+
async function synthesize(query, sources, isFinal, liveSearch, priorContext, freshMode, angles, criticNotes) {
|
|
29
|
+
const sourceBlocks = sources
|
|
30
|
+
.map((s) => {
|
|
31
|
+
const dateNote = s.publishedAt ? ` (published ${s.publishedAt.slice(0, 10)})` : "";
|
|
32
|
+
const classNote = s.class !== "unclassified" ? ` [${s.class}]` : "";
|
|
33
|
+
return `[${s.n}]${classNote} ${s.title} - ${s.domain}${dateNote}\nURL: ${s.url}\n\n${s.excerpt}`;
|
|
34
|
+
})
|
|
35
|
+
.join("\n\n---\n\n");
|
|
36
|
+
// Multi-domain enrichment - auto-detect topic (crypto/tech/academic/general)
|
|
37
|
+
// and pull live primary-source data in parallel. Crypto routes to DefiLlama
|
|
38
|
+
// + CoinGecko; tech to HackerNews + GitHub; academic to arXiv; general to
|
|
39
|
+
// Wikipedia. The combined block prepends to source blocks and is tagged
|
|
40
|
+
// AUTHORITATIVE LIVE DATA so the LLM treats those numbers as ground truth.
|
|
41
|
+
const enrichment = isFinal ? await (0, enrichment_router_js_1.enrichQuery)(query) : { context: "", hasData: false, domains: [] };
|
|
42
|
+
// Profile + memory auto-injection - only on final synthesis.
|
|
43
|
+
// Profile: persistent identity/business/state from vault.
|
|
44
|
+
// Memory: top relevant memories for this query (personalizes report framing).
|
|
45
|
+
// Both run in parallel; either can fail without blocking synthesis.
|
|
46
|
+
let profileContext = "";
|
|
47
|
+
let memoryContext = "";
|
|
48
|
+
if (isFinal) {
|
|
49
|
+
const [profileData, memHits] = await Promise.allSettled([
|
|
50
|
+
(0, convex_js_1.callConvex)("/vault/profile-context?maxChars=1200", "GET", undefined, "vault_read"),
|
|
51
|
+
(0, memory_js_1.searchSupermemory)(query, 4),
|
|
52
|
+
]);
|
|
53
|
+
if (profileData.status === "fulfilled") {
|
|
54
|
+
profileContext = (profileData.value?.context ?? "").trim();
|
|
55
|
+
}
|
|
56
|
+
if (memHits.status === "fulfilled" && memHits.value.length > 0) {
|
|
57
|
+
memoryContext = memHits.value
|
|
58
|
+
.map((r) => {
|
|
59
|
+
const title = r.metadata?.title ? `[${r.metadata.title}] ` : "";
|
|
60
|
+
return `- ${title}${r.content.slice(0, 200).replace(/\n/g, " ")}`;
|
|
61
|
+
})
|
|
62
|
+
.join("\n");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const liveSearchNote = liveSearch
|
|
66
|
+
? `\n\nIMPORTANT - Real-time augmentation:
|
|
67
|
+
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:
|
|
68
|
+
1. Verify recent claims (last ${liveSearch.fromDate ? "from " + liveSearch.fromDate : "few weeks"})
|
|
69
|
+
2. Add fresh data points the static sources may have missed
|
|
70
|
+
3. Surface X (Twitter) posts when the topic is moving quickly
|
|
71
|
+
4. Pull current numbers when the static sources are dated
|
|
72
|
+
|
|
73
|
+
CITATION RULE for Live Search content:
|
|
74
|
+
- Numbered sources [N] = static scraped sources at top of prompt
|
|
75
|
+
- 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
|
|
76
|
+
- If a Live Search result contradicts a static source, FLAG it as "(real-time conflicts with [N])"`
|
|
77
|
+
: "";
|
|
78
|
+
const finalSections = isFinal
|
|
79
|
+
? `## TL;DR
|
|
80
|
+
2-3 sentences answering the question directly. No hedging unless evidence demands it.
|
|
81
|
+
|
|
82
|
+
## At a Glance
|
|
83
|
+
A Markdown table summarizing 4-8 key metrics, dimensions, or status indicators from the sources. Format:
|
|
84
|
+
| Dimension | Value / Status | Source |
|
|
85
|
+
|---|---|---|
|
|
86
|
+
| (example) Production adoption | 57% have agents in production | [3] |
|
|
87
|
+
|
|
88
|
+
Use tables whenever you have:
|
|
89
|
+
- Comparison data (X vs Y vs Z)
|
|
90
|
+
- Status snapshots (multiple metrics at one point in time)
|
|
91
|
+
- Rankings or scorecards
|
|
92
|
+
- Dollar amounts, percentages, dates side-by-side
|
|
93
|
+
|
|
94
|
+
This section is REQUIRED whenever the sources contain quantitative data. Skip ONLY if the topic is purely qualitative.
|
|
95
|
+
|
|
96
|
+
## Key Findings
|
|
97
|
+
- 6-10 substantive bullets, each citing source numbers like [1] or [2,4]
|
|
98
|
+
- Lead with specific named entities, dollar amounts, percentages, dates - not generalities
|
|
99
|
+
- Mix angles - definition, current state, comparisons, criticisms
|
|
100
|
+
- Tag each bullet with a confidence level at the end: \`(high)\` / \`(medium)\` / \`(low)\`
|
|
101
|
+
- "high" means primary sources or strong consensus; "low" means single source or contested
|
|
102
|
+
|
|
103
|
+
## Analysis
|
|
104
|
+
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].
|
|
105
|
+
|
|
106
|
+
## Counterevidence & Limitations
|
|
107
|
+
- 2-4 bullets listing what could change the conclusion: weak sources, missing data, conflicting findings, age of evidence
|
|
108
|
+
- This section is required - never skip it
|
|
109
|
+
|
|
110
|
+
## Follow-up Questions
|
|
111
|
+
- 3-5 questions a curious reader would ask after reading this report
|
|
112
|
+
- Make them concrete and answerable, not philosophical`
|
|
113
|
+
: `## Draft Summary
|
|
114
|
+
Single paragraph synthesis covering the main findings from sources, with inline citations.`;
|
|
115
|
+
const profileBlock = profileContext
|
|
116
|
+
? `\n\n<user_profile>\n${profileContext}\n</user_profile>\n`
|
|
117
|
+
: "";
|
|
118
|
+
const memoryBlock = memoryContext
|
|
119
|
+
? `\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`
|
|
120
|
+
: "";
|
|
121
|
+
const sys = `You are a senior analyst writing a structured research report from numbered web sources.
|
|
122
|
+
|
|
123
|
+
${(0, enrichment_router_js_1.todayContext)()}${profileBlock}${memoryBlock}
|
|
124
|
+
|
|
125
|
+
OUTPUT FORMAT (strict - exact Markdown sections, in this order):
|
|
126
|
+
|
|
127
|
+
# {short concrete title - max 10 words}
|
|
128
|
+
|
|
129
|
+
${finalSections}
|
|
130
|
+
|
|
131
|
+
SOURCE CLASS TAGGING (mandatory in Key Findings):
|
|
132
|
+
Each source in the list above is labeled [primary] / [expert] / [secondary] / [market].
|
|
133
|
+
- [primary]: official docs, .gov/.edu, protocol data (DefiLlama, CoinGecko, etherscan), peer-reviewed
|
|
134
|
+
- [expert]: named researchers, audit reports, tier-1 financial press (Reuters, FT, Bloomberg)
|
|
135
|
+
- [secondary]: crypto media, newsletters, Wikipedia, GitHub
|
|
136
|
+
- [market]: X/Twitter, Reddit, Substack, prediction markets - sentiment, not fact
|
|
137
|
+
|
|
138
|
+
TAG RULES:
|
|
139
|
+
- Every Key Findings bullet must end with the source class of its strongest citation, e.g. "[primary]" or "[market]"
|
|
140
|
+
- Example: "Aerodrome TVL reached $2.1B in June 2026 [3] [primary]"
|
|
141
|
+
- If a bullet's evidence mixes classes, use the LOWEST class: one Reddit citation drags the whole bullet to "[market]"
|
|
142
|
+
|
|
143
|
+
CONTRADICTION RULES (mandatory):
|
|
144
|
+
- When two sources disagree on a fact, do NOT average them. Surface the conflict explicitly:
|
|
145
|
+
"Source [N] says X; source [M] says not-X - reconciliation: ..."
|
|
146
|
+
- Put irreconcilable contradictions in Counterevidence & Limitations, not Key Findings
|
|
147
|
+
- A single uncontested source is flagged: "(single source [N])"
|
|
148
|
+
|
|
149
|
+
CITATION DENSITY RULES:
|
|
150
|
+
- Every numerical claim (percentage, dollar amount, count, date) MUST carry [N]
|
|
151
|
+
- Every named entity (company, product, framework, person) MUST carry [N] on first mention
|
|
152
|
+
- Target: at least 1 citation per 50 words in Key Findings and Analysis
|
|
153
|
+
- Note source dates when relevant - older sources may be stale
|
|
154
|
+
|
|
155
|
+
STYLE RULES:
|
|
156
|
+
- Be specific: numbers, names, dates over vague claims
|
|
157
|
+
- Lead with concrete entities, not abstract concepts
|
|
158
|
+
- Tables > bullets when comparing dimensions
|
|
159
|
+
- No filler ("it is important to note", "in conclusion", "in today's world", "navigate the landscape")
|
|
160
|
+
- No hedging when evidence is strong; no false confidence when it's weak
|
|
161
|
+
- Don't write a Sources section - that gets appended automatically`;
|
|
162
|
+
const freshBlock = freshMode && isFinal
|
|
163
|
+
? `
|
|
164
|
+
|
|
165
|
+
⏱ FRESH MODE active - today is ${(0, deep_research_text_js_1.todayISO)()}:
|
|
166
|
+
- Prioritize claims dated within the last 30 days. If a source is older, only cite it if it's primary evidence (data, official statement).
|
|
167
|
+
- In the At a Glance table, include a "Date" column showing the publish date for each metric.
|
|
168
|
+
- In Key Findings, prefix each bullet with the source's publish date in brackets: [2026-MM-DD] Finding text [N].
|
|
169
|
+
- In Counterevidence, flag any claim whose evidence is older than 60 days as "(potentially stale).".`
|
|
170
|
+
: "";
|
|
171
|
+
const continuationBlock = priorContext && isFinal
|
|
172
|
+
? `
|
|
173
|
+
|
|
174
|
+
PRIOR REPORT (you are CONTINUING this research, not starting fresh):
|
|
175
|
+
|
|
176
|
+
\`\`\`
|
|
177
|
+
${priorContext.content.slice(0, 3500)}
|
|
178
|
+
\`\`\`
|
|
179
|
+
|
|
180
|
+
CONTINUATION RULES:
|
|
181
|
+
- Start the TL;DR with: "Update to prior report \`${priorContext.key}\` -" followed by the new takeaway.
|
|
182
|
+
- The "At a Glance" table must include a column "Δ since prior" showing what changed.
|
|
183
|
+
- 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.
|
|
184
|
+
- Counterevidence section must explicitly say which prior claims are now weaker.
|
|
185
|
+
- Follow-up Questions must build on the prior report's open questions if they're still relevant.
|
|
186
|
+
|
|
187
|
+
Do not re-explain background already in the prior report. Assume the reader read it.`
|
|
188
|
+
: "";
|
|
189
|
+
const enrichmentBlock = enrichment.hasData
|
|
190
|
+
? `\n\n${enrichment.context}\n\n`
|
|
191
|
+
: "";
|
|
192
|
+
// Specialist angles - when present, tell the synthesizer to mirror this
|
|
193
|
+
// structure in the report (Key Findings organized by angle, At a Glance
|
|
194
|
+
// table dimensions match the angles). Without this, the model blends
|
|
195
|
+
// every angle into a homogeneous narrative.
|
|
196
|
+
const anglesBlock = isFinal && angles && angles.length > 0
|
|
197
|
+
? `\n\nSPECIALIST ANGLES INVESTIGATED:
|
|
198
|
+
${angles.map((a, i) => `${i + 1}. **${a.label}** - ${a.rationale}`).join("\n")}
|
|
199
|
+
|
|
200
|
+
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.`
|
|
201
|
+
: "";
|
|
202
|
+
// Critic notes (deep mode only) - explicit weaknesses surfaced during
|
|
203
|
+
// the audit pass. The synthesizer must address each one rather than
|
|
204
|
+
// simply ignoring it. This is the strongest single quality lever.
|
|
205
|
+
const criticBlock = isFinal && criticNotes && criticNotes.length > 50
|
|
206
|
+
? `\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):
|
|
207
|
+
|
|
208
|
+
${criticNotes}
|
|
209
|
+
|
|
210
|
+
If a critic concern can't be resolved with the sources you have, surface it in Counterevidence & Limitations rather than burying it.`
|
|
211
|
+
: "";
|
|
212
|
+
const user = `RESEARCH QUESTION: ${query}
|
|
213
|
+
${enrichmentBlock}
|
|
214
|
+
SOURCES:
|
|
215
|
+
${sourceBlocks}${liveSearchNote}${continuationBlock}${freshBlock}${anglesBlock}${criticBlock}
|
|
216
|
+
|
|
217
|
+
Write the ${isFinal ? "final" : "draft"} report now. Markdown only - no preamble, no postamble.${enrichment.hasData
|
|
218
|
+
? `\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].`
|
|
219
|
+
: ""}`;
|
|
220
|
+
// Research synthesis uses FINCH_RESEARCH_MODEL when set. Default is
|
|
221
|
+
// `grok-4.3` - when Bankr is the active gateway this routes to Grok 4.3
|
|
222
|
+
// through Bankr (Grok handles fresh data better; Claude is the safer
|
|
223
|
+
// pick for reasoning, JSON, code). Override via env or pass the same
|
|
224
|
+
// model string to FINCH_MODEL to bypass.
|
|
225
|
+
const researchModel = process.env.FINCH_RESEARCH_MODEL ?? "grok-4.3";
|
|
226
|
+
// Deep mode (critic notes present, or the 5-angle planner ran) produces a
|
|
227
|
+
// longer report than the standard 6-section template - a fixed 4000-token
|
|
228
|
+
// budget was cutting deep reports off mid-sentence around risk #6-7 of 13+.
|
|
229
|
+
const isDeepMode = !!criticNotes || (angles?.length ?? 0) >= 5;
|
|
230
|
+
const finalTokens = isDeepMode ? 7000 : 4000;
|
|
231
|
+
const raw = await (0, llm_js_1.callLLM)(sys, user, isFinal ? finalTokens : 2000, [], 90000, { liveSearch, model: researchModel });
|
|
232
|
+
const { content: report, liveCitations } = extractLiveCitations(raw);
|
|
233
|
+
// Citation density check - only for final reports. If the report has many
|
|
234
|
+
// numerical claims but very few [N] citations, retry once with a stricter
|
|
235
|
+
// instruction. Cheap insurance against lazy synthesis.
|
|
236
|
+
if (!isFinal)
|
|
237
|
+
return { report, liveCitations };
|
|
238
|
+
const density = measureCitationDensity(report);
|
|
239
|
+
if (density.numericalClaims >= 5 && density.citations < Math.max(3, density.numericalClaims / 2)) {
|
|
240
|
+
const retryUser = `${user}
|
|
241
|
+
|
|
242
|
+
⚠️ 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.`;
|
|
243
|
+
try {
|
|
244
|
+
const rawRetry = await (0, llm_js_1.callLLM)(sys, retryUser, finalTokens, [], 90000, { liveSearch, model: researchModel });
|
|
245
|
+
const { content: retryReport, liveCitations: retryCitations } = extractLiveCitations(rawRetry);
|
|
246
|
+
return { report: retryReport, liveCitations: retryCitations.length > 0 ? retryCitations : liveCitations };
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
return { report, liveCitations };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return { report, liveCitations };
|
|
253
|
+
}
|
|
254
|
+
// Strip the GROK_LIVE_CITATIONS sentinel block (added by callGrok when Live
|
|
255
|
+
// Search ran) and return the citation URLs separately.
|
|
256
|
+
function extractLiveCitations(raw) {
|
|
257
|
+
const match = raw.match(/<!--GROK_LIVE_CITATIONS\n([\s\S]*?)\nGROK_LIVE_CITATIONS-->/);
|
|
258
|
+
if (!match)
|
|
259
|
+
return { content: raw, liveCitations: [] };
|
|
260
|
+
const urls = match[1].split("\n").map((u) => u.trim()).filter(Boolean);
|
|
261
|
+
return { content: raw.replace(match[0], "").trimEnd(), liveCitations: urls };
|
|
262
|
+
}
|
|
263
|
+
function measureCitationDensity(report) {
|
|
264
|
+
// Numerical claims: percentages, dollar amounts, large counts, years
|
|
265
|
+
const percentages = report.match(/\d+(?:\.\d+)?\s*%/g) ?? [];
|
|
266
|
+
const dollars = report.match(/\$\s*\d+(?:\.\d+)?\s*(?:[KkMmBbTt]|million|billion|trillion)?/g) ?? [];
|
|
267
|
+
const counts = report.match(/\b\d{1,3}(?:,\d{3})+\b/g) ?? [];
|
|
268
|
+
const years = report.match(/\b(?:19|20)\d{2}\b/g) ?? [];
|
|
269
|
+
const numericalClaims = percentages.length + dollars.length + counts.length + years.length;
|
|
270
|
+
// Inline citations
|
|
271
|
+
const citationsMatches = report.match(/\[\d+(?:\s*,\s*\d+)*\]/g) ?? [];
|
|
272
|
+
const citations = citationsMatches.length;
|
|
273
|
+
// Capitalized multi-word entities (proper nouns) - proxy for named entities
|
|
274
|
+
const namedEntities = (report.match(/\b[A-Z][a-z]+(?:[A-Z][a-z]+|\s+[A-Z][a-z]+)\b/g) ?? []).length;
|
|
275
|
+
return { numericalClaims, citations, namedEntities };
|
|
276
|
+
}
|
|
277
|
+
// Output structure validation - returns the names of any failed checks.
|
|
278
|
+
// Used to decide whether the synthesis output is worth retrying.
|
|
279
|
+
function validateReportStructure(report) {
|
|
280
|
+
const issues = [];
|
|
281
|
+
// 1. "At a Glance" section with a Markdown table
|
|
282
|
+
const atGlance = report.match(/##\s*At a Glance[\s\S]*?(?=\n##|\n#|$)/i);
|
|
283
|
+
if (!atGlance) {
|
|
284
|
+
issues.push("missing-at-a-glance");
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
// Markdown table = at least 2 lines that start with `|`
|
|
288
|
+
const tableLines = (atGlance[0].match(/^\|.+\|.+$/gm) ?? []).length;
|
|
289
|
+
if (tableLines < 2)
|
|
290
|
+
issues.push("at-a-glance-no-table");
|
|
291
|
+
}
|
|
292
|
+
// 2. Counterevidence section must exist and be non-trivial
|
|
293
|
+
const counter = report.match(/##\s*Counterevidence[\s\S]*?(?=\n##|\n#|$)/i);
|
|
294
|
+
if (!counter) {
|
|
295
|
+
issues.push("missing-counterevidence");
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
const counterText = counter[0].replace(/##.*$/m, "").trim();
|
|
299
|
+
if (counterText.length < 80)
|
|
300
|
+
issues.push("counterevidence-too-short");
|
|
301
|
+
}
|
|
302
|
+
// 3. Citation density - every 200 words should have at least 1 [N] citation
|
|
303
|
+
// in the Key Findings + Analysis sections
|
|
304
|
+
const findingsAndAnalysis = report
|
|
305
|
+
.replace(/^#.+$/m, "") // strip title
|
|
306
|
+
.replace(/##\s*(TL;DR|At a Glance|Sources|Follow-up Questions)[\s\S]*?(?=\n##|\n#|$)/gi, "")
|
|
307
|
+
.replace(/##\s*Counterevidence[\s\S]*?(?=\n##|\n#|$)/gi, "");
|
|
308
|
+
const wordCount = findingsAndAnalysis.split(/\s+/).filter(Boolean).length;
|
|
309
|
+
const citationCount = (findingsAndAnalysis.match(/\[\d+(?:\s*,\s*\d+)*\]/g) ?? []).length;
|
|
310
|
+
if (wordCount >= 200 && citationCount < Math.floor(wordCount / 200)) {
|
|
311
|
+
issues.push("low-citation-density");
|
|
312
|
+
}
|
|
313
|
+
// 4. Follow-up Questions section present
|
|
314
|
+
if (!report.match(/##\s*Follow-up Questions/i)) {
|
|
315
|
+
issues.push("missing-followups");
|
|
316
|
+
}
|
|
317
|
+
return issues;
|
|
318
|
+
}
|
|
319
|
+
// Group Live Search citations by source type (X, news, web) for readability.
|
|
320
|
+
function formatLiveCitations(urls) {
|
|
321
|
+
const groups = { "X (Twitter)": [], "News": [], "Web": [] };
|
|
322
|
+
for (const url of urls) {
|
|
323
|
+
if (/x\.com|twitter\.com/i.test(url))
|
|
324
|
+
groups["X (Twitter)"].push(url);
|
|
325
|
+
else if (/(reuters|apnews|bbc|cnbc|bloomberg|theverge|techcrunch|wsj|ft|nytimes|coindesk|axios|economist)\.com/i.test(url))
|
|
326
|
+
groups["News"].push(url);
|
|
327
|
+
else
|
|
328
|
+
groups["Web"].push(url);
|
|
329
|
+
}
|
|
330
|
+
const lines = [];
|
|
331
|
+
for (const [label, list] of Object.entries(groups)) {
|
|
332
|
+
if (list.length === 0)
|
|
333
|
+
continue;
|
|
334
|
+
lines.push(`**${label}** (${list.length}):`);
|
|
335
|
+
for (const url of list.slice(0, 8)) {
|
|
336
|
+
lines.push(`- ${url}`);
|
|
337
|
+
}
|
|
338
|
+
if (list.length > 8)
|
|
339
|
+
lines.push(`- _…and ${list.length - 8} more_`);
|
|
340
|
+
lines.push("");
|
|
341
|
+
}
|
|
342
|
+
return lines.join("\n").trimEnd();
|
|
343
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Text utilities: JSON extraction, domain scoring, excerpt selection, query
|
|
3
|
+
// terms, and source ranking/dedup - the parts of the pipeline with no LLM
|
|
4
|
+
// or network call.
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.safeParseJson = safeParseJson;
|
|
7
|
+
exports.domainOf = domainOf;
|
|
8
|
+
exports.tierBonus = tierBonus;
|
|
9
|
+
exports.chunkMarkdown = chunkMarkdown;
|
|
10
|
+
exports.chunkRelevance = chunkRelevance;
|
|
11
|
+
exports.pickBestExcerpt = pickBestExcerpt;
|
|
12
|
+
exports.extractQueryTerms = extractQueryTerms;
|
|
13
|
+
exports.todayISO = todayISO;
|
|
14
|
+
exports.currentYearMonth = currentYearMonth;
|
|
15
|
+
exports.buildSearchTermsForLinking = buildSearchTermsForLinking;
|
|
16
|
+
exports.extractVaultKeyFromHit = extractVaultKeyFromHit;
|
|
17
|
+
exports.rankAndDedupe = rankAndDedupe;
|
|
18
|
+
const deep_research_constants_js_1 = require("./deep-research-constants.js");
|
|
19
|
+
function safeParseJson(raw, fallback) {
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(raw);
|
|
22
|
+
}
|
|
23
|
+
catch { /* try the next parse strategy */ }
|
|
24
|
+
const stripped = raw.replace(/^```(?:json)?\n?/m, "").replace(/\n?```$/m, "").trim();
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(stripped);
|
|
27
|
+
}
|
|
28
|
+
catch { /* try the next parse strategy */ }
|
|
29
|
+
const arrMatch = stripped.match(/\[[\s\S]*\]/);
|
|
30
|
+
if (arrMatch) {
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(arrMatch[0]);
|
|
33
|
+
}
|
|
34
|
+
catch { /* try the next parse strategy */ }
|
|
35
|
+
}
|
|
36
|
+
const objMatch = stripped.match(/\{[\s\S]*\}/);
|
|
37
|
+
if (objMatch) {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(objMatch[0]);
|
|
40
|
+
}
|
|
41
|
+
catch { /* try the next parse strategy */ }
|
|
42
|
+
}
|
|
43
|
+
return fallback;
|
|
44
|
+
}
|
|
45
|
+
function domainOf(url) {
|
|
46
|
+
try {
|
|
47
|
+
return new URL(url).hostname.replace(/^www\./, "");
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return "unknown";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function tierBonus(url) {
|
|
54
|
+
for (const [re, bonus] of deep_research_constants_js_1.DOMAIN_TIER_BONUS)
|
|
55
|
+
if (re.test(url))
|
|
56
|
+
return bonus;
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
// Split markdown into ~600-char chunks at paragraph boundaries.
|
|
60
|
+
function chunkMarkdown(md, chunkSize = deep_research_constants_js_1.EXCERPT_CHARS_PER_CHUNK) {
|
|
61
|
+
const paragraphs = md.split(/\n{2,}/).map((p) => p.trim()).filter(Boolean);
|
|
62
|
+
const chunks = [];
|
|
63
|
+
let current = "";
|
|
64
|
+
for (const p of paragraphs) {
|
|
65
|
+
if (current.length + p.length + 2 <= chunkSize) {
|
|
66
|
+
current = current ? `${current}\n\n${p}` : p;
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
if (current)
|
|
70
|
+
chunks.push(current);
|
|
71
|
+
current = p.slice(0, chunkSize * 2); // very long single paragraph → cap
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (current)
|
|
75
|
+
chunks.push(current);
|
|
76
|
+
return chunks;
|
|
77
|
+
}
|
|
78
|
+
// Score a chunk against the query using term overlap. Cheap, no LLM call.
|
|
79
|
+
function chunkRelevance(chunk, queryTerms) {
|
|
80
|
+
const lower = chunk.toLowerCase();
|
|
81
|
+
let score = 0;
|
|
82
|
+
for (const term of queryTerms) {
|
|
83
|
+
const occurrences = lower.split(term).length - 1;
|
|
84
|
+
score += Math.min(occurrences, 5); // cap each term so a spammy page can't win
|
|
85
|
+
}
|
|
86
|
+
return score;
|
|
87
|
+
}
|
|
88
|
+
function pickBestExcerpt(md, queryTerms) {
|
|
89
|
+
const chunks = chunkMarkdown(md);
|
|
90
|
+
if (chunks.length === 0)
|
|
91
|
+
return md.slice(0, 1500);
|
|
92
|
+
const scored = chunks.map((c, i) => ({ c, i, score: chunkRelevance(c, queryTerms) }));
|
|
93
|
+
scored.sort((a, b) => b.score - a.score);
|
|
94
|
+
const top = scored.slice(0, deep_research_constants_js_1.EXCERPT_TOP_CHUNKS).sort((a, b) => a.i - b.i);
|
|
95
|
+
return top.map((t) => t.c).join("\n\n---\n\n");
|
|
96
|
+
}
|
|
97
|
+
function extractQueryTerms(query) {
|
|
98
|
+
return Array.from(new Set(query
|
|
99
|
+
.toLowerCase()
|
|
100
|
+
.replace(/[^a-z0-9\s]/g, " ")
|
|
101
|
+
.split(/\s+/)
|
|
102
|
+
.filter((t) => t.length >= 3 && !deep_research_constants_js_1.STOPWORDS.has(t)))).slice(0, 10);
|
|
103
|
+
}
|
|
104
|
+
function todayISO() {
|
|
105
|
+
return new Date().toISOString().slice(0, 10);
|
|
106
|
+
}
|
|
107
|
+
function currentYearMonth() {
|
|
108
|
+
const now = new Date();
|
|
109
|
+
const months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
|
110
|
+
return `${months[now.getMonth()]} ${now.getFullYear()}`;
|
|
111
|
+
}
|
|
112
|
+
// Build a search query for finding related vault entries. Prefer high-signal
|
|
113
|
+
// terms from the user query, dropping common research filler words.
|
|
114
|
+
function buildSearchTermsForLinking(query) {
|
|
115
|
+
const terms = extractQueryTerms(query);
|
|
116
|
+
return terms.length > 0 ? terms.join(" ") : query;
|
|
117
|
+
}
|
|
118
|
+
// Extract a vault key from a search hit. The /vault/search endpoint returns
|
|
119
|
+
// semantic memory documents whose metadata may or may not contain a vault key.
|
|
120
|
+
function extractVaultKeyFromHit(hit) {
|
|
121
|
+
// Heuristic 1: metadata.vaultKey or metadata.key
|
|
122
|
+
if (hit.metadata?.vaultKey && typeof hit.metadata.vaultKey === "string")
|
|
123
|
+
return hit.metadata.vaultKey;
|
|
124
|
+
if (hit.metadata?.key && typeof hit.metadata.key === "string")
|
|
125
|
+
return hit.metadata.key;
|
|
126
|
+
// Heuristic 2: content first line matches the vault key path pattern
|
|
127
|
+
const firstLine = (hit.content ?? "").split("\n", 1)[0];
|
|
128
|
+
const m = firstLine.match(/^(research|memory|workflow|prompt|execution|file|credential)\/[a-z0-9\-/]+/i);
|
|
129
|
+
if (m)
|
|
130
|
+
return m[0];
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
function rankAndDedupe(candidates, freshMode) {
|
|
134
|
+
// Score: search-rank inverse + domain tier bonus. Lower queryRank = higher.
|
|
135
|
+
// In fresh mode, give news domains an extra +2 boost so recent reporting
|
|
136
|
+
// ranks above evergreen content.
|
|
137
|
+
const scored = candidates.map((c) => ({
|
|
138
|
+
...c,
|
|
139
|
+
score: -c.queryRank + tierBonus(c.url) + (freshMode && deep_research_constants_js_1.NEWS_DOMAIN_BOOST_RE.test(c.url) ? 2 : 0),
|
|
140
|
+
domain: domainOf(c.url),
|
|
141
|
+
}));
|
|
142
|
+
scored.sort((a, b) => b.score - a.score);
|
|
143
|
+
// Domain diversity - cap MAX_PER_DOMAIN sources from same domain
|
|
144
|
+
const seenDomain = new Map();
|
|
145
|
+
const seenUrl = new Set();
|
|
146
|
+
const result = [];
|
|
147
|
+
for (const c of scored) {
|
|
148
|
+
if (seenUrl.has(c.url))
|
|
149
|
+
continue;
|
|
150
|
+
const count = seenDomain.get(c.domain) ?? 0;
|
|
151
|
+
if (count >= deep_research_constants_js_1.MAX_PER_DOMAIN)
|
|
152
|
+
continue;
|
|
153
|
+
seenUrl.add(c.url);
|
|
154
|
+
seenDomain.set(c.domain, count + 1);
|
|
155
|
+
result.push(c);
|
|
156
|
+
}
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Tool definition + input schema for deep_research. Pure data/validation,
|
|
3
|
+
// no execution logic.
|
|
4
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
|
+
exports.InputSchema = exports.DEEP_RESEARCH_TOOLS = void 0;
|
|
6
|
+
const zod_1 = require("zod");
|
|
7
|
+
exports.DEEP_RESEARCH_TOOLS = [
|
|
8
|
+
{
|
|
9
|
+
name: "deep_research",
|
|
10
|
+
description: "Web research engine: searches, scrapes, ranks and de-duplicates sources, then returns them " +
|
|
11
|
+
"as a numbered, citable evidence pack for YOU to synthesise. This is the default (mode='sources') " +
|
|
12
|
+
"and needs no API key. " +
|
|
13
|
+
"You are the analyst: pass your own sub-queries via `queries` for full control over the angles " +
|
|
14
|
+
"covered — otherwise they are derived from the topic. " +
|
|
15
|
+
"Set mode='report' only if you want the server to write the prose itself (requires an LLM key, " +
|
|
16
|
+
"and you cannot steer the result). " +
|
|
17
|
+
"Profile-aware, auto-saves to vault, auto-links to related past reports.",
|
|
18
|
+
inputSchema: {
|
|
19
|
+
type: "object",
|
|
20
|
+
properties: {
|
|
21
|
+
query: {
|
|
22
|
+
type: "string",
|
|
23
|
+
description: "Research question. Be specific: 'state of Base chain TVL Q2 2026' beats 'Base chain'.",
|
|
24
|
+
},
|
|
25
|
+
queries: {
|
|
26
|
+
type: "array",
|
|
27
|
+
items: { type: "string" },
|
|
28
|
+
description: "YOUR sub-queries to search (recommended). You know the topic and the user's intent, so plan " +
|
|
29
|
+
"the angles yourself — 3-6 specific queries beat a generic decomposition. Omit to derive them.",
|
|
30
|
+
},
|
|
31
|
+
mode: {
|
|
32
|
+
type: "string",
|
|
33
|
+
enum: ["sources", "report"],
|
|
34
|
+
description: "'sources' (default) returns the ranked evidence pack for you to synthesise — no API key needed. " +
|
|
35
|
+
"'report' makes the server write the prose (needs an LLM key; you cannot steer it).",
|
|
36
|
+
},
|
|
37
|
+
depth: {
|
|
38
|
+
type: "string",
|
|
39
|
+
enum: ["fast", "standard", "deep"],
|
|
40
|
+
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.",
|
|
41
|
+
},
|
|
42
|
+
focus: {
|
|
43
|
+
type: "string",
|
|
44
|
+
description: "Optional angle hint - 'technical', 'investment', 'news', 'comparison'. Steers planning.",
|
|
45
|
+
},
|
|
46
|
+
continueFrom: {
|
|
47
|
+
type: "string",
|
|
48
|
+
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.",
|
|
49
|
+
},
|
|
50
|
+
freshMode: {
|
|
51
|
+
type: "boolean",
|
|
52
|
+
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.).",
|
|
53
|
+
},
|
|
54
|
+
freshDays: {
|
|
55
|
+
type: "number",
|
|
56
|
+
description: "When freshMode is on, restrict to results from the last N days. Default 14 days. Capped at 90.",
|
|
57
|
+
},
|
|
58
|
+
liveSearch: {
|
|
59
|
+
type: "boolean",
|
|
60
|
+
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).",
|
|
61
|
+
},
|
|
62
|
+
liveSearchSources: {
|
|
63
|
+
type: "array",
|
|
64
|
+
items: { type: "string", enum: ["web", "x", "news", "rss"] },
|
|
65
|
+
description: "Which Live Search sources to pull from. Default: ['web', 'x', 'news']. Only respected when liveSearch is true and Grok is active.",
|
|
66
|
+
},
|
|
67
|
+
liveSearchDays: {
|
|
68
|
+
type: "number",
|
|
69
|
+
description: "Restrict Live Search to results from the last N days (max 365). Useful for time-sensitive queries. Default: no date filter.",
|
|
70
|
+
},
|
|
71
|
+
saveToVault: { type: "boolean", description: "Auto-save report to vault (default true)" },
|
|
72
|
+
},
|
|
73
|
+
required: ["query"],
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
exports.InputSchema = zod_1.z.object({
|
|
78
|
+
query: zod_1.z.string().min(3).max(500),
|
|
79
|
+
queries: zod_1.z.array(zod_1.z.string().min(3).max(300)).max(12).optional(),
|
|
80
|
+
mode: zod_1.z.enum(["sources", "report"]).optional(),
|
|
81
|
+
depth: zod_1.z.enum(["fast", "standard", "deep"]).optional(),
|
|
82
|
+
focus: zod_1.z.string().max(80).optional(),
|
|
83
|
+
continueFrom: zod_1.z.string().max(200).optional(),
|
|
84
|
+
freshMode: zod_1.z.boolean().optional(),
|
|
85
|
+
freshDays: zod_1.z.number().int().min(1).max(90).optional(),
|
|
86
|
+
liveSearch: zod_1.z.boolean().optional(),
|
|
87
|
+
liveSearchSources: zod_1.z.array(zod_1.z.enum(["web", "x", "news", "rss"])).optional(),
|
|
88
|
+
liveSearchDays: zod_1.z.number().int().min(1).max(365).optional(),
|
|
89
|
+
saveToVault: zod_1.z.boolean().optional(),
|
|
90
|
+
});
|