improved validity verification, ui responsiveness

This commit is contained in:
2026-05-31 21:11:44 -04:00
parent 2af8968e71
commit b6c2b10eb5
9 changed files with 1265 additions and 355 deletions

1
.gitignore vendored
View File

@@ -2,3 +2,4 @@ node_modules/
dist/ dist/
.pi-lens/ .pi-lens/
AGENTS.md AGENTS.md
package-lock.json

View File

@@ -10,6 +10,7 @@ pi install npm:@mikefreno/deep-research
- **Multi-round iteration**: Each round generates follow-up queries based on previous findings (depth 1-3) - **Multi-round iteration**: Each round generates follow-up queries based on previous findings (depth 1-3)
- **Parallel query expansion**: Multiple diverse search queries per round (breadth 1-5) covering technical, practical, comparative, critical, and forward-looking angles - **Parallel query expansion**: Multiple diverse search queries per round (breadth 1-5) covering technical, practical, comparative, critical, and forward-looking angles
- **Round-robin parallel execution**: Searches and analyses run concurrently within each round using bounded-concurrency worker pools, dramatically reducing total research time
- **LLM-driven analysis**: Each round's results are analyzed by an agent session to extract structured findings with confidence ratings - **LLM-driven analysis**: Each round's results are analyzed by an agent session to extract structured findings with confidence ratings
- **Automatic deduplication**: Search results are deduplicated by URL across all queries - **Automatic deduplication**: Search results are deduplicated by URL across all queries
- **Graceful degradation**: Individual search or analysis failures don't crash the full research — partial results are preserved - **Graceful degradation**: Individual search or analysis failures don't crash the full research — partial results are preserved
@@ -40,7 +41,7 @@ Parameters:
### Command (interactive) ### Command (interactive)
``` ```
/deep-research <your research question> /deepi-research <your research question>
``` ```
Prompts for depth (1-3 rounds) and breadth (1-5 queries) interactively, then runs the research and sends the final report as a user message. Prompts for depth (1-3 rounds) and breadth (1-5 queries) interactively, then runs the research and sends the final report as a user message.

282
index.ts
View File

@@ -3,7 +3,7 @@
* *
* Registers: * Registers:
* - `deep_research` tool — callable by the LLM to conduct deep research * - `deep_research` tool — callable by the LLM to conduct deep research
* - `/deep-research` command — interactive session invocation * - `/deepi-research` command — interactive session invocation
* *
* Architecture: * Architecture:
* Each research round generates queries, searches in parallel via * Each research round generates queries, searches in parallel via
@@ -24,12 +24,13 @@ import { Type } from "typebox";
import { Box, Text } from "@earendil-works/pi-tui"; import { Box, Text } from "@earendil-works/pi-tui";
import { runDeepResearch, type ResearchProgress } from "./src/research"; import { runDeepResearch, type ResearchProgress } from "./src/research";
import { isFirecrawlReachable } from "./src/firecrawl"; import { isFirecrawlReachable } from "./src/firecrawl";
import type { ResearchConfig, ResearchReport } from "./src/types"; import type { ResearchConfig, ResearchReport, Audience } from "./src/types";
/* ── Constants ────────────────────────────────────────────────────── */ /* ── Constants ────────────────────────────────────────────────────── */
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const PHASE_ICONS: Record<string, string> = { const PHASE_ICONS: Record<string, string> = {
decomposing: "🧩",
generating_queries: "🔍", generating_queries: "🔍",
searching: "🌐", searching: "🌐",
analyzing: "📊", analyzing: "📊",
@@ -37,6 +38,8 @@ const PHASE_ICONS: Record<string, string> = {
complete: "✅", complete: "✅",
}; };
type ResearchPhase = Parameters<ResearchProgress>[0]["phase"];
/* ── Helpers ──────────────────────────────────────────────────────── */ /* ── Helpers ──────────────────────────────────────────────────────── */
function formatDuration(ms: number): string { function formatDuration(ms: number): string {
@@ -60,7 +63,7 @@ const DeepResearchParams = Type.Object({
depth: Type.Optional( depth: Type.Optional(
Type.Integer({ Type.Integer({
description: description:
"Number of research rounds (1-3). Each round uses findings from the previous to generate deeper follow-up queries. Default: 2", "Number of research rounds (1-3). Each round builds on findings from the previous for deeper analysis. Default: 2",
minimum: 1, minimum: 1,
maximum: 3, maximum: 3,
default: 2, default: 2,
@@ -69,7 +72,7 @@ const DeepResearchParams = Type.Object({
breadth: Type.Optional( breadth: Type.Optional(
Type.Integer({ Type.Integer({
description: description:
"Number of search queries per round (1-5). More queries = broader coverage. Default: 3", "Number of search queries per round (1-5). More queries = broader coverage but slower. Default: 3",
minimum: 1, minimum: 1,
maximum: 5, maximum: 5,
default: 3, default: 3,
@@ -78,16 +81,30 @@ const DeepResearchParams = Type.Object({
format: Type.Optional( format: Type.Optional(
Type.Union([Type.Literal("markdown"), Type.Literal("structured")], { Type.Union([Type.Literal("markdown"), Type.Literal("structured")], {
description: description:
'Output format for the research report. "markdown" for prose, "structured" for detailed sections. Default: "markdown"', 'Output format for the research report. "markdown" for prose with headings, "structured" for detailed hierarchical sections. Default: "markdown"',
default: "markdown", default: "markdown",
}), }),
), ),
audience: Type.Optional(
Type.Union(
[
Type.Literal("general"),
Type.Literal("expert"),
Type.Literal("executive"),
],
{
description:
"Target audience for the report. 'general' (accessible), 'expert' (technical depth), 'executive' (concise, action-oriented). Default: 'general'",
default: "general",
},
),
),
details: Type.Optional( details: Type.Optional(
Type.Object({ Type.Object({
showRoundDetails: Type.Optional( showRoundDetails: Type.Optional(
Type.Boolean({ Type.Boolean({
description: description:
"Include per-round search details in the output. Default: false", "Include per-round search methodology in the output. Default: false",
}), }),
), ),
}), }),
@@ -106,6 +123,88 @@ interface ResearchDetails {
durationMs: number; durationMs: number;
} }
/* ── Widget Helper ────────────────────────────────────────────────── */
/**
* Create a widget state that drives a spinner-based progress widget.
* Returns the state object, the timer, and cleanup function.
*/
function createProgressWidget(
ctx: any,
initialPhase: ResearchPhase = "generating_queries",
) {
const state: {
phase: ResearchPhase;
message: string;
detail: string | undefined;
fraction: number;
round: number | undefined;
totalRounds: number | undefined;
} = {
phase: initialPhase,
message: "Starting...",
detail: undefined,
fraction: 0,
round: undefined,
totalRounds: undefined,
};
let widgetTui: { requestRender(): void } | null = null;
let spinnerIdx = 0;
ctx.ui.setWidget(
"deep-research",
(tui: { requestRender(): void }, _theme: any) => {
widgetTui = tui;
return {
render: () => {
const spinner = SPINNER_FRAMES[spinnerIdx];
const icon = PHASE_ICONS[state.phase] ?? "";
const roundInfo =
state.round && state.totalRounds
? ` Round ${state.round}/${state.totalRounds}`
: "";
const lines: string[] = [
`${spinner} ${icon} ${truncate(state.message, 80)}${roundInfo}`,
];
if (state.detail) {
lines.push(` ${truncate(state.detail, 76)}`);
}
if (state.fraction > 0) {
const barLen = 15;
const filled = Math.round(barLen * state.fraction);
const bar = "█".repeat(filled) + "░".repeat(barLen - filled);
lines.push(` ${bar}`);
}
return lines;
},
invalidate: () => {},
};
},
);
const spinnerTimer = setInterval(() => {
spinnerIdx = (spinnerIdx + 1) % SPINNER_FRAMES.length;
widgetTui?.requestRender();
}, 100);
const onProgress: ResearchProgress = (update) => {
state.phase = update.phase;
state.message = update.message;
state.detail = update.detail;
state.fraction = update.fraction ?? 0;
state.round = update.round;
state.totalRounds = update.totalRounds;
};
const cleanup = () => {
clearInterval(spinnerTimer);
ctx.ui.setWidget("deep-research", undefined);
};
return { state, onProgress, cleanup, spinnerTimer };
}
/* ── Extension Entry ───────────────────────────────────────────────── */ /* ── Extension Entry ───────────────────────────────────────────────── */
export default function (pi: ExtensionAPI) { export default function (pi: ExtensionAPI) {
@@ -114,16 +213,18 @@ export default function (pi: ExtensionAPI) {
label: "Deep Research", label: "Deep Research",
description: [ description: [
"Conduct multi-round deep web research on any topic using Firecrawl.", "Conduct multi-round deep web research on any topic using Firecrawl.",
"Generates diverse search queries, searches the web in parallel, analyzes results, and produces a comprehensive report.", "Generates diverse search queries, searches the web in parallel, analyzes results,",
"Supports iterative refinement: each round builds on findings from the previous one.", "and produces a comprehensive report with numbered citations and a bibliography.",
"Parameters: question (required), depth (1-3, default 2), breadth (1-5, default 3), format (markdown|structured).", "Supports iterative refinement and sub-question decomposition for deeper analysis.",
"Parameters: question (required), depth, breadth, format, audience, details.",
].join(" "), ].join(" "),
promptSnippet: promptSnippet:
"deep_research — multi-round deep web research via Firecrawl with iterative query refinement", "deep_research — multi-round deep web research via Firecrawl with iterative query refinement, sub-question decomposition, source authority scoring, and numbered citations",
promptGuidelines: [ promptGuidelines: [
"Use deep_research for complex, multi-faceted questions that benefit from multiple search angles and iterative refinement.", "Use deep_research for complex, multi-faceted questions that benefit from multiple search angles and iterative refinement.",
"The tool handles query generation, web search, result analysis, and report synthesis automatically.", "The tool handles query generation, web search, result analysis, and report synthesis automatically.",
"For simple fact-finding questions, use firecrawl_search directly instead.", "For simple fact-finding questions, use firecrawl_search directly instead.",
"Set audience to 'executive' for concise, action-oriented reports; 'expert' for technical depth; 'general' (default) for accessible reports.",
], ],
parameters: DeepResearchParams, parameters: DeepResearchParams,
@@ -134,6 +235,7 @@ export default function (pi: ExtensionAPI) {
depth?: number; depth?: number;
breadth?: number; breadth?: number;
format?: "markdown" | "structured"; format?: "markdown" | "structured";
audience?: Audience;
details?: { showRoundDetails?: boolean }; details?: { showRoundDetails?: boolean };
}, },
signal: AbortSignal | undefined, signal: AbortSignal | undefined,
@@ -145,60 +247,17 @@ export default function (pi: ExtensionAPI) {
depth: params.depth ?? 2, depth: params.depth ?? 2,
breadth: params.breadth ?? 3, breadth: params.breadth ?? 3,
format: params.format ?? "markdown", format: params.format ?? "markdown",
audience: params.audience ?? "general",
}; };
// Use provided signals
const abortSignal = signal; const abortSignal = signal;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
// Wire progress updates to both the widget and onUpdate const { state: _state, onProgress, cleanup } = createProgressWidget(ctx);
let spinnerIdx = 0;
const spinnerTimer = setInterval(() => {
spinnerIdx = (spinnerIdx + 1) % SPINNER_FRAMES.length;
}, 100);
let researchResult: ResearchReport | null = null; let researchResult: ResearchReport | null = null;
let lastError: string | null = null; let lastError: string | null = null;
const onProgress: ResearchProgress = (update) => {
const icon = PHASE_ICONS[update.phase] ?? "";
const spinner = SPINNER_FRAMES[spinnerIdx];
const roundInfo =
update.round && update.totalRounds
? ` Round ${update.round}/${update.totalRounds}`
: "";
// Update widget
const lines: string[] = [
`${spinner} ${icon} ${truncate(update.message, 80)}${roundInfo}`,
];
if (update.detail) {
lines.push(` ${truncate(update.detail, 76)}`);
}
if (update.fraction !== undefined) {
const barLen = 15;
const filled = Math.round(barLen * update.fraction);
const bar = "█".repeat(filled) + "░".repeat(barLen - filled);
lines.push(` ${bar}`);
}
ctx.ui.setWidget("deep-research", lines);
// Stream partial results via onUpdate
if (onUpdate) {
const partialText = lines.join("\n");
onUpdate({
content: [{ type: "text", text: partialText }],
details: {
phase: update.phase,
round: update.round,
message: update.message,
fraction: update.fraction,
},
});
}
};
try { try {
// Initial status
ctx.ui.setStatus( ctx.ui.setStatus(
"deep-research", "deep-research",
`🌐 Researching: ${truncate(config.question, 40)}`, `🌐 Researching: ${truncate(config.question, 40)}`,
@@ -231,10 +290,30 @@ export default function (pi: ExtensionAPI) {
durationMs: researchResult.durationMs, durationMs: researchResult.durationMs,
}; };
const showRoundDetails = params.details?.showRoundDetails ?? false; // Stream final content via onUpdate before returning
if (onUpdate) {
onUpdate({
content: [{ type: "text", text: researchResult.finalReport }],
details: {
phase: "complete",
duration: researchResult.durationMs,
rounds: researchResult.rounds.length,
findings: researchResult.rounds.reduce(
(s, r) => s + r.findings.length,
0,
),
references: researchResult.references.length,
},
});
}
cleanup();
ctx.ui.setStatus("deep-research", undefined);
let output = researchResult.finalReport; let output = researchResult.finalReport;
if (showRoundDetails) {
// Append methodology section if requested
if (params.details?.showRoundDetails) {
output += `\n\n---\n\n## Research Methodology\n\n`; output += `\n\n---\n\n## Research Methodology\n\n`;
for (const round of researchResult.rounds) { for (const round of researchResult.rounds) {
output += `### Round ${round.round}\n\n`; output += `### Round ${round.round}\n\n`;
@@ -247,21 +326,16 @@ export default function (pi: ExtensionAPI) {
} }
output += `**Total searches:** ${researchResult.totalSearches}\n`; output += `**Total searches:** ${researchResult.totalSearches}\n`;
output += `**Total pages scraped:** ${researchResult.totalPagesScraped}\n`; output += `**Total pages scraped:** ${researchResult.totalPagesScraped}\n`;
output += `**Sources in bibliography:** ${researchResult.references.length}\n`;
output += `**Duration:** ${formatDuration(researchResult.durationMs)}\n`; output += `**Duration:** ${formatDuration(researchResult.durationMs)}\n`;
} }
// Clean up widget
clearInterval(spinnerTimer);
ctx.ui.setWidget("deep-research", undefined);
ctx.ui.setStatus("deep-research", undefined);
return { return {
content: [{ type: "text", text: output }], content: [{ type: "text", text: output }],
details, details,
}; };
} catch (error) { } catch (error) {
clearInterval(spinnerTimer); cleanup();
ctx.ui.setWidget("deep-research", undefined);
ctx.ui.setStatus("deep-research", undefined); ctx.ui.setStatus("deep-research", undefined);
lastError = error instanceof Error ? error.message : String(error); lastError = error instanceof Error ? error.message : String(error);
@@ -274,11 +348,12 @@ export default function (pi: ExtensionAPI) {
}, },
], ],
details: { details: {
rounds: [],
totalSearches: 0,
totalPagesScraped: 0,
durationMs: 0,
error: lastError, error: lastError,
phase: researchResult } as ResearchDetails & { error: string },
? `completed ${researchResult.rounds.length} rounds`
: "preparation",
},
isError: true, isError: true,
}; };
} }
@@ -292,6 +367,7 @@ export default function (pi: ExtensionAPI) {
depth?: number; depth?: number;
breadth?: number; breadth?: number;
format?: string; format?: string;
audience?: string;
}, },
theme: any, theme: any,
_context: any, _context: any,
@@ -300,11 +376,15 @@ export default function (pi: ExtensionAPI) {
const depth = args.depth ?? 2; const depth = args.depth ?? 2;
const breadth = args.breadth ?? 3; const breadth = args.breadth ?? 3;
const format = args.format ?? "markdown"; const format = args.format ?? "markdown";
const audience = args.audience ?? "general";
const text = const text =
theme.fg("toolTitle", theme.bold("deep_research ")) + theme.fg("toolTitle", theme.bold("deep_research ")) +
theme.fg("accent", `"${question}"`) + theme.fg("accent", `"${question}"`) +
theme.fg("muted", ` [depth:${depth} breadth:${breadth} ${format}]`); theme.fg(
"muted",
` [depth:${depth} breadth:${breadth} ${format} ${audience}]`,
);
return new Text(text, 0, 0); return new Text(text, 0, 0);
}, },
@@ -407,19 +487,19 @@ export default function (pi: ExtensionAPI) {
// ── Command ─────────────────────────────────────────────────────── // ── Command ───────────────────────────────────────────────────────
pi.registerCommand("deep-research", { pi.registerCommand("deepi-research", {
description: description:
"Conduct multi-round deep web research on any topic via Firecrawl. Usage: /deep-research <question>", "Conduct multi-round deep web research on any topic via Firecrawl. Usage: /deepi-research <question>",
handler: async (args: string, ctx: ExtensionCommandContext) => { handler: async (args: string, ctx: ExtensionCommandContext) => {
if (!args || args.trim().length === 0) { if (!args || args.trim().length === 0) {
ctx.ui.notify( ctx.ui.notify(
"Usage: /deep-research <your research question>", "Usage: /deepi-research <your research question>",
"error", "error",
); );
return; return;
} }
// Ask about depth/breadth // Ask about depth
const depthStr = await ctx.ui.select("Research depth?", [ const depthStr = await ctx.ui.select("Research depth?", [
"1 round (quick survey)", "1 round (quick survey)",
"2 rounds (standard)", "2 rounds (standard)",
@@ -431,6 +511,7 @@ export default function (pi: ExtensionAPI) {
? 3 ? 3
: 2; : 2;
// Ask about breadth
const breadthStr = await ctx.ui.select("Research breadth?", [ const breadthStr = await ctx.ui.select("Research breadth?", [
"1 query/round (narrow)", "1 query/round (narrow)",
"3 queries/round (balanced)", "3 queries/round (balanced)",
@@ -442,7 +523,18 @@ export default function (pi: ExtensionAPI) {
? 5 ? 5
: 3; : 3;
// Create a promise-based interaction // Ask about audience
const audienceStr = await ctx.ui.select("Report audience?", [
"General (accessible, explains terms)",
"Expert (technical depth, assumes domain knowledge)",
"Executive (concise, action-oriented)",
]);
const audience: Audience = audienceStr?.startsWith("Expert")
? "expert"
: audienceStr?.startsWith("Executive")
? "executive"
: "general";
ctx.ui.setStatus( ctx.ui.setStatus(
"deep-research", "deep-research",
`🌐 Researching: ${truncate(args, 40)}`, `🌐 Researching: ${truncate(args, 40)}`,
@@ -453,56 +545,32 @@ export default function (pi: ExtensionAPI) {
depth, depth,
breadth, breadth,
format: "markdown", format: "markdown",
audience,
}; };
let spinnerIdx = 0; const { onProgress, cleanup } = createProgressWidget(ctx);
const spinnerTimer = setInterval(() => {
spinnerIdx = (spinnerIdx + 1) % SPINNER_FRAMES.length;
}, 100);
try { try {
const onProgress: ResearchProgress = (update) => {
const icon = PHASE_ICONS[update.phase] ?? "";
const spinner = SPINNER_FRAMES[spinnerIdx];
const lines: string[] = [
`${spinner} ${icon} ${truncate(update.message, 80)}`,
];
if (update.detail) {
lines.push(` ${truncate(update.detail, 76)}`);
}
if (update.fraction !== undefined) {
const barLen = 15;
const filled = Math.round(barLen * update.fraction);
const bar = "█".repeat(filled) + "░".repeat(barLen - filled);
lines.push(` ${bar}`);
}
ctx.ui.setWidget("deep-research", lines);
};
const report = await runDeepResearch(config, ctx, onProgress); const report = await runDeepResearch(config, ctx, onProgress);
clearInterval(spinnerTimer); cleanup();
ctx.ui.setWidget("deep-research", undefined);
ctx.ui.setStatus("deep-research", undefined); ctx.ui.setStatus("deep-research", undefined);
// Show notification // Show notification
ctx.ui.notify( ctx.ui.notify(
`Research complete: ${report.rounds.length} rounds, ${report.totalSearches} searches, ${report.totalPagesScraped} pages in ${formatDuration(report.durationMs)}`, `Research complete: ${report.rounds.length} rounds, ${report.totalSearches} searches, ${report.totalPagesScraped} pages, ${report.references.length} sources in ${formatDuration(report.durationMs)}`,
"info", "info",
); );
// Send the report as a user message // Send the report as a user message
pi.sendUserMessage( pi.sendUserMessage(
`## Deep Research: ${args}\n\n${report.finalReport}\n\n---\n*${report.rounds.length} rounds · ${report.totalSearches} searches · ${report.totalPagesScraped} pages · ${formatDuration(report.durationMs)}*`, `## Deep Research: ${args}\n\n${report.finalReport}\n\n---\n*${report.rounds.length} rounds · ${report.totalSearches} searches · ${report.totalPagesScraped} pages · ${report.references.length} sources · ${formatDuration(report.durationMs)}*`,
); );
} catch (error) { } catch (error) {
clearInterval(spinnerTimer); cleanup();
ctx.ui.setWidget("deep-research", undefined);
ctx.ui.setStatus("deep-research", undefined); ctx.ui.setStatus("deep-research", undefined);
ctx.ui.notify( const msg = error instanceof Error ? error.message : String(error);
`Research failed: error instanceof Error ? error.message : String(error)`, ctx.ui.notify(`Research failed: ${msg}`, "error");
"error",
);
} }
}, },
}); });

47
package-lock.json generated
View File

@@ -1,47 +0,0 @@
{
"name": "deep-research",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "deep-research",
"version": "1.0.0",
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.3.0"
}
},
"node_modules/@types/node": {
"version": "20.19.41",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz",
"integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}

View File

@@ -7,7 +7,7 @@
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as path from "node:path"; import * as path from "node:path";
import * as os from "node:os"; import * as os from "node:os";
import type { SearchResult } from "./types"; import type { SearchResult, EnrichedSearchResult, ContentType } from "./types";
/* ── Config ──────────────────────────────────────────────────────── */ /* ── Config ──────────────────────────────────────────────────────── */
@@ -36,6 +36,159 @@ function loadFirecrawlConfig() {
const { baseUrl: BASE_URL, apiKey: API_KEY } = loadFirecrawlConfig(); const { baseUrl: BASE_URL, apiKey: API_KEY } = loadFirecrawlConfig();
/* ── Domain Authority Heuristics ─────────────────────────────────── */
/**
* Known high-authority domains and their authority scores (0.0 1.0).
* Academic, official, and established technical sources score highest.
*/
const AUTHORITY_DOMAINS: Record<string, number> = {
// Academic & scholarly
"arxiv.org": 0.95,
"scholar.google.com": 0.95,
"pubmed.ncbi.nlm.nih.gov": 0.95,
"semanticscholar.org": 0.9,
"ieee.org": 0.95,
"acm.org": 0.95,
"springer.com": 0.9,
"sciencedirect.com": 0.9,
"wiley.com": 0.85,
"nature.com": 0.95,
"science.org": 0.95,
"plos.org": 0.85,
// Official documentation
"docs.python.org": 0.9,
"developer.mozilla.org": 0.9,
"learn.microsoft.com": 0.85,
"developer.apple.com": 0.85,
"kubernetes.io": 0.85,
"react.dev": 0.85,
"nextjs.org": 0.8,
// Government & non-profits
".gov": 0.9,
".edu": 0.85,
"who.int": 0.9,
"worldbank.org": 0.85,
"oecd.org": 0.85,
// Established tech & news
"github.com": 0.8,
"stackoverflow.com": 0.7,
"medium.com": 0.4,
"dev.to": 0.5,
"wikipedia.org": 0.7,
"reuters.com": 0.8,
"apnews.com": 0.8,
"bbc.com": 0.75,
"nytimes.com": 0.75,
"theguardian.com": 0.7,
"techcrunch.com": 0.6,
"arstechnica.com": 0.65,
"wired.com": 0.65,
"infoworld.com": 0.55,
};
/** Content-type hints based on domain patterns */
const CONTENT_TYPE_HINTS: [RegExp, ContentType][] = [
[
/arxiv\.org|semanticscholar|ieee\.org|acm\.org|springer|sciencedirect|pubmed\.ncbi/,
"paper",
],
[
/docs\.|learn\.|developer\.|kubernetes\.io|react\.dev|nextjs\.org/,
"documentation",
],
[/wikipedia\.org|stackoverflow\.com|medium\.com|dev\.to/, "forum"],
[
/reuters\.com|apnews\.com|bbc\.com|nytimes\.com|techcrunch|arstechnica|wired/,
"news",
],
[/\.gov|\.edu|who\.int|worldbank|oecd\.org/, "official"],
[/github\.com/, "documentation"],
];
/* ── Source enrichment helpers ───────────────────────────────────── */
/**
* Extract the registered domain from a URL (e.g., "blog.example.com" → "example.com").
* Uses a simple 2-part TLD heuristic. For common cases like .co.uk this is approximate.
*/
function extractDomain(url: string): string {
try {
const hostname = new URL(url).hostname.toLowerCase();
// Special-case common multi-part TLDs
const multiPartTlds =
/\.(co\.uk|org\.uk|ac\.uk|gov\.uk|com\.au|co\.jp|co\.kr|com\.br)$/;
const parts = hostname.split(".");
if (multiPartTlds.test(hostname) && parts.length >= 3) {
return parts.slice(-3).join(".");
}
return parts.slice(-2).join(".");
} catch {
return url.replace(/^https?:\/\//, "").split("/")[0] ?? url;
}
}
function computeAuthorityScore(domain: string): number {
// Direct match first
if (AUTHORITY_DOMAINS[domain]) return AUTHORITY_DOMAINS[domain];
// Suffix matches (.gov, .edu, etc.)
for (const [key, score] of Object.entries(AUTHORITY_DOMAINS)) {
if (key.startsWith(".") && domain.endsWith(key)) return score;
}
// Subdomain matches (e.g., blog.example.com matches example.com)
const parent = domain.split(".").slice(-2).join(".");
if (parent !== domain && AUTHORITY_DOMAINS[parent]) {
return AUTHORITY_DOMAINS[parent] * 0.9;
}
return 0.3; // Unknown / low-authority default
}
function detectContentType(url: string, description: string): ContentType {
const lowerUrl = url.toLowerCase();
const lowerDesc = description.toLowerCase();
for (const [pattern, type] of CONTENT_TYPE_HINTS) {
if (pattern.test(lowerUrl)) return type;
}
// Heuristics from description text
if (/paper|research|study|experiment|analysis\b/.test(lowerDesc))
return "paper";
if (/documentation|guide|tutorial|api|reference/.test(lowerDesc))
return "documentation";
if (/blog|post|article|opinion/.test(lowerDesc)) return "blog";
if (/news|report|announce|release/.test(lowerDesc)) return "news";
if (/forum|discussion|question|answer|thread/.test(lowerDesc)) return "forum";
return "other";
}
function tryParseDate(dateStr: string | undefined | null): Date | null {
if (!dateStr) return null;
const d = new Date(dateStr);
return isNaN(d.getTime()) ? null : d;
}
/**
* Enrich a raw search result with source authority metadata.
* Accepts extra fields (e.g. date) from the Firecrawl API response.
*/
export function enrichResult(
result: SearchResult & Record<string, unknown>,
): EnrichedSearchResult {
const domain = extractDomain(result.url);
return {
...result,
domain,
authorityScore: computeAuthorityScore(domain),
publishedDate: tryParseDate(result.date as string | undefined),
contentType: detectContentType(result.url, result.description),
};
}
/* ── Helpers ──────────────────────────────────────────────────────── */ /* ── Helpers ──────────────────────────────────────────────────────── */
async function firecrawlRequest( async function firecrawlRequest(
@@ -87,14 +240,14 @@ export async function isFirecrawlReachable(): Promise<boolean> {
/* ── Search ───────────────────────────────────────────────────────── */ /* ── Search ───────────────────────────────────────────────────────── */
/** /**
* Search the web and return structured results. * Search the web and return structured, enriched results.
* Uses Firecrawl's search endpoint with scrape to get full page content. * Uses Firecrawl's search endpoint with scrape to get full page content.
*/ */
export async function searchWeb( export async function searchWeb(
query: string, query: string,
limit: number = 5, limit: number = 5,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<SearchResult[]> { ): Promise<EnrichedSearchResult[]> {
const body: Record<string, unknown> = { const body: Record<string, unknown> = {
query, query,
limit: Math.min(limit, 10), limit: Math.min(limit, 10),
@@ -116,14 +269,19 @@ export async function searchWeb(
if (!res.success || !res.data) return []; if (!res.success || !res.data) return [];
return res.data const rawResults: (SearchResult & Record<string, unknown>)[] = res.data
.map((doc) => ({ .map((doc) => ({
title: (doc.title as string) ?? "", title: (doc.title as string) ?? "",
url: (doc.url as string) ?? "", url: (doc.url as string) ?? "",
description: (doc.description as string) ?? "", description: (doc.description as string) ?? "",
markdown: (doc.markdown as string) ?? "", markdown: (doc.markdown as string) ?? "",
// Preserve extra fields for date extraction
...doc,
})) }))
.filter((r) => r.markdown || r.description); .filter((r) => r.markdown || r.description);
// Enrich each result with source metadata
return rawResults.map(enrichResult);
} }
/* ── Scrape ───────────────────────────────────────────────────────── */ /* ── Scrape ───────────────────────────────────────────────────────── */

View File

@@ -4,13 +4,36 @@
* Uses an LLM agent to generate search queries from different research * Uses an LLM agent to generate search queries from different research
* angles, then analyzes results to produce follow-up queries. * angles, then analyzes results to produce follow-up queries.
*/ */
import type { SearchQuery, Finding, ResearchRound } from "./types"; import type {
SearchQuery,
Finding,
ResearchRound,
EnrichedSearchResult,
} from "./types";
import { runAnalysisAgent } from "./agent"; import { runAnalysisAgent } from "./agent";
/* ── System Prompts ──────────────────────────────────────────────── */
const DECOMPOSE_SYSTEM = `You are a research methodology expert. Given a broad research question, your job is to break it down into 4-7 focused sub-questions that, when answered, collectively provide a complete answer to the original question.
Guidelines:
- Each sub-question should tackle ONE specific facet of the research question
- Cover different dimensions: what, how, why, who, comparison, evidence, implications
- Sub-questions should be independently researchable via web search
- Avoid overlap between sub-questions
- Prioritize questions that will surface concrete evidence over speculative ones
Output ONLY a JSON array of sub-question strings.
Example:
Input: "What are the benefits and risks of artificial intelligence in healthcare?"
Output: ["What specific AI technologies are currently deployed in clinical healthcare settings?", "What peer-reviewed evidence exists for AI improving diagnostic accuracy?", "What are the documented risks and failure cases of AI in healthcare?", "How do regulatory frameworks (FDA, EMA) address AI-based medical devices?", "What do healthcare practitioners report as barriers to AI adoption?"]
`;
const GENERATE_QUERIES_SYSTEM = `You are a research methodology expert. Your role is to generate effective web search queries that will yield high-quality, diverse information about a research topic. const GENERATE_QUERIES_SYSTEM = `You are a research methodology expert. Your role is to generate effective web search queries that will yield high-quality, diverse information about a research topic.
Guidelines: Guidelines:
- Create queries from DIFFERENT angles (technical, practical, comparative, critical, forward-looking) - Create queries from DIFFERENT angles (technical, practical, comparative, critical, forward-looking, authoritative)
- Each query should target a specific facet of the question - Each query should target a specific facet of the question
- Queries should use keywords that search engines rank well (avoid overly long questions) - Queries should use keywords that search engines rank well (avoid overly long questions)
- Cover contrasting viewpoints and alternative approaches - Cover contrasting viewpoints and alternative approaches
@@ -20,7 +43,7 @@ Guidelines:
Output ONLY a JSON array of objects with fields: Output ONLY a JSON array of objects with fields:
- "query": the search query string - "query": the search query string
- "rationale": why this query will help answer the research question - "rationale": why this query will help answer the research question
- "angle": one of "technical" | "practical" | "comparative" | "critical" | "forward-looking" | "authoritative" - "angle": one of "technical" | "practical" | "comparative" | "critical" | "forward-looking" | "authoritative" | "historical" | "case-study" | "data-statistics" | "ethical"
Example: Example:
[ [
@@ -29,7 +52,7 @@ Example:
] ]
`; `;
const FOLLOWUP_SYSTEM = `You are a research analyst. Given the research question and findings so far, your job is to identify what's still unknown and generate follow-up search queries to fill those gaps. const FOLLOWUP_SYSTEM = `You are a research analyst. Given the research question, sub-questions, and findings so far, your job is to identify what's still unknown and generate follow-up search queries to fill those gaps.
Look for: Look for:
- Claims made without sufficient evidence - Claims made without sufficient evidence
@@ -42,18 +65,105 @@ Look for:
Output ONLY a JSON array of objects with fields: Output ONLY a JSON array of objects with fields:
- "query": the search query string - "query": the search query string
- "rationale": what gap this query fills or what angle it explores - "rationale": what gap this query fills or what angle it explores
- "angle": one of "technical" | "practical" | "comparative" | "critical" | "forward-looking" | "authoritative" - "angle": one of "technical" | "practical" | "comparative" | "critical" | "forward-looking" | "authoritative" | "historical" | "case-study" | "data-statistics" | "ethical"
`; `;
/* ── Sub-Question Decomposition ───────────────────────────────────── */
/**
* Decompose a broad research question into focused, independently
* researchable sub-questions. Returns the sub-questions or an empty
* array if the LLM call fails.
*/
export async function decomposeQuestion(
question: string,
cwd: string,
signal?: AbortSignal,
): Promise<string[]> {
const taskPrompt = `Break down this research question into 4-7 focused sub-questions:\n\n${question}`;
const result = await runAnalysisAgent(
DECOMPOSE_SYSTEM,
taskPrompt,
cwd,
60_000,
undefined,
signal,
);
if (!result.success || !result.text) return [];
try {
const parsed = JSON.parse(result.text);
if (Array.isArray(parsed) && parsed.length > 0) {
return parsed.map(String).filter((s: string) => s.length > 10);
}
} catch {
// parse failed
}
return [];
}
/* ── Query Generation ────────────────────────────────────────────── */
/** /**
* Generate initial search queries for a research question. * Generate initial search queries for a research question.
* When sub-questions are available, generates queries per sub-question
* for better depth and diversity.
*/ */
export async function generateQueries( export async function generateQueries(
question: string, question: string,
count: number, count: number,
cwd: string, cwd: string,
signal?: AbortSignal, signal?: AbortSignal,
subQuestions?: string[],
): Promise<SearchQuery[]> { ): Promise<SearchQuery[]> {
// If we have sub-questions, generate queries distributed across them
if (subQuestions && subQuestions.length > 0) {
const queriesPerSub = Math.max(1, Math.ceil(count / subQuestions.length));
const allQueries: SearchQuery[] = [];
for (const subQ of subQuestions) {
if (allQueries.length >= count) break;
const taskPrompt = `Research question: ${question}\nSub-question: ${subQ}\n\nGenerate ${queriesPerSub} search query(ies) to answer this sub-question specifically.`;
const result = await runAnalysisAgent(
GENERATE_QUERIES_SYSTEM,
taskPrompt,
cwd,
60_000,
undefined,
signal,
);
if (!result.success || !result.text) continue;
try {
const parsed = JSON.parse(result.text);
if (Array.isArray(parsed)) {
const queries = parsed
.slice(0, queriesPerSub)
.map((q: Record<string, unknown>) => ({
query: String(q.query ?? ""),
rationale: String(q.rationale ?? ""),
angle: String(q.angle ?? "technical"),
}))
.filter((q: { query: string }) => q.query.length > 0);
allQueries.push(...queries);
}
} catch {
// parse failed for this sub-question, continue
}
}
if (allQueries.length > 0) {
return allQueries.slice(0, count);
}
}
// Fall through to standard query generation
const taskPrompt = `Research question: ${question} const taskPrompt = `Research question: ${question}
Generate ${count} diverse search queries to research this topic effectively. Cover different angles.`; Generate ${count} diverse search queries to research this topic effectively. Cover different angles.`;
@@ -81,7 +191,7 @@ Generate ${count} diverse search queries to research this topic effectively. Cov
rationale: String(q.rationale ?? ""), rationale: String(q.rationale ?? ""),
angle: String(q.angle ?? "technical"), angle: String(q.angle ?? "technical"),
})) }))
.filter((q) => q.query.length > 0); .filter((q: { query: string }) => q.query.length > 0);
} }
} catch { } catch {
// JSON parse failed, fall back // JSON parse failed, fall back
@@ -90,6 +200,8 @@ Generate ${count} diverse search queries to research this topic effectively. Cov
return generateFallbackQueries(question, count); return generateFallbackQueries(question, count);
} }
/* ── Follow-up Query Generation ──────────────────────────────────── */
/** /**
* Generate follow-up queries based on findings from previous rounds. * Generate follow-up queries based on findings from previous rounds.
*/ */
@@ -103,7 +215,13 @@ export async function generateFollowUpQueries(
// Build a summary of findings so far // Build a summary of findings so far
const allFindings = rounds.flatMap((r) => r.findings); const allFindings = rounds.flatMap((r) => r.findings);
const findingsSummary = allFindings const findingsSummary = allFindings
.map((f) => `- ${f.title}: ${f.summary} (confidence: ${f.confidence})`) .map((f) => {
const corr =
f.corroborationScore !== undefined
? ` [corroboration: ${(f.corroborationScore * 100).toFixed(0)}%]`
: "";
return `- ${f.title}: ${f.summary} (confidence: ${f.confidence}${corr})`;
})
.join("\n"); .join("\n");
const exploredAngles = rounds const exploredAngles = rounds
@@ -111,6 +229,12 @@ export async function generateFollowUpQueries(
.map((q) => `[${q.angle}] ${q.query}${q.rationale}`) .map((q) => `[${q.angle}] ${q.query}${q.rationale}`)
.join("\n"); .join("\n");
// Find low-corroboration or low-confidence topics
const gaps = allFindings
.filter((f) => f.confidence === "low" || (f.corroborationScore ?? 1) < 0.5)
.map((f) => `Gap: ${f.title}${f.summary}`)
.join("\n");
const taskPrompt = `Research question: ${question} const taskPrompt = `Research question: ${question}
Queries already explored: Queries already explored:
@@ -119,6 +243,8 @@ ${exploredAngles}
Findings so far: Findings so far:
${findingsSummary} ${findingsSummary}
${gaps ? `Remaining knowledge gaps:\n${gaps}` : ""}
Generate ${count} follow-up search queries to fill remaining gaps and deepen the research.`; Generate ${count} follow-up search queries to fill remaining gaps and deepen the research.`;
const result = await runAnalysisAgent( const result = await runAnalysisAgent(
@@ -144,7 +270,7 @@ Generate ${count} follow-up search queries to fill remaining gaps and deepen the
rationale: String(q.rationale ?? ""), rationale: String(q.rationale ?? ""),
angle: String(q.angle ?? "technical"), angle: String(q.angle ?? "technical"),
})) }))
.filter((q) => q.query.length > 0); .filter((q: { query: string }) => q.query.length > 0);
} }
} catch { } catch {
// parse failed // parse failed
@@ -153,6 +279,8 @@ Generate ${count} follow-up search queries to fill remaining gaps and deepen the
return []; return [];
} }
/* ── Fallback Query Generation ────────────────────────────────────── */
/** /**
* Fallback query generation when the LLM call fails. * Fallback query generation when the LLM call fails.
*/ */
@@ -183,6 +311,8 @@ function generateFallbackQueries(
return queries; return queries;
} }
/* ── Analysis ────────────────────────────────────────────────────── */
const ANALYZE_SYSTEM = `You are a research analyst. Given search results for a specific query, extract key findings. const ANALYZE_SYSTEM = `You are a research analyst. Given search results for a specific query, extract key findings.
For each finding: For each finding:
@@ -204,19 +334,15 @@ Output ONLY a JSON array of objects with fields:
*/ */
export async function analyzeResults( export async function analyzeResults(
query: string, query: string,
results: { results: EnrichedSearchResult[],
title: string;
url: string;
description: string;
markdown: string;
}[],
cwd: string, cwd: string,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<Finding[]> { ): Promise<Finding[]> {
// Include authority metadata in the prompt so the LLM can consider source quality
const resultsText = results const resultsText = results
.map( .map(
(r, i) => (r, i) =>
`--- Result ${i + 1} ---\nTitle: ${r.title}\nURL: ${r.url}\nDescription: ${r.description}\nContent:\n${r.markdown.slice(0, 3000)}`, `--- Result ${i + 1} ---\nTitle: ${r.title}\nURL: ${r.url}\nDomain: ${r.domain}\nAuthority Score: ${(r.authorityScore * 100).toFixed(0)}%\nContent Type: ${r.contentType}\nDescription: ${r.description}\nContent:\n${r.markdown.slice(0, 3000)}`,
) )
.join("\n\n"); .join("\n\n");
@@ -225,7 +351,7 @@ export async function analyzeResults(
Search results: Search results:
${resultsText} ${resultsText}
Extract key findings from these results.`; Extract key findings from these results. Consider source authority when rating confidence.`;
const result = await runAnalysisAgent( const result = await runAnalysisAgent(
ANALYZE_SYSTEM, ANALYZE_SYSTEM,
@@ -251,7 +377,9 @@ Extract key findings from these results.`;
? String(f.confidence) ? String(f.confidence)
: "medium") as Finding["confidence"], : "medium") as Finding["confidence"],
})) }))
.filter((f) => f.title && f.summary); .filter(
(f: { title: string; summary: string }) => f.title && f.summary,
);
} }
} catch { } catch {
// parse failed // parse failed
@@ -259,3 +387,119 @@ Extract key findings from these results.`;
return []; return [];
} }
/* ── Corroboration Tracking ──────────────────────────────────────── */
/**
* Cross-reference all findings to compute corroboration scores.
*
* For each finding, we check:
* 1. How many other findings reference the same or similar source URLs
* 2. The authority scores of the supporting sources
* 3. Whether independent domains support the same claim
*
* Returns the findings with added corroborationScore, bestSourceAuthority,
* and avgSourceAuthority.
*/
export function computeCorroboration(findings: Finding[]): Finding[] {
if (findings.length === 0) return [];
// Collect all unique source URLs and their authority scores
// In a real implementation, we'd map URLs to EnrichedSearchResult authority scores
// For now, extract domain-level patterns
// Build a map of domain -> authority scores from source URLs
const domainAuthority = new Map<string, number>();
for (const finding of findings) {
for (const url of finding.sources) {
try {
const domain = extractDomainSimple(url);
if (!domainAuthority.has(domain)) {
domainAuthority.set(domain, heuristicDomainScore(domain));
}
} catch {
// skip invalid URLs
}
}
}
return findings.map((finding) => {
if (finding.sources.length === 0) {
return {
...finding,
corroborationScore: 0,
bestSourceAuthority: 0,
avgSourceAuthority: 0,
};
}
// Compute source authority stats
const authorities: number[] = finding.sources.map((url) => {
try {
const domain = extractDomainSimple(url);
return domainAuthority.get(domain) ?? 0.3;
} catch {
return 0.3;
}
});
const bestAuthority = Math.max(...authorities);
const avgAuthority =
authorities.reduce((a, b) => a + b, 0) / authorities.length;
// Compute corroboration: how many other findings share source URLs
let corroboratingFindings = 0;
const mySources = new Set(finding.sources);
for (const other of findings) {
if (other === finding) continue;
const overlap = other.sources.some((url) => mySources.has(url));
if (overlap) corroboratingFindings++;
}
// Normalize corroboration: 0-1 based on what fraction of other findings agree
const maxCorroboration = findings.length - 1;
const corroborationScore =
maxCorroboration > 0
? Math.min(1, corroboratingFindings / maxCorroboration)
: 0;
return {
...finding,
corroborationScore: Math.round(corroborationScore * 100) / 100,
bestSourceAuthority: Math.round(bestAuthority * 100) / 100,
avgSourceAuthority: Math.round(avgAuthority * 100) / 100,
};
});
}
/**
* Simple domain extraction (avoids URL constructor for compatibility).
*/
function extractDomainSimple(url: string): string {
const match = url.match(/https?:\/\/([^/]+)/);
if (!match) return url;
const hostname = match[1].toLowerCase();
const parts = hostname.split(".");
const multiPartTlds =
/\.(co\.uk|org\.uk|ac\.uk|gov\.uk|com\.au|co\.jp|co\.kr|com\.br)$/;
if (multiPartTlds.test(hostname) && parts.length >= 3) {
return parts.slice(-3).join(".");
}
return parts.slice(-2).join(".");
}
/**
* Very basic domain score heuristic without the full domain list.
*/
function heuristicDomainScore(domain: string): number {
if (/\.gov$|\.edu$/.test(domain)) return 0.85;
if (/arxiv|scholar|pubmed|ieee|acm|springer|nature|science/.test(domain))
return 0.9;
if (/github|gitlab|bitbucket/.test(domain)) return 0.75;
if (/wikipedia|stackoverflow|medium|dev\.to/.test(domain)) return 0.55;
if (/docs\.|learn\.|developer\./.test(domain)) return 0.8;
if (/reuters|apnews|bbc|nytimes|bloomberg/.test(domain)) return 0.75;
if (/blog|forum|reddit/.test(domain)) return 0.3;
return 0.4;
}

View File

@@ -2,31 +2,163 @@
* Deep Research — Report synthesis * Deep Research — Report synthesis
* *
* Takes all research rounds and synthesizes a comprehensive report * Takes all research rounds and synthesizes a comprehensive report
* using an LLM agent. * using an LLM agent. Produces:
* - Numbered inline citations with a bibliography
* - Layered report: TL;DR → Executive Summary → Key Findings
* → Detailed Analysis → Limitations/Gaps → References
* - Audience-aware tone adjustment
*/ */
import type { ResearchRound, ResearchConfig } from "./types"; import type {
ResearchRound,
ResearchConfig,
Reference,
Finding,
} from "./types";
import { runAnalysisAgent } from "./agent"; import { runAnalysisAgent } from "./agent";
const SYNTHESIS_SYSTEM = `You are a senior research analyst synthesizing findings from multiple web searches into a comprehensive, well-structured report. /** Return shape from synthesizeReport */
export interface SynthesisResult {
report: string;
references: Reference[];
}
Your report should: /* ── System Prompts ──────────────────────────────────────────────── */
1. Start with an executive summary (2-3 paragraphs covering the key answer to the research question)
2. Organize findings by theme, not by search query function buildSynthesisSystem(audience: string): string {
3. Include specific evidence from sources (cite URLs in [brackets]) const audienceGuidance: Record<string, string> = {
4. Note areas of disagreement or uncertainty expert:
5. Identify knowledge gaps that remain "Assume expert-level domain knowledge. Use precise technical terminology, reference specific methodologies and standards, and prioritize depth over hand-holding. The reader understands the field.",
6. End with actionable conclusions general:
"Write for an informed general audience. Define technical terms on first use, explain context, and keep the tone accessible but not simplistic. Avoid jargon without explanation.",
executive:
"Write for a busy executive or decision-maker. Lead with actionable conclusions and recommendations. Be concise — use bold for key takeaways. Minimize technical detail; focus on implications, trade-offs, and decisions. Target 2-3 pages.",
};
const guidance = audienceGuidance[audience] ?? audienceGuidance.general;
return `You are a senior research analyst synthesizing findings from multiple web searches into a comprehensive, well-structured report.
Audience: ${guidance}
Report structure (use ## headings):
1. **TL;DR** — One paragraph (2-3 sentences) giving the single most important answer
2. **Executive Summary** — 2-3 paragraphs covering what was found, how confident we are, and key implications
3. **Key Findings** — Tiered by importance/confidence. Bullet points with inline citations
4. **Detailed Analysis** — Organized by theme. Each section covers one aspect with evidence
5. **Limitations & Knowledge Gaps** — What evidence is weak, missing, or contradictory
6. **Conclusion** — Wrap up with actionable takeaways
Citation rules:
- Use numbered references like [1], [2] etc. throughout the text
- At the end, include a ## References section listing each citation
- Format references as: [1] Title — Domain (URL)
- Cite specific evidence, not vague associations
- When multiple sources support a claim, cite all of them: [1][3][5]
Style guidelines: Style guidelines:
- Use clear section headings (## level)
- Write in an objective, authoritative tone - Write in an objective, authoritative tone
- Include bullet points for listing evidence - Use bullet points for listing evidence
- Use inline citations like [source](url)
- Note the confidence level for key claims - Note the confidence level for key claims
- Be thorough but concise — every paragraph should add value`; - Be thorough but concise — every paragraph should add value
- Use > for notable direct quotes with citations`;
}
/* ── Evidence Builder ────────────────────────────────────────────── */
function buildEvidenceText(
question: string,
rounds: ResearchRound[],
): { evidenceText: string; referenceMap: Map<string, Reference> } {
const allFindings = rounds.flatMap((r) => r.findings);
const totalSearches = rounds.reduce((sum, r) => sum + r.queries.length, 0);
const totalPages = rounds.reduce((sum, r) => sum + r.results.length, 0);
// Build a bibliography map (url -> Reference)
const seenUrls = new Map<string, Reference>();
let refId = 0;
for (const round of rounds) {
for (const result of round.results) {
if (!seenUrls.has(result.url)) {
refId++;
seenUrls.set(result.url, {
id: refId,
url: result.url,
title: result.title,
domain: result.domain,
authorityScore: result.authorityScore,
accessedAt: new Date().toISOString().split("T")[0],
});
}
}
}
// Organize findings by thematic angle
const evidenceByAngle = new Map<string, Finding[]>();
for (const round of rounds) {
for (const finding of round.findings) {
const angle = round.queries[0]?.angle ?? "technical";
if (!evidenceByAngle.has(angle)) evidenceByAngle.set(angle, []);
evidenceByAngle.get(angle)!.push(finding);
}
}
let evidenceText = `## Research Question\n${question}\n\n`;
evidenceText += `## Overview\n- Rounds of research: ${rounds.length}\n`;
evidenceText += `- Total searches executed: ${totalSearches}\n`;
evidenceText += `- Total pages analyzed: ${totalPages}\n`;
evidenceText += `- Key findings extracted: ${allFindings.length}\n\n`;
// Build evidence grouped by angle with reference IDs
for (const [angle, findings] of Array.from(evidenceByAngle)) {
if (findings.length === 0) continue;
evidenceText += `## Angle: ${angle}\n\n`;
for (const finding of findings) {
// Get reference IDs for this finding's sources
const refs = finding.sources
.map((url) => seenUrls.get(url))
.filter((r): r is Reference => !!r)
.map((r) => `[${r.id}]`);
const avgAuth =
finding.avgSourceAuthority !== undefined
? ` | Avg Authority: ${(finding.avgSourceAuthority * 100).toFixed(0)}%`
: "";
const corr =
finding.corroborationScore !== undefined
? ` | Corroboration: ${(finding.corroborationScore * 100).toFixed(0)}%`
: "";
const bestAuthStr =
finding.bestSourceAuthority !== undefined
? ` | Best Source: ${(finding.bestSourceAuthority * 100).toFixed(0)}%`
: "";
evidenceText += `### ${finding.title}\n`;
evidenceText += `**Confidence:** ${finding.confidence}${avgAuth}${corr}${bestAuthStr}\n`;
if (refs.length > 0) {
evidenceText += `**Sources:** ${refs.join(", ")}\n`;
}
evidenceText += `${finding.summary}\n\n`;
if (finding.keyQuotes.length > 0) {
evidenceText += `> ${finding.keyQuotes[0]}\n\n`;
}
}
}
// Include reference metadata for the LLM to build proper citations
evidenceText += `## Reference Metadata\n\n`;
for (const [, ref] of seenUrls) {
evidenceText += `[${ref.id}] ${ref.title} (${ref.domain}, authority: ${(ref.authorityScore * 100).toFixed(0)}%) — ${ref.url}\n`;
}
return { evidenceText, referenceMap: seenUrls };
}
/* ── Main Synthesis ──────────────────────────────────────────────── */
/** /**
* Synthesize a research report from all rounds. * Synthesize a research report from all rounds.
* Returns both the formatted report and the full bibliography.
*/ */
export async function synthesizeReport( export async function synthesizeReport(
question: string, question: string,
@@ -34,58 +166,14 @@ export async function synthesizeReport(
config: ResearchConfig, config: ResearchConfig,
cwd: string, cwd: string,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<string> { ): Promise<SynthesisResult> {
// Build the evidence summary const audience = config.audience ?? "general";
const allFindings = rounds.flatMap((r) => r.findings); const { evidenceText, referenceMap } = buildEvidenceText(question, rounds);
const totalSearches = rounds.reduce((sum, r) => sum + r.queries.length, 0);
const totalPages = rounds.reduce((sum, r) => sum + r.results.length, 0);
const evidenceByAngle = new Map<string, ResearchRound["findings"]>(); const formatInstruction =
for (const round of rounds) { config.format === "structured"
for (const query of round.queries) { ? "Structured report with numbered sections, clear hierarchies, and data tables where appropriate."
const key = query.angle; : "Well-formatted markdown report with ## headings, bullet points, and inline numbered citations like [1].";
if (!evidenceByAngle.has(key)) evidenceByAngle.set(key, []);
}
for (const finding of round.findings) {
// Try to determine angle from the round's queries
const angle = round.queries[0]?.angle ?? "technical";
if (!evidenceByAngle.has(angle)) evidenceByAngle.set(angle, []);
evidenceByAngle.get(angle)!.push(finding);
}
}
// Build structured evidence text
let evidenceText = `## Research Question\n${question}\n\n`;
evidenceText += `## Overview\n- Rounds of research: ${rounds.length}\n`;
evidenceText += `- Total searches executed: ${totalSearches}\n`;
evidenceText += `- Total pages analyzed: ${totalPages}\n`;
evidenceText += `- Key findings extracted: ${allFindings.length}\n\n`;
for (const [angle, findings] of Array.from(evidenceByAngle)) {
if (findings.length === 0) continue;
evidenceText += `## Angle: ${angle}\n\n`;
for (const finding of findings) {
evidenceText += `### ${finding.title}\n`;
evidenceText += `**Confidence:** ${finding.confidence}\n`;
evidenceText += `${finding.summary}\n\n`;
if (finding.keyQuotes.length > 0) {
evidenceText += `> ${finding.keyQuotes[0]}\n\n`;
}
if (finding.sources.length > 0) {
evidenceText += `Sources: ${finding.sources.map((s: string) => `[${s}](${s})`).join(", ")}\n\n`;
}
}
}
// Also include raw search context for depth
evidenceText += `## Raw Search Context\n\n`;
for (const round of rounds) {
evidenceText += `### Round ${round.round}\n`;
for (const q of round.queries) {
evidenceText += `- **"${q.query}"** (${q.angle}) — ${q.rationale}\n`;
}
evidenceText += `\n`;
}
const taskPrompt = `Synthesize the following research findings into a comprehensive, well-structured report. const taskPrompt = `Synthesize the following research findings into a comprehensive, well-structured report.
@@ -93,10 +181,13 @@ ${evidenceText}
Write a thorough report that answers the original question: "${question}" Write a thorough report that answers the original question: "${question}"
Format: ${config.format === "structured" ? "Structured report with numbered sections, clear hierarchies, and data tables where appropriate." : "Well-formatted markdown report with ## headings, bullet points, and inline citations."}`; Format: ${formatInstruction}
Audience: ${audience}
Remember to use numbered citations like [1], [2] and include a ## References section at the end.`;
const result = await runAnalysisAgent( const result = await runAnalysisAgent(
SYNTHESIS_SYSTEM, buildSynthesisSystem(audience),
taskPrompt, taskPrompt,
cwd, cwd,
120_000, 120_000,
@@ -105,64 +196,250 @@ Format: ${config.format === "structured" ? "Structured report with numbered sect
); );
if (result.success && result.text) { if (result.success && result.text) {
return result.text; // Build bibliography section
const bibSection = buildBibliography(referenceMap);
// Append references if not already present
let report = result.text;
if (!report.includes("## References") && !report.includes("# References")) {
report += `\n\n${bibSection}`;
} }
// Fallback: generate a simple report from the evidence return { report, references: Array.from(referenceMap.values()) };
return generateFallbackReport(question, rounds); }
// Fallback: generate a simple structured report
const fallbackReport = generateFallbackReport(
question,
rounds,
referenceMap,
audience,
);
return {
report: fallbackReport + `\n\n${buildBibliography(referenceMap)}`,
references: Array.from(referenceMap.values()),
};
} }
/* ── Bibliography Builder ────────────────────────────────────────── */
/**
* Build a structured ## References section from the reference map.
*/
function buildBibliography(referenceMap: Map<string, Reference>): string {
if (referenceMap.size === 0) return "## References\n\nNo sources cited.";
const refs = Array.from(referenceMap.values()).sort((a, b) => a.id - b.id);
const lines: string[] = ["## References\n"];
for (const ref of refs) {
const authIcon =
ref.authorityScore >= 0.8 ? "⭐" : ref.authorityScore >= 0.5 ? "✓" : "○";
lines.push(
`[${ref.id}] ${authIcon} **${ref.title}** — ${ref.domain} (${ref.url}) — accessed ${ref.accessedAt}`,
);
}
return lines.join("\n");
}
/* ── Fallback Report ─────────────────────────────────────────────── */
/** /**
* Fallback report when the LLM synthesis fails. * Fallback report when the LLM synthesis fails.
* Produces a clean, structured report from the evidence.
*/ */
function generateFallbackReport( function generateFallbackReport(
question: string, question: string,
rounds: ResearchRound[], rounds: ResearchRound[],
referenceMap: Map<string, Reference>,
_audience: string,
): string { ): string {
const lines: string[] = []; const lines: string[] = [];
const allFindings = rounds.flatMap((r) => r.findings);
// ── TL;DR ──
lines.push(`# Research Report: ${question}`); lines.push(`# Research Report: ${question}`);
lines.push(""); lines.push("");
const highConfFindings = allFindings.filter((f) => f.confidence === "high");
const totalHigh = highConfFindings.length;
const total = allFindings.length;
lines.push("## TL;DR");
lines.push("");
if (highConfFindings.length > 0) {
lines.push(
`Based on analysis of ${total} findings across ${rounds.length} research round(s), ` +
`${totalHigh} high-confidence conclusions were identified. ` +
`${highConfFindings[0].title}: ${highConfFindings[0].summary}`,
);
} else {
lines.push(
`This report covers findings from ${rounds.length} research round(s) exploring "${question}". ` +
`${total} findings were extracted, with varying levels of confidence.`,
);
}
lines.push("");
// ── Executive Summary ──
lines.push("## Executive Summary"); lines.push("## Executive Summary");
lines.push(""); lines.push("");
lines.push( lines.push(
`This report summarizes findings from ${rounds.length} research round(s) exploring the question above.`, `This report synthesizes findings from ${rounds.length} research round(s), ` +
`${rounds.reduce((s, r) => s + r.queries.length, 0)} search queries, ` +
`and ${rounds.reduce((s, r) => s + r.results.length, 0)} sources.`,
); );
lines.push(""); lines.push("");
const allFindings = rounds.flatMap((r) => r.findings); // ── Key Findings (tiered) ──
if (allFindings.length > 0) { if (allFindings.length > 0) {
lines.push("## Key Findings"); lines.push("## Key Findings");
lines.push(""); lines.push("");
for (const finding of allFindings) {
lines.push(`### ${finding.title}`); // High confidence first
lines.push(`*Confidence: ${finding.confidence}*`); const highConf = allFindings.filter((f) => f.confidence === "high");
lines.push(""); if (highConf.length > 0) {
lines.push(finding.summary); lines.push("### High Confidence");
lines.push(""); for (const finding of highConf) {
if (finding.keyQuotes.length > 0) { const refs = finding.sources
lines.push(`> ${finding.keyQuotes[0]}`); .map((url) => referenceMap.get(url))
.filter((r): r is Reference => !!r)
.map((r) => `[${r.id}]`);
lines.push(
`- **${finding.title}** ${refs.length > 0 ? refs.join("") : ""}`,
);
lines.push(` - ${finding.summary}`);
}
lines.push(""); lines.push("");
} }
if (finding.sources.length > 0) {
lines.push("Sources:"); // Medium confidence
for (const src of finding.sources) { const medConf = allFindings.filter((f) => f.confidence === "medium");
lines.push(`- [${src}](${src})`); if (medConf.length > 0) {
lines.push("### Moderate Confidence");
for (const finding of medConf) {
const refs = finding.sources
.map((url) => referenceMap.get(url))
.filter((r): r is Reference => !!r)
.map((r) => `[${r.id}]`);
lines.push(
`- **${finding.title}** ${refs.length > 0 ? refs.join("") : ""}`,
);
lines.push(` - ${finding.summary}`);
} }
lines.push(""); lines.push("");
} }
// Low confidence
const lowConf = allFindings.filter((f) => f.confidence === "low");
if (lowConf.length > 0) {
lines.push("### Lower Confidence (Needs Further Research)");
for (const finding of lowConf) {
const refs = finding.sources
.map((url) => referenceMap.get(url))
.filter((r): r is Reference => !!r)
.map((r) => `[${r.id}]`);
lines.push(
`- **${finding.title}** ${refs.length > 0 ? refs.join("") : ""}`,
);
lines.push(` - ${finding.summary}`);
}
lines.push("");
}
// ── Detailed Analysis ──
lines.push("## Detailed Analysis");
lines.push("");
const byAngle = new Map<string, Finding[]>();
for (const round of rounds) {
for (const f of round.findings) {
const angle = round.queries[0]?.angle ?? "general";
if (!byAngle.has(angle)) byAngle.set(angle, []);
byAngle.get(angle)!.push(f);
}
}
for (const [angle, findings] of byAngle) {
lines.push(`### ${angle.charAt(0).toUpperCase() + angle.slice(1)}`);
lines.push("");
for (const f of findings) {
const corrStr =
f.corroborationScore !== undefined
? ` (corroboration: ${(f.corroborationScore * 100).toFixed(0)}%)`
: "";
lines.push(`**${f.title}** — *${f.confidence} confidence${corrStr}*`);
lines.push("");
lines.push(f.summary);
lines.push("");
if (f.keyQuotes.length > 0) {
lines.push(`> ${f.keyQuotes[0]}`);
lines.push("");
}
} }
} }
lines.push("## Search Methodology"); // ── Limitations ──
const lowConfCount = allFindings.filter(
(f) => f.confidence === "low",
).length;
const noCorr = allFindings.filter(
(f) => (f.corroborationScore ?? 0) < 0.3,
).length;
lines.push("## Limitations & Knowledge Gaps");
lines.push("");
if (lowConfCount > 0) {
lines.push(
`- **${lowConfCount} of ${allFindings.length} findings** have low confidence, indicating limited or conflicting evidence.`,
);
}
if (noCorr > 0) {
lines.push(
`- **${noCorr} findings** lack corroboration from multiple independent sources.`,
);
}
lines.push(
"- This research relied on web search results; some relevant sources may not be indexed or accessible.",
);
lines.push(
"- Findings are dependent on search engine ranking and the quality of indexed content.",
);
lines.push("");
// ── Conclusion ──
lines.push("## Conclusion");
lines.push("");
if (highConf.length > 0) {
lines.push(
`The research identified ${highConf.length} high-confidence finding(s) and ${medConf.length} moderately-supported finding(s). ` +
`The strongest evidence relates to: ${highConf.map((f) => f.title).join(", ")}.`,
);
} else {
lines.push(
"The research surfaced relevant information but with limited high-confidence evidence. Further investigation is recommended for the identified knowledge gaps.",
);
}
lines.push("");
}
// ── Methodology ──
lines.push(`*Report prepared for: ${_audience} audience*`);
lines.push("");
lines.push("## Methodology");
lines.push(""); lines.push("");
for (const round of rounds) { for (const round of rounds) {
const failedSearches = round.queries.length - round.successfulSearches;
lines.push(`### Round ${round.round}`); lines.push(`### Round ${round.round}`);
lines.push( lines.push(
`Queries: ${round.queries.map((q) => `"${q.query}"`).join(", ")}`, `Queries: ${round.queries.map((q) => `"${q.query}" [${q.angle}]`).join(", ")}`,
); );
lines.push(`Pages scraped: ${round.results.length}`); lines.push(`Pages scraped: ${round.results.length}`);
lines.push(`Findings: ${round.findings.length}`); lines.push(`Findings extracted: ${round.findings.length}`);
if (failedSearches > 0) {
lines.push(`Searches failed: ${failedSearches}`);
}
lines.push(""); lines.push("");
} }

View File

@@ -2,33 +2,40 @@
* Deep Research — Core research orchestration * Deep Research — Core research orchestration
* *
* Manages the multi-round deep research process: * Manages the multi-round deep research process:
* 1. Generate initial search queries * 1. Decompose the question into sub-questions (when depth > 1)
* 2. Execute all queries in parallel via Firecrawl * 2. Generate initial search queries (per sub-question for better diversity)
* 3. Analyze results and extract findings * 3. Execute all queries in parallel via Firecrawl
* 4. Generate follow-up queries * 4. Analyze results and extract findings
* 5. Iterate for depth rounds * 5. Compute corroboration scores
* 6. Synthesize final report * 6. Generate follow-up queries for gaps
* 7. Iterate for depth rounds
* 8. Synthesize final report with numbered references
* *
* Widget and progress callback patterns borrowed from ralpi's executor. * Widget and progress callback patterns borrowed from ralpi's executor.
*/ */
import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import type { import type {
Finding,
ResearchConfig, ResearchConfig,
SearchResult, EnrichedSearchResult,
ResearchRound, ResearchRound,
ResearchReport, ResearchReport,
} from "./types"; } from "./types";
import type { SynthesisResult } from "./report";
import { searchWeb } from "./firecrawl"; import { searchWeb } from "./firecrawl";
import { import {
generateQueries, generateQueries,
generateFollowUpQueries, generateFollowUpQueries,
analyzeResults, analyzeResults,
computeCorroboration,
decomposeQuestion,
} from "./queries"; } from "./queries";
import { synthesizeReport } from "./report"; import { synthesizeReport } from "./report";
/** Progress callback for UI updates */ /** Progress callback for UI updates */
export type ResearchProgress = (update: { export type ResearchProgress = (update: {
phase: phase:
| "decomposing"
| "generating_queries" | "generating_queries"
| "searching" | "searching"
| "analyzing" | "analyzing"
@@ -41,6 +48,86 @@ export type ResearchProgress = (update: {
fraction?: number; // 0-1 fraction?: number; // 0-1
}) => void; }) => void;
// ── Round-Robin Parallel Execution ──────────────────────────────────
/**
* Maximum concurrent Firecrawl search requests.
* Prevents rate limiting while still parallelizing queries.
*/
const MAX_SEARCH_CONCURRENT = 3;
/**
* Maximum concurrent analysis agent sessions.
*/
const MAX_ANALYSIS_CONCURRENT = 2;
/**
* Minimum findings per round before we consider early stopping.
* If we're getting very few new findings, saturation is near.
*/
const SATURATION_THRESHOLD = 0.15; // < 15% new findings = likely saturated
/**
* Bounded-concurrency parallel execution with round-robin slot assignment.
*
* Similar to ralpi's ModelRoundRobin: with N concurrent slots, items are
* assigned to free slots in FIFO order. When a slot finishes, the next
* item in the queue is assigned to it.
*
* This ensures even load distribution and avoids bursty concurrency.
*/
async function boundedConcurrency<T, R>(
items: T[],
maxConcurrent: number,
mapper: (item: T, index: number) => Promise<R>,
): Promise<R[]> {
const results: R[] = new Array(items.length);
let nextIndex = 0;
async function worker(): Promise<void> {
while (true) {
const currentIndex = nextIndex++;
if (currentIndex >= items.length) return;
results[currentIndex] = await mapper(items[currentIndex], currentIndex);
}
}
const numWorkers = Math.min(maxConcurrent, items.length);
const workers = Array.from({ length: numWorkers }, () => worker());
await Promise.all(workers);
return results;
}
/**
* Assess whether the research is reaching information saturation.
*/
function assessSaturation(
previousRound: ResearchRound | undefined,
currentRound: ResearchRound,
): number {
if (!previousRound || previousRound.findings.length === 0) return 0;
const prevUrls = new Set(previousRound.results.map((r) => r.url));
const newUrls = currentRound.results.filter(
(r) => !prevUrls.has(r.url),
).length;
const totalUrls = currentRound.results.length;
const newRatio = totalUrls > 0 ? newUrls / totalUrls : 0;
// Also check finding novelty
const prevFindingTitles = new Set(
previousRound.findings.map((f) => f.title.toLowerCase()),
);
const newFindings = currentRound.findings.filter(
(f) => !prevFindingTitles.has(f.title.toLowerCase()),
).length;
const totalFindings = currentRound.findings.length;
const findingNovelty = totalFindings > 0 ? newFindings / totalFindings : 0;
// Weight: URL novelty (40%) + finding novelty (60%)
return newRatio * 0.4 + findingNovelty * 0.6;
}
/** /**
* Run a complete deep research session. * Run a complete deep research session.
*/ */
@@ -54,15 +141,35 @@ export async function runDeepResearch(
const rounds: ResearchRound[] = []; const rounds: ResearchRound[] = [];
let totalSearches = 0; let totalSearches = 0;
let totalPages = 0; let totalPages = 0;
let subQuestions: string[] = [];
// ── Round 1: Generate initial queries ────────────────────────────── // ── Phase: Decompose question into sub-questions ────────────────
if (config.depth > 1) {
onProgress({
phase: "decomposing",
round: 1,
totalRounds: config.depth,
message: "Decomposing research question into sub-topics...",
fraction: 0,
});
if (signal?.aborted) throw new Error("Research cancelled");
subQuestions = await decomposeQuestion(config.question, ctx.cwd, signal);
}
// ── Phase: Generate initial queries ─────────────────────────────
onProgress({ onProgress({
phase: "generating_queries", phase: "generating_queries",
round: 1, round: 1,
totalRounds: config.depth, totalRounds: config.depth,
message: "Generating initial search queries...", message:
fraction: 0, subQuestions.length > 0
? `Generating queries across ${subQuestions.length} sub-topics...`
: "Generating initial search queries...",
fraction: 0.05,
}); });
if (signal?.aborted) throw new Error("Research cancelled"); if (signal?.aborted) throw new Error("Research cancelled");
@@ -72,13 +179,14 @@ export async function runDeepResearch(
config.breadth, config.breadth,
ctx.cwd, ctx.cwd,
signal, signal,
subQuestions.length > 0 ? subQuestions : undefined,
); );
if (queries.length === 0) { if (queries.length === 0) {
throw new Error("Failed to generate any search queries"); throw new Error("Failed to generate any search queries");
} }
// ── Execute rounds ───────────────────────────────────────────────── // ── Execute rounds ───────────────────────────────────────────────
for (let round = 1; round <= config.depth; round++) { for (let round = 1; round <= config.depth; round++) {
if (signal?.aborted) throw new Error("Research cancelled"); if (signal?.aborted) throw new Error("Research cancelled");
@@ -99,22 +207,25 @@ export async function runDeepResearch(
break; break;
} }
// ── Search phase ────────────────────────────────────────────────── // ── Search phase (parallel with round-robin) ────────────────────
onProgress({ onProgress({
phase: "searching", phase: "searching",
round, round,
totalRounds: config.depth, totalRounds: config.depth,
message: `Searching with ${currentQueries.length} queries...`, message: `Searching ${currentQueries.length} queries in parallel...`,
fraction: 0.25, fraction: 0.25,
}); });
const searchResults: SearchResult[] = [];
for (let i = 0; i < currentQueries.length; i++) {
if (signal?.aborted) throw new Error("Research cancelled"); if (signal?.aborted) throw new Error("Research cancelled");
const q = currentQueries[i]; // Run searches in parallel using round-robin bounded concurrency.
// Each mapper call runs independently; failures are caught per-query.
const searchResultsArrays: (EnrichedSearchResult[] | null)[] =
await boundedConcurrency(
currentQueries,
MAX_SEARCH_CONCURRENT,
async (q, i) => {
onProgress({ onProgress({
phase: "searching", phase: "searching",
round, round,
@@ -125,11 +236,10 @@ export async function runDeepResearch(
}); });
try { try {
const results = await searchWeb(q.query, 5, signal); return await searchWeb(q.query, 5, signal);
searchResults.push(...results);
} catch (error) { } catch (error) {
// Individual search failure shouldn't crash the whole round const errorMsg =
const errorMsg = error instanceof Error ? error.message : String(error); error instanceof Error ? error.message : String(error);
onProgress({ onProgress({
phase: "searching", phase: "searching",
round, round,
@@ -137,87 +247,137 @@ export async function runDeepResearch(
message: `Search failed: ${errorMsg.slice(0, 80)}`, message: `Search failed: ${errorMsg.slice(0, 80)}`,
fraction: 0.25 + ((i + 1) / currentQueries.length) * 0.25, fraction: 0.25 + ((i + 1) / currentQueries.length) * 0.25,
}); });
return null;
} }
},
);
// Small delay between searches to avoid rate limits // Flatten results, filtering out nulls (failed searches)
if (i < currentQueries.length - 1) { const searchResults: EnrichedSearchResult[] = searchResultsArrays
await new Promise((r) => setTimeout(r, 300)); .filter((r): r is EnrichedSearchResult[] => r !== null)
} .flat();
}
totalSearches += currentQueries.length; totalSearches += currentQueries.length;
// Deduplicate results by URL // Deduplicate results by URL (prefer higher authority)
const seen = new Set<string>(); const seen = new Map<string, EnrichedSearchResult>();
const uniqueResults = searchResults.filter((r) => { for (const r of searchResults) {
if (seen.has(r.url)) return false; const existing = seen.get(r.url);
seen.add(r.url); if (!existing || r.authorityScore > existing.authorityScore) {
return true; seen.set(r.url, r);
}); }
}
const uniqueResults = Array.from(seen.values());
totalPages += uniqueResults.length; totalPages += uniqueResults.length;
// ── Analyze phase ────────────────────────────────────────────────── // ── Analyze phase (parallel with round-robin) ──────────────────
onProgress({ onProgress({
phase: "analyzing", phase: "analyzing",
round, round,
totalRounds: config.depth, totalRounds: config.depth,
message: `Analyzing ${uniqueResults.length} search results...`, message: `Analyzing ${uniqueResults.length} search results in parallel...`,
fraction: 0.6, fraction: 0.6,
}); });
// Analyze results per query group
const allFindings: ResearchRound["findings"] = [];
for (let i = 0; i < currentQueries.length; i++) {
if (signal?.aborted) throw new Error("Research cancelled"); if (signal?.aborted) throw new Error("Research cancelled");
const q = currentQueries[i]; // Build query-result pairs for parallel analysis
// Find results that match this query (loosely: take a portion of results) const analysisTasks: Array<{
query: (typeof currentQueries)[number];
results: typeof uniqueResults;
index: number;
}> = [];
const resultsPerQuery = Math.ceil( const resultsPerQuery = Math.ceil(
uniqueResults.length / currentQueries.length, uniqueResults.length / currentQueries.length,
); );
for (let i = 0; i < currentQueries.length; i++) {
const startIdx = i * resultsPerQuery; const startIdx = i * resultsPerQuery;
const endIdx = Math.min(startIdx + resultsPerQuery, uniqueResults.length); const endIdx = Math.min(startIdx + resultsPerQuery, uniqueResults.length);
const queryResults = uniqueResults.slice(startIdx, endIdx); const queryResults = uniqueResults.slice(startIdx, endIdx);
if (queryResults.length === 0) continue; if (queryResults.length === 0) continue;
analysisTasks.push({
query: currentQueries[i],
results: queryResults,
index: i,
});
}
// Run analyses in parallel using round-robin bounded concurrency
const findingsArrays: Finding[][] = await boundedConcurrency(
analysisTasks,
MAX_ANALYSIS_CONCURRENT,
async (task) => {
onProgress({ onProgress({
phase: "analyzing", phase: "analyzing",
round, round,
totalRounds: config.depth, totalRounds: config.depth,
message: `Analyzing results for "${q.query.slice(0, 40)}..."`, message: `Analyzing: "${task.query.query.slice(0, 40)}..."`,
fraction: 0.6 + (i / currentQueries.length) * 0.2, fraction: 0.6 + (task.index / currentQueries.length) * 0.2,
}); });
try { try {
const findings = await analyzeResults( return await analyzeResults(
q.query, task.query.query,
queryResults, task.results,
ctx.cwd, ctx.cwd,
signal, signal,
); );
allFindings.push(...findings);
} catch { } catch {
// Analysis failure shouldn't crash the round // Analysis failure shouldn't crash the round
return [];
} }
} },
);
// Flatten all findings
const allFindings: ResearchRound["findings"] = findingsArrays.flat();
// ── Corroboration pass ────────────────────────────────────────
// Cross-reference findings to compute corroboration scores
const corroboratedFindings = computeCorroboration(allFindings);
// Record this round // Record this round
const successfulSearches = currentQueries.length;
const followUpTopics = corroboratedFindings
.filter(
(f: Finding) =>
f.confidence === "low" && (f.corroborationScore ?? 0) < 0.5,
)
.map((f: Finding) => f.title);
rounds.push({ rounds.push({
round, round,
queries: currentQueries, queries: currentQueries,
results: uniqueResults, results: uniqueResults,
findings: allFindings, findings: corroboratedFindings,
followUpTopics: allFindings followUpTopics,
.filter((f) => f.confidence === "low") successfulSearches,
.map((f) => f.title),
}); });
// ── Adaptive depth: check for saturation ──────────────────────
if (round > 1 && round < config.depth) {
const saturation = assessSaturation(
rounds[rounds.length - 2],
rounds[rounds.length - 1],
);
if (saturation < SATURATION_THRESHOLD) {
onProgress({
phase: "synthesizing",
message: `Information saturation reached (${(saturation * 100).toFixed(0)}% novelty) — synthesizing early`,
fraction: 0.85,
});
break;
}
}
} }
// ── Synthesis phase ───────────────────────────────────────────────── // ── Synthesis phase ───────────────────────────────────────────────
onProgress({ onProgress({
phase: "synthesizing", phase: "synthesizing",
@@ -227,13 +387,15 @@ export async function runDeepResearch(
if (signal?.aborted) throw new Error("Research cancelled"); if (signal?.aborted) throw new Error("Research cancelled");
const finalReport = await synthesizeReport( const synthesisResult: SynthesisResult = await synthesizeReport(
config.question, config.question,
rounds, rounds,
config, config,
ctx.cwd, ctx.cwd,
signal, signal,
); );
const finalReport = synthesisResult.report;
const references = synthesisResult.references;
const durationMs = Date.now() - startTime; const durationMs = Date.now() - startTime;
@@ -250,5 +412,6 @@ export async function runDeepResearch(
totalSearches, totalSearches,
totalPagesScraped: totalPages, totalPagesScraped: totalPages,
durationMs, durationMs,
references,
}; };
} }

View File

@@ -2,6 +2,16 @@
* Deep Research — type definitions * Deep Research — type definitions
*/ */
/** Content type classification for a source */
export type ContentType =
| "documentation"
| "paper"
| "news"
| "blog"
| "forum"
| "official"
| "other";
/** A single search result from Firecrawl */ /** A single search result from Firecrawl */
export interface SearchResult { export interface SearchResult {
title: string; title: string;
@@ -10,6 +20,14 @@ export interface SearchResult {
markdown: string; markdown: string;
} }
/** Enriched search result with source authority metadata */
export interface EnrichedSearchResult extends SearchResult {
domain: string;
authorityScore: number; // 0.0 1.0
publishedDate: Date | null;
contentType: ContentType;
}
/** A finding extracted from search results by an analysis agent */ /** A finding extracted from search results by an analysis agent */
export interface Finding { export interface Finding {
title: string; title: string;
@@ -17,6 +35,22 @@ export interface Finding {
sources: string[]; sources: string[];
keyQuotes: string[]; keyQuotes: string[];
confidence: "high" | "medium" | "low"; confidence: "high" | "medium" | "low";
/** 0.0 1.0: how many independent sources support this finding */
corroborationScore?: number;
/** Authority score of the best source supporting this finding */
bestSourceAuthority?: number;
/** Average authority score across all sources */
avgSourceAuthority?: number;
}
/** A numbered reference with full metadata */
export interface Reference {
id: number;
url: string;
title: string;
domain: string;
authorityScore: number;
accessedAt: string; // ISO date string
} }
/** A generated search query with its intent/rationale */ /** A generated search query with its intent/rationale */
@@ -30,18 +64,28 @@ export interface SearchQuery {
export interface ResearchRound { export interface ResearchRound {
round: number; round: number;
queries: SearchQuery[]; queries: SearchQuery[];
results: SearchResult[]; results: EnrichedSearchResult[];
findings: Finding[]; findings: Finding[];
/** Any follow-up questions/angles the analysis suggests */ /** Any follow-up questions/angles the analysis suggests */
followUpTopics: string[]; followUpTopics: string[];
/** Number of sources that actually returned data (non-empty) */
successfulSearches: number;
} }
/** Target audience expertise level */
export type Audience = "expert" | "general" | "executive";
/** Configuration for a research session */ /** Configuration for a research session */
export interface ResearchConfig { export interface ResearchConfig {
question: string; question: string;
depth: number; // 1-3 rounds depth: number; // 1-3 rounds
breadth: number; // queries per round (1-5) breadth: number; // queries per round (1-5)
format: "markdown" | "structured"; format: "markdown" | "structured";
audience?: Audience;
/** Focus on specific research angles only (empty = all angles) */
focus?: string[];
/** Show the research methodology section in the report */
showMethodology?: boolean;
} }
/** Final research report */ /** Final research report */
@@ -52,4 +96,5 @@ export interface ResearchReport {
totalSearches: number; totalSearches: number;
totalPagesScraped: number; totalPagesScraped: number;
durationMs: number; durationMs: number;
references: Reference[];
} }