Forked from bakit/deep-search
Forked from bakit/deep-search
src / index.ts
import { LMStudioClient, tool, type ToolCallContext } from "@lmstudio/sdk";
import { z } from "zod";
// ─── Types ───────────────────────────────────────────────────────────────────
interface SearchResult {
title: string;
url: string;
displayedUrl: string;
snippet: string;
source: string;
}
interface CrawledPage {
url: string;
title: string;
description: string;
content: string;
wordCount: number;
links: string[];
excerpt: string;
relevanceScore: number;
depth: number;
sourceType: string;
}
interface ResearchResult {
query: string;
focus: string | null;
modelUsed: string | null;
rounds: number;
searchQueries: string[];
searchResults: SearchResult[];
sources: Array<{
rank: number;
title: string;
url: string;
domain: string;
sourceType: string;
excerpt: string;
wordCount: number;
relevanceScore: number;
depth: number;
}>;
contradictions: string[];
gaps: string[];
reportMarkdown: string;
}
interface DeepResearchParams {
query: string;
focus?: string;
maxRounds: number;
maxSearchesPerRound: number;
maxResultsPerSearch: number;
maxPages: number;
maxDepth: number;
maxCharsPerPage: number;
modelId?: string;
maxTokens: number;
temperature: number;
}
interface LoadedModel {
identifier: string;
model: Awaited<ReturnType<LMStudioClient["llm"]["listLoaded"]>>[number];
}
interface AnchorCandidate {
url: string;
text: string;
score: number;
}
interface QueueEntry {
url: string;
depth: number;
score: number;
}
// ─── Constants ───────────────────────────────────────────────────────────────
const DEFAULTS = {
timeoutMs: 15000,
maxRounds: 2,
maxSearchesPerRound: 4,
maxResultsPerSearch: 6,
maxPages: 12,
maxDepth: 2,
maxCharsPerPage: 16000,
maxTokens: 650,
temperature: 0.2,
maxLinksPerPage: 12,
};
const USER_AGENT = "Mozilla/5.0 (compatible; LMStudioDeepResearch/2.0; +https://lmstudio.ai)";
const BLOCKED_HOSTS = new Set([
"facebook.com", "instagram.com", "x.com", "twitter.com", "tiktok.com",
"reddit.com", "pinterest.com", "linkedin.com", "snapchat.com",
"discord.com", "discord.gg", "tumblr.com", "quora.com", "fandom.com",
"youtube.com", "youtu.be", "twitch.tv", "onlyfans.com",
]);
const BLOCKED_URL_PARTS = [
"/share", "/sharer", "/intent/", "/status/", "/posts/", "/reels/",
"/shorts/", "/video/", "/watch?", "/watch/", "/tiktok.com/", "/redd.it/",
];
const BOILERPLATE_LINK_TEXT = new Set([
"home", "menu", "log in", "login", "sign in", "sign up", "subscribe",
"newsletter", "privacy", "terms", "cookies", "cookie policy",
"accept cookies", "contact", "about us", "about", "sitemap", "search",
"share", "follow", "read more", "learn more",
]);
const STOP_WORDS = new Set([
"the", "and", "for", "with", "that", "this", "from", "into", "about",
"what", "when", "where", "which", "who", "how", "why", "can", "could",
"would", "should", "please", "need", "want", "best", "latest", "current",
"new", "old", "vs", "via", "of", "to", "in", "on", "by", "as", "is",
"are", "be", "it", "or", "an", "a",
]);
// Pre-compiled regexes
const RE_HTML_TAGS = /<[^>]+>/g;
const RE_SCRIPT = /<script[\s\S]*?<\/script>/gi;
const RE_STYLE = /<style[\s\S]*?<\/style>/gi;
const RE_NOSCRIPT = /<noscript[\s\S]*?<\/noscript>/gi;
const RE_SVG = /<svg[\s\S]*?<\/svg>/gi;
const RE_IFRAME = /<iframe[\s\S]*?<\/iframe>/gi;
const RE_NAV = /<nav[\s\S]*?<\/nav>/gi;
const RE_FOOTER = /<footer[\s\S]*?<\/footer>/gi;
const RE_HEADER = /<header[\s\S]*?<\/header>/gi;
const RE_FORM = /<form[\s\S]*?<\/form>/gi;
const RE_ASIDE = /<aside[\s\S]*?<\/aside>/gi;
const RE_BR = /<br\s*\/?>/gi;
const RE_BLOCK_END = /<\/(p|div|li|section|article|tr|table|blockquote|h[1-6])>/gi;
const RE_LI = /<li\b[^>]*>/gi;
const RE_H = /<h[1-6]\b[^>]*>/gi;
const RE_TITLE = /<title[^>]*>([\s\S]*?)<\/title>/i;
const RE_META_DESC = /<meta[^>]+name=["']description["'][^>]*content=["']([^"']+)["'][^>]*>/i;
const RE_META_OG_DESC = /<meta[^>]+property=["']og:description["'][^>]*content=["']([^"']+)["'][^>]*>/i;
const RE_META_OG_TITLE = /<meta[^>]+property=["']og:title["'][^>]*content=["']([^"']+)["'][^>]*>/i;
const RE_CANONICAL = /<link[^>]+rel=["']canonical["'][^>]*href=["']([^"']+)["'][^>]*>/i;
const RE_ANCHOR = /<a\b[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi;
const RE_NOFOLLOW_ANCHOR = /<a[^>]*rel="nofollow"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi;
const RE_BLOCKED_COOKIE = /(cookie|privacy|terms|subscribe|login)/i;
const RE_NON_ALPHANUM = /[^a-z0-9]+/i;
const RE_WHITESPACE = /\s+/g;
const RE_NEWLINES = /\n{3,}/g;
const RE_WORD_SPLIT = /\s+/;
// ─── Client singleton ────────────────────────────────────────────────────────
let client: LMStudioClient | null = null;
function getClient(): LMStudioClient {
if (!client) client = new LMStudioClient();
return client;
}
// ─── Clamping helpers ────────────────────────────────────────────────────────
function clampInt(value: unknown, fallback: number, min: number, max: number): number {
if (typeof value === "number" && Number.isFinite(value)) {
return clampRange(Math.trunc(value), min, max);
}
if (typeof value === "string" && /^-?\d+$/.test(value.trim())) {
return clampRange(Math.trunc(Number(value)), min, max);
}
return clampRange(fallback, min, max);
}
function clampFloat(value: unknown, fallback: number, min: number, max: number): number {
if (typeof value === "number" && Number.isFinite(value)) {
return clampRange(value, min, max);
}
if (typeof value === "string" && /^-?\d+(?:\.\d+)?$/.test(value.trim())) {
return clampRange(Number(value), min, max);
}
return clampRange(fallback, min, max);
}
function clampRange(value: number, min: number, max: number): number {
return value < min ? min : value > max ? max : value;
}
// ─── String utilities ────────────────────────────────────────────────────────
function normalizeWhitespace(value: string): string {
return value.replace(RE_WHITESPACE, " ").trim();
}
function trimString(value: string, maxChars: number): string {
const normalized = value.trim();
if (normalized.length <= maxChars) return normalized;
return `${normalized.slice(0, maxChars - 1).trimEnd()}…`;
}
function decodeHtmlEntities(value: string): string {
return value
.replace(/ /gi, " ")
.replace(/&/gi, "&")
.replace(/"/gi, '"')
.replace(/'/gi, "'")
.replace(/'/gi, "'")
.replace(/</gi, "<")
.replace(/>/gi, ">")
.replace(/'/gi, "'")
.replace(///gi, "/")
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
.replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCharCode(parseInt(n, 16)));
}
// ─── URL utilities ───────────────────────────────────────────────────────────
function getHostname(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./i, "").toLowerCase();
} catch {
return "";
}
}
function getRootDomain(hostname: string): string {
const parts = hostname.split(".").filter(Boolean);
return parts.length <= 2 ? hostname : parts.slice(-2).join(".");
}
// URL normalization cache
const urlCache = new Map<string, string>();
function normalizeUrl(url: string): string {
const cached = urlCache.get(url);
if (cached) return cached;
try {
const parsed = new URL(url);
parsed.hash = "";
if (parsed.pathname !== "/" && parsed.pathname.endsWith("/")) {
parsed.pathname = parsed.pathname.replace(/\/+$/, "");
if (!parsed.pathname) parsed.pathname = "/";
}
const result = parsed.toString();
urlCache.set(url, result);
return result;
} catch {
const result = normalizeWhitespace(url);
urlCache.set(url, result);
return result;
}
}
function resolveUrl(raw: string, baseUrl: string): string | null {
const trimmed = raw.trim();
if (!trimmed) return null;
if (/^(javascript|mailto|tel|data):/i.test(trimmed)) return null;
try {
return new URL(trimmed, baseUrl).toString();
} catch {
return null;
}
}
// ─── Blocking checks ─────────────────────────────────────────────────────────
function isBlockedHost(hostname: string): boolean {
const lower = hostname.toLowerCase();
for (const blocked of BLOCKED_HOSTS) {
if (lower === blocked || lower.endsWith(`.${blocked}`)) return true;
}
return false;
}
function isBlockedUrl(url: string): boolean {
const lower = url.toLowerCase();
if (isBlockedHost(getHostname(url))) return true;
for (const part of BLOCKED_URL_PARTS) {
if (lower.includes(part)) return true;
}
return false;
}
// ─── Scoring ─────────────────────────────────────────────────────────────────
function scoreDomain(host: string): number {
if (!host) return 0;
const lower = host.toLowerCase();
if (lower.endsWith(".gov") || lower.endsWith(".edu") || lower.endsWith(".ac.uk")) return 8;
if (lower.includes("nih.gov") || lower.includes("who.int") || lower.includes("arxiv.org")) return 7;
if (lower.includes("wikipedia.org")) return 5;
if (lower.includes("docs.") || lower.includes("developer.")) return 4;
return 1;
}
function sourceTypeForHost(host: string): string {
const lower = host.toLowerCase();
if (isBlockedHost(lower)) return "blocked";
if (lower.endsWith(".gov") || lower.endsWith(".edu") || lower.includes("nih.gov")) return "authoritative";
if (lower.includes("wikipedia.org")) return "reference";
return "web";
}
function tokenizeQuery(query: string): string[] {
const tokens = query.toLowerCase().split(RE_NON_ALPHANUM);
const seen = new Set<string>();
const result: string[] = [];
for (const token of tokens) {
if (token.length >= 3 && !STOP_WORDS.has(token) && !seen.has(token)) {
seen.add(token);
result.push(token);
}
}
return result;
}
function countOccurrences(haystack: string, needle: string): number {
if (!needle) return 0;
let count = 0, start = 0;
while (true) {
const idx = haystack.indexOf(needle, start);
if (idx === -1) break;
count++;
start = idx + needle.length;
}
return count;
}
function scoreText(text: string, tokens: string[]): number {
const normalized = text.toLowerCase();
let score = 0;
for (const token of tokens) {
score += countOccurrences(normalized, token);
}
return score;
}
// ─── HTML extraction ─────────────────────────────────────────────────────────
function stripTags(html: string): string {
let t = html;
t = t.replace(RE_SCRIPT, " ");
t = t.replace(RE_STYLE, " ");
t = t.replace(RE_NOSCRIPT, " ");
t = t.replace(RE_SVG, " ");
t = t.replace(RE_IFRAME, " ");
t = t.replace(RE_NAV, " ");
t = t.replace(RE_FOOTER, " ");
t = t.replace(RE_HEADER, " ");
t = t.replace(RE_FORM, " ");
t = t.replace(RE_ASIDE, " ");
t = t.replace(RE_BR, "\n");
t = t.replace(RE_BLOCK_END, "\n");
t = t.replace(RE_LI, "• ");
t = t.replace(RE_H, "\n");
t = t.replace(RE_HTML_TAGS, " ");
t = decodeHtmlEntities(t);
const lines = t
.replace(/\r/g, "")
.replace(/\u00a0/g, " ")
.split("\n")
.map((l) => normalizeWhitespace(l))
.filter((l) => l.length > 0 && !isBoilerplateLine(l));
const seen = new Set<string>();
return lines
.filter((l) => {
const k = l.toLowerCase();
if (seen.has(k)) return false;
seen.add(k);
return true;
})
.join("\n")
.replace(RE_NEWLINES, "\n\n")
.trim();
}
function isBoilerplateLine(line: string): boolean {
const lower = line.toLowerCase();
if (!lower || lower.length <= 2) return true;
if (BOILERPLATE_LINK_TEXT.has(lower)) return true;
for (const part of BOILERPLATE_LINK_TEXT) {
if (lower === ` ${part}` || lower.startsWith(`${part} `)) return true;
}
if (RE_BLOCKED_COOKIE.test(lower) && lower.length < 90) return true;
return false;
}
function extractMetaDescription(html: string): string {
const match = html.match(RE_META_DESC) ?? html.match(RE_META_OG_DESC);
return match ? normalizeWhitespace(stripTags(match[1])) : "";
}
function extractTitle(html: string): string {
const titleMatch = html.match(RE_TITLE);
if (titleMatch) return normalizeWhitespace(stripTags(titleMatch[1]));
const ogTitle = html.match(RE_META_OG_TITLE);
return ogTitle ? normalizeWhitespace(stripTags(ogTitle[1])) : "";
}
function extractCanonical(html: string): string {
const match = html.match(RE_CANONICAL);
return match ? match[1].trim() : "";
}
function extractAnchors(html: string, baseUrl: string, limit: number): AnchorCandidate[] {
const anchors: AnchorCandidate[] = [];
const seen = new Set<string>();
const anchorRegex = RE_ANCHOR;
let match: RegExpExecArray | null;
while ((match = anchorRegex.exec(html)) !== null) {
const resolved = resolveUrl(match[1], baseUrl);
if (!resolved) continue;
const normalized = normalizeUrl(resolved);
if (seen.has(normalized) || !/^https?:/i.test(normalized) || isBlockedUrl(normalized)) continue;
const text = normalizeWhitespace(stripTags(match[2]));
const host = getHostname(normalized);
let score = 0;
if (!text || BOILERPLATE_LINK_TEXT.has(text.toLowerCase())) score -= 3;
score += scoreDomain(host);
if (getRootDomain(host) === getRootDomain(getHostname(baseUrl))) score += 4;
anchors.push({ url: normalized, text, score });
seen.add(normalized);
if (anchors.length >= limit * 2) break;
}
return anchors
.sort((a, b) => b.score - a.score)
.slice(0, limit);
}
// ─── Sentence extraction ─────────────────────────────────────────────────────
function extractUsefulSentences(text: string, tokens: string[], maxSentences = 4): string[] {
const sentences = normalizeWhitespace(text)
.split(/(?<=[.!?])\s+/)
.map((part) => part.trim())
.filter(Boolean);
const scored = sentences
.map((sentence) => ({
sentence,
score: scoreText(sentence, tokens) + Math.min(3, sentence.length / 120),
}))
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, maxSentences);
return scored.map((item) => trimString(item.sentence, 300));
}
// ─── Fetching ────────────────────────────────────────────────────────────────
async function fetchText(url: string, timeoutMs = DEFAULTS.timeoutMs): Promise<{ text: string; contentType: string }> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(new Error("Request timed out")), timeoutMs);
try {
const response = await fetch(url, {
signal: controller.signal,
redirect: "follow",
headers: {
"user-agent": USER_AGENT,
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
},
});
if (!response.ok) throw new Error(`Request failed with status ${response.status} ${response.statusText}`);
const contentType = (response.headers.get("content-type") ?? "").toLowerCase();
if (!contentType.includes("text/html") && !contentType.includes("application/xhtml") && !contentType.includes("text/plain")) {
throw new Error(`Unsupported content type: ${contentType || "unknown"}`);
}
return { text: await response.text(), contentType };
} finally {
clearTimeout(timeout);
}
}
// ─── DuckDuckGo search ───────────────────────────────────────────────────────
function decodeDuckDuckGoUrl(href: string): string {
try {
const parsed = new URL(href, "https://duckduckgo.com");
const uddg = parsed.searchParams.get("uddg");
if (uddg) return decodeURIComponent(uddg);
return parsed.toString();
} catch {
return href;
}
}
function parseDuckDuckGoResults(html: string, isLite: boolean): SearchResult[] {
const results: SearchResult[] = [];
if (isLite) {
const anchorRegex = RE_NOFOLLOW_ANCHOR;
let match: RegExpExecArray | null;
while ((match = anchorRegex.exec(html)) !== null) {
const rawUrl = decodeDuckDuckGoUrl(match[1]);
const title = normalizeWhitespace(stripTags(match[2]));
if (!rawUrl || !title || isBlockedUrl(rawUrl)) continue;
let displayedUrl = rawUrl;
try {
const parsed = new URL(rawUrl);
displayedUrl = `${parsed.hostname.replace(/^www\./i, "")}${parsed.pathname}`;
} catch { /* ignore */ }
results.push({ title, url: rawUrl, displayedUrl, snippet: "", source: "duckduckgo-lite" });
}
} else {
const blocks = html.split(/<div class="result\b/gi);
for (const block of blocks.slice(1)) {
const linkMatch = block.match(/<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i) ??
block.match(/<a[^>]*href="([^"]+)"[^>]*class="[^"]*result__a[^"]*"[^>]*>([\s\S]*?)<\/a>/i);
if (!linkMatch) continue;
const rawUrl = decodeDuckDuckGoUrl(linkMatch[1]);
const title = normalizeWhitespace(stripTags(linkMatch[2]));
if (!rawUrl || !title || isBlockedUrl(rawUrl)) continue;
const snippetMatch = block.match(/class="[^"]*result__snippet[^"]*"[^>]*>([\s\S]*?)<\/(?:a|div|span)>/i) ??
block.match(/class="[^"]*result__snippet[^"]*"[^>]*>([\s\S]*?)<\/div>/i);
const snippet = snippetMatch ? normalizeWhitespace(stripTags(snippetMatch[1])) : "";
let displayedUrl = rawUrl;
try {
const parsed = new URL(rawUrl);
displayedUrl = `${parsed.hostname.replace(/^www\./i, "")}${parsed.pathname}`;
} catch { /* ignore */ }
results.push({ title, url: rawUrl, displayedUrl, snippet, source: "duckduckgo" });
}
}
return results;
}
async function searchDuckDuckGo(query: string, limit: number): Promise<SearchResult[]> {
const urls = [
`https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`,
`https://lite.duckduckgo.com/lite/?q=${encodeURIComponent(query)}`,
];
for (let i = 0; i < urls.length; i++) {
try {
const html = (await fetchText(urls[i])).text;
const parsed = parseDuckDuckGoResults(html, i === 1);
if (parsed.length > 0) return parsed.slice(0, limit);
} catch { /* fall through */ }
}
return [];
}
// ─── Deduplication & ranking ─────────────────────────────────────────────────
function uniqueByUrl<T extends { url: string }>(items: T[]): T[] {
const seen = new Set<string>();
const out: T[] = [];
for (const item of items) {
const key = normalizeUrl(item.url);
if (seen.has(key)) continue;
seen.add(key);
out.push(item);
}
return out;
}
function uniqueStrings(items: string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const item of items) {
const trimmed = item.trim();
if (trimmed && !seen.has(trimmed)) {
seen.add(trimmed);
out.push(trimmed);
}
}
return out;
}
function buildBaseSearchQueries(query: string, focus: string | undefined): string[] {
const cleanQuery = normalizeWhitespace(query);
const tokens = tokenizeQuery(cleanQuery);
const rootPhrase = tokens.slice(0, Math.min(4, tokens.length)).join(" ");
const variations: string[] = [
cleanQuery,
`${cleanQuery} official`,
`${cleanQuery} documentation`,
`${cleanQuery} research`,
`${cleanQuery} analysis`,
`${cleanQuery} review`,
`${cleanQuery} key facts`,
];
if (focus?.trim()) variations.unshift(`${cleanQuery} ${focus.trim()}`);
if (rootPhrase && rootPhrase !== cleanQuery.toLowerCase()) variations.push(rootPhrase);
if (tokens.length >= 3) variations.push(`"${tokens.slice(0, 3).join(" ")}"`);
return uniqueStrings(variations);
}
function scoreSearchResult(result: SearchResult, query: string): number {
const tokens = tokenizeQuery(query);
const host = getHostname(result.url);
return (scoreText(result.title, tokens) * 6 +
scoreText(result.snippet, tokens) * 3 +
scoreDomain(host) +
Math.min(3, Math.floor((result.snippet.length || result.title.length) / 80)));
}
function rankSearchResults(results: SearchResult[], query: string, limit: number): SearchResult[] {
const scored = uniqueByUrl(results)
.filter((item) => !isBlockedUrl(item.url))
.map((item) => ({
item,
score: scoreSearchResult(item, query),
}))
.sort((a, b) => b.score - a.score);
const out: SearchResult[] = [];
const seenDomains = new Map<string, number>();
for (const entry of scored) {
const host = getRootDomain(getHostname(entry.item.url));
const count = seenDomains.get(host) ?? 0;
if (count >= 2) continue;
seenDomains.set(host, count + 1);
out.push(entry.item);
if (out.length >= limit) break;
}
return out;
}
function pickTopPages(pages: CrawledPage[], limit: number): CrawledPage[] {
const ranked = [...pages].sort((a, b) => b.relevanceScore - a.relevanceScore);
const out: CrawledPage[] = [];
const seenDomains = new Map<string, number>();
for (const page of ranked) {
const host = getRootDomain(getHostname(page.url));
const count = seenDomains.get(host) ?? 0;
if (count >= 2) continue;
seenDomains.set(host, count + 1);
out.push(page);
if (out.length >= limit) break;
}
return out;
}
// ─── Link scoring ────────────────────────────────────────────────────────────
const RE_DOCS_KEYWORD = /(docs|guide|help|support|manual|paper|study|report|blog|news|research|about|faq)/i;
function buildLinkCandidates(html: string, baseUrl: string, queryTokens: string[], maxLinks: number): AnchorCandidate[] {
return extractAnchors(html, baseUrl, maxLinks)
.map((link) => {
const host = getHostname(link.url);
let score = link.score;
score += scoreText(link.text, queryTokens) * 2;
score += scoreText(link.url, queryTokens);
if (RE_DOCS_KEYWORD.test(link.text + " " + link.url)) {
score += 2;
}
return { ...link, score: score + scoreDomain(host) };
})
.sort((a, b) => b.score - a.score);
}
// ─── Page crawling ───────────────────────────────────────────────────────────
function buildExcerpt(text: string, maxChars: number): string {
return trimString(normalizeWhitespace(text), maxChars);
}
function summarizePage(pageText: string, queryTokens: string[], maxSentences = 3): string {
const snippets = extractUsefulSentences(pageText, queryTokens, maxSentences);
return snippets.length > 0 ? snippets.join(" ") : trimString(pageText, 700);
}
async function crawlUrl(url: string, depth: number, queryTokens: string[], maxChars: number, maxLinkCount: number): Promise<{ page: CrawledPage; links: AnchorCandidate[] }> {
const { text: html } = await fetchText(url);
const title = extractTitle(html) || getHostname(url) || url;
const description = extractMetaDescription(html);
const canonical = extractCanonical(html);
const content = trimString(stripTags(html), maxChars);
const finalUrl = canonical && !isBlockedUrl(canonical)
? normalizeUrl(resolveUrl(canonical, url) ?? url)
: normalizeUrl(url);
const links = buildLinkCandidates(html, finalUrl, queryTokens, maxLinkCount);
const sourceHost = getHostname(finalUrl);
const sourceType = sourceTypeForHost(sourceHost);
const excerpt = buildExcerpt(description || summarizePage(content, queryTokens), 700);
const wordCount = content ? content.split(RE_WORD_SPLIT).filter(Boolean).length : 0;
const relevanceScore = scoreText(`${title}\n${description}\n${content}`, queryTokens) * 2 +
scoreDomain(sourceHost) +
Math.min(4, Math.floor(wordCount / 250));
return {
page: {
url: finalUrl,
title,
description,
content,
wordCount,
links: links.map((link) => link.url),
excerpt,
relevanceScore,
depth,
sourceType,
},
links,
};
}
// ─── LLM helpers ─────────────────────────────────────────────────────────────
async function getLoadedModel(modelId?: string): Promise<LoadedModel | null> {
const loaded = await getClient().llm.listLoaded();
if (loaded.length === 0) return null;
if (modelId) {
const match = loaded.find((m) => m.identifier === modelId);
if (!match) return null;
return { identifier: match.identifier ?? modelId, model: match };
}
const first = loaded[0];
return first?.identifier ? { identifier: first.identifier, model: first } : null;
}
async function completeWithModel(modelId: string | undefined, prompt: string, maxTokens: number, temperature: number): Promise<{ text: string | null; modelUsed: string | null }> {
const loaded = await getLoadedModel(modelId).catch(() => null);
if (!loaded) return { text: null, modelUsed: null };
try {
const prediction = await loaded.model.complete(prompt, { maxTokens, temperature });
let text = "";
for await (const chunk of prediction) {
text += chunk.content ?? "";
}
return { text: text.trim(), modelUsed: loaded.identifier };
} catch {
return { text: null, modelUsed: null };
}
}
// ─── Prompt builders ─────────────────────────────────────────────────────────
function buildPlannerPrompt(query: string, focus: string | undefined, pages: CrawledPage[], maxQueries: number): string {
const preview = pages
.slice(0, 6)
.map((page, index) => [
`Source ${index + 1}`,
`Title: ${page.title}`,
`URL: ${page.url}`,
`Type: ${page.sourceType}`,
`Snippet: ${trimString(page.excerpt || page.description || page.content, 280)}`,
].join("\n"))
.join("\n\n");
return [
"You are a local deep-research planner.",
`Task: ${query}`,
focus ? `Focus: ${focus}` : "",
"You must return ONLY valid JSON with this shape:",
`{ "followUpQueries": string[], "gaps": string[], "contradictions": string[] }`,
`Limit followUpQueries to at most ${maxQueries}.`,
"Prefer queries that test evidence, fill missing details, or inspect authoritative sources.",
"Avoid social media, video sites, and shallow listicle search terms.",
"Evidence packet:",
preview || "(no sources yet)",
]
.filter(Boolean)
.join("\n\n");
}
function buildSynthesisPrompt(query: string, focus: string | undefined, pages: CrawledPage[], searchResults: SearchResult[]): string {
const sources = pages
.slice(0, 10)
.map((page, index) => [
`Source ${index + 1}`,
`Title: ${page.title}`,
`URL: ${page.url}`,
`Type: ${page.sourceType}`,
`Excerpt: ${page.excerpt}`,
`Relevant sentences: ${trimString(page.content, 1800)}`,
].join("\n"))
.join("\n\n");
const trail = searchResults
.slice(0, 12)
.map((result, index) => `${index + 1}. ${result.title}\n ${result.url}\n ${trimString(result.snippet, 220)}`)
.join("\n");
return [
"You are a careful deep-research analyst.",
`Question: ${query}`,
focus ? `Focus: ${focus}` : "",
"Write a concise markdown report using only the evidence packet below.",
"Be explicit about uncertainty and conflicts.",
"Every important claim should be tied to one or more source numbers like [1] or [1][3].",
"Return sections in this order:",
"# Answer",
"# Key findings",
"# Conflicts / caveats",
"# Sources",
"Evidence packet:",
sources || "(no crawled sources)",
"Search trail:",
trail || "(no search trail)",
]
.filter(Boolean)
.join("\n\n");
}
function buildFallbackReport(query: string, focus: string | undefined, pages: CrawledPage[], searchResults: SearchResult[], gaps: string[] = [], contradictions: string[] = []): string {
const topPages = pages.slice(0, 10);
const sourceLines = topPages.length
? topPages
.map((page, index) => `${index + 1}. ${page.title} — ${page.url}\n ${trimString(page.excerpt || page.description || page.content, 220)}`)
.join("\n")
: "No pages could be crawled.";
const searchTrail = searchResults
.slice(0, 12)
.map((r, index) => `${index + 1}. ${r.title} — ${r.url}`);
const keyFindings = topPages
.slice(0, 6)
.map((page, index) => `- [${index + 1}] ${page.title}: ${trimString(page.excerpt || page.description || page.content, 180)}`);
return [
"# Answer",
`I gathered ${topPages.length} crawled sources${focus ? ` for the focus area "${focus}"` : ""}. This is a best-effort local synthesis for: ${query}.`,
"",
"# Key findings",
...(keyFindings.length ? keyFindings : ["- No strong findings extracted yet."]),
"",
"# Conflicts / caveats",
...(contradictions.length ? contradictions.map((item) => `- ${item}`) : ["- No clear contradictions were detected automatically."]),
...(gaps.length ? gaps.map((item) => `- ${item}`) : ["- The search may need more targeted follow-up queries."]),
"",
"# Sources",
sourceLines,
"",
"# Search trail",
...(searchTrail.length ? searchTrail : ["No search results were returned."]),
].join("\n");
}
// ─── JSON parsing ────────────────────────────────────────────────────────────
interface PlannerOutput {
followUpQueries?: string[];
gaps?: string[];
contradictions?: string[];
}
function parseJsonLoose(text: string): PlannerOutput | null {
const trimmed = text.trim();
const fenced = trimmed.match(/```json\s*([\s\S]*?)```/i);
const candidate = fenced ? fenced[1].trim() : trimmed;
try {
return JSON.parse(candidate);
} catch {
const firstBrace = candidate.indexOf("{");
const lastBrace = candidate.lastIndexOf("}");
if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
try {
return JSON.parse(candidate.slice(firstBrace, lastBrace + 1));
} catch { /* ignore */ }
}
return null;
}
}
// ─── Heuristic follow-up queries ─────────────────────────────────────────────
function makeHeuristicFollowUps(query: string, focus: string | undefined, pages: CrawledPage[], round: number): string[] {
const followUps = new Set<string>();
const tokens = tokenizeQuery(query);
const topPages = pages.slice(0, 4);
for (const page of topPages) {
const keyBits = page.title
.split(RE_NON_ALPHANUM)
.map((part) => part.trim())
.filter((part) => part.length >= 4)
.slice(0, 4)
.join(" ");
if (keyBits) followUps.add(`${query} ${keyBits}`.trim());
const host = getHostname(page.url);
if (host && !isBlockedHost(host)) followUps.add(`${query} site:${host}`);
}
if (focus) followUps.add(`${query} ${focus}`);
if (tokens.length > 0) {
followUps.add(`${query} official`);
followUps.add(`${query} documentation`);
followUps.add(`${query} key facts`);
}
if (round > 1) {
followUps.add(`${query} controversy`);
followUps.add(`${query} analysis`);
followUps.add(`${query} evidence`);
}
return Array.from(followUps).slice(0, 8);
}
// ─── Main research workflow ──────────────────────────────────────────────────
async function runDeepResearch(params: DeepResearchParams, ctx: ToolCallContext): Promise<ResearchResult> {
const query = normalizeWhitespace(params.query);
const focus = params.focus?.trim() || undefined;
const maxRounds = clampInt(params.maxRounds, DEFAULTS.maxRounds, 1, 4);
const maxSearchesPerRound = clampInt(params.maxSearchesPerRound, DEFAULTS.maxSearchesPerRound, 1, 8);
const maxResultsPerSearch = clampInt(params.maxResultsPerSearch, DEFAULTS.maxResultsPerSearch, 1, 10);
const maxPages = clampInt(params.maxPages, DEFAULTS.maxPages, 1, 24);
const maxDepth = clampInt(params.maxDepth, DEFAULTS.maxDepth, 1, 3);
const maxCharsPerPage = clampInt(params.maxCharsPerPage, DEFAULTS.maxCharsPerPage, 3000, 40000);
const maxTokens = clampInt(params.maxTokens, DEFAULTS.maxTokens, 64, 1500);
const temperature = clampFloat(params.temperature, DEFAULTS.temperature, 0, 2);
const queryTokens = tokenizeQuery(`${query} ${focus ?? ""}`);
const issuedQueries = new Set<string>();
const visited = new Set<string>();
const allSearchResults: SearchResult[] = [];
const allPages: CrawledPage[] = [];
const queue: QueueEntry[] = [];
let frontier = buildBaseSearchQueries(query, focus).slice(0, maxSearchesPerRound);
ctx.status(`Planning research for ${query}`);
for (let round = 1; round <= maxRounds; round += 1) {
if (ctx.signal.aborted) throw new Error("Research was aborted");
const roundQueries = frontier
.map((item) => item.trim())
.filter((item) => item && !issuedQueries.has(item.toLowerCase()));
roundQueries.forEach((item) => issuedQueries.add(item.toLowerCase()));
if (roundQueries.length === 0) break;
ctx.status(`Round ${round}/${maxRounds}: searching ${roundQueries.length} queries`);
for (const searchQuery of roundQueries.slice(0, maxSearchesPerRound)) {
if (ctx.signal.aborted) throw new Error("Research was aborted");
ctx.status(`Searching: ${searchQuery}`);
let results = await searchDuckDuckGo(searchQuery, maxResultsPerSearch);
results = rankSearchResults(results, `${query} ${focus ?? ""}`, maxResultsPerSearch);
allSearchResults.push(...results);
for (const result of results) {
queue.push({ url: normalizeUrl(result.url), depth: 0, score: scoreSearchResult(result, query) });
}
}
queue.sort((a, b) => b.score - a.score);
const nextQueue: QueueEntry[] = [];
while (queue.length > 0) {
if (allPages.length >= maxPages) break;
if (ctx.signal.aborted) throw new Error("Research was aborted");
const current = queue.shift()!;
const url = normalizeUrl(current.url);
if (!url || visited.has(url) || isBlockedUrl(url)) continue;
if (current.depth > maxDepth) continue;
visited.add(url);
ctx.status(`Crawling ${allPages.length + 1}/${maxPages}: ${getHostname(url) || url}`);
try {
const { page, links } = await crawlUrl(url, current.depth, queryTokens, maxCharsPerPage, DEFAULTS.maxLinksPerPage);
page.relevanceScore =
scoreText(`${page.title}\n${page.description}\n${page.content}`, queryTokens) * 2 +
scoreDomain(getHostname(page.url)) +
Math.min(4, Math.floor(page.wordCount / 250));
allPages.push(page);
if (current.depth < maxDepth) {
for (const link of links) {
if (visited.has(link.url) || isBlockedUrl(link.url)) continue;
nextQueue.push({ url: link.url, depth: current.depth + 1, score: link.score });
}
}
} catch (error) {
ctx.warn(`Could not crawl ${url}: ${error instanceof Error ? error.message : String(error)}`);
}
}
queue.push(...nextQueue.sort((a, b) => b.score - a.score));
queue.sort((a, b) => b.score - a.score);
const rankedPages = pickTopPages(allPages, Math.min(maxPages, 10));
// Planner: attempt LLM-generated follow-ups
let followUps: string[] = [];
const modelInfo = await getLoadedModel(params.modelId).catch(() => null);
if (modelInfo) {
const plannerPrompt = buildPlannerPrompt(query, focus, rankedPages, 8);
const planner = await completeWithModel(params.modelId, plannerPrompt, 320, 0.15);
const parsed = planner.text ? parseJsonLoose(planner.text) : null;
if (parsed && Array.isArray(parsed.followUpQueries)) {
followUps = parsed.followUpQueries
.map((item) => String(item).trim())
.filter(Boolean);
}
}
if (followUps.length === 0) {
followUps = makeHeuristicFollowUps(query, focus, rankedPages, round);
}
frontier = uniqueStrings(followUps)
.filter((item) => !issuedQueries.has(item.toLowerCase()))
.slice(0, maxSearchesPerRound);
if (frontier.length === 0) break;
}
// ── Synthesis ──────────────────────────────────────────────────────────────
const finalPages = pickTopPages(allPages, maxPages);
const uniqueSearchResults = rankSearchResults(allSearchResults, `${query} ${focus ?? ""}`, Math.min(allSearchResults.length, maxPages * 2));
let contradictions: string[] = [];
let gaps: string[] = [];
let reportMarkdown = "";
let modelUsed: string | null = null;
// Synthesis step
const synthesisPrompt = buildSynthesisPrompt(query, focus, finalPages, uniqueSearchResults);
const synthesis = await completeWithModel(params.modelId, synthesisPrompt, maxTokens, temperature);
if (synthesis.text) {
reportMarkdown = synthesis.text;
modelUsed = synthesis.modelUsed;
} else {
reportMarkdown = buildFallbackReport(query, focus, finalPages, uniqueSearchResults);
}
// Audit step: detect contradictions and gaps
const auditEvidence = finalPages
.slice(0, 8)
.map((page) => `${page.title}: ${trimString(page.excerpt || page.description || page.content, 240)}`)
.join("\n");
const auditPrompt = [
"You are auditing a research packet for missing evidence, contradictions, and reliability concerns.",
`Question: ${query}`,
focus ? `Focus: ${focus}` : "",
"Evidence:",
auditEvidence || "(none)",
'Return ONLY valid JSON with keys "contradictions" and "gaps", both arrays of concise strings.',
]
.filter(Boolean)
.join("\n\n");
const audit = await completeWithModel(params.modelId, auditPrompt, 220, 0.1);
const parsedAudit = audit.text ? parseJsonLoose(audit.text) : null;
if (parsedAudit?.contradictions && Array.isArray(parsedAudit.contradictions)) {
contradictions = parsedAudit.contradictions
.map((item) => String(item).trim())
.filter(Boolean)
.slice(0, 8);
}
if (parsedAudit?.gaps && Array.isArray(parsedAudit.gaps)) {
gaps = parsedAudit.gaps
.map((item) => String(item).trim())
.filter(Boolean)
.slice(0, 8);
}
// Fallback if synthesis failed entirely
if (!reportMarkdown || reportMarkdown === buildFallbackReport(query, focus, finalPages, uniqueSearchResults)) {
reportMarkdown = buildFallbackReport(query, focus, finalPages, uniqueSearchResults, gaps, contradictions);
}
if (audit.modelUsed && !modelUsed) modelUsed = audit.modelUsed;
const sources = finalPages.map((page, index) => ({
rank: index + 1,
title: page.title,
url: page.url,
domain: getHostname(page.url),
sourceType: page.sourceType,
excerpt: page.excerpt,
wordCount: page.wordCount,
relevanceScore: page.relevanceScore,
depth: page.depth,
}));
return {
query,
focus: focus ?? null,
modelUsed,
rounds: maxRounds,
searchQueries: Array.from(issuedQueries),
searchResults: uniqueSearchResults,
sources,
contradictions,
gaps,
reportMarkdown,
};
}
// ─── Tool definition ─────────────────────────────────────────────────────────
const deepResearchTool = tool({
name: "deepResearch",
description: "Run a local autonomous deep-research workflow: it plans searches, crawls pages recursively, filters social sites, checks for gaps and contradictions, and returns a synthesized markdown report. No paid APIs are used.",
parameters: {
query: z.string().min(1),
focus: z.string().optional(),
maxRounds: z.number().int().min(1).max(4).default(DEFAULTS.maxRounds),
maxSearchesPerRound: z.number().int().min(1).max(8).default(DEFAULTS.maxSearchesPerRound),
maxResultsPerSearch: z.number().int().min(1).max(10).default(DEFAULTS.maxResultsPerSearch),
maxPages: z.number().int().min(1).max(24).default(DEFAULTS.maxPages),
maxDepth: z.number().int().min(1).max(3).default(DEFAULTS.maxDepth),
maxCharsPerPage: z.number().int().min(3000).max(40000).default(DEFAULTS.maxCharsPerPage),
modelId: z.string().optional(),
maxTokens: z.number().int().min(64).max(1500).default(DEFAULTS.maxTokens),
temperature: z.number().min(0).max(2).default(DEFAULTS.temperature),
},
implementation: async (params: DeepResearchParams, ctx: ToolCallContext) => {
return await runDeepResearch(params, ctx);
},
});
interface PluginContext {
withToolsProvider(provider: () => Promise<any>): void;
}
async function main(pluginContext: PluginContext): Promise<void> {
pluginContext.withToolsProvider(async () => [deepResearchTool]);
}
export { main };
src / index.ts
import { LMStudioClient, tool, type ToolCallContext } from "@lmstudio/sdk";
import { z } from "zod";
// ─── Types ───────────────────────────────────────────────────────────────────
interface SearchResult {
title: string;
url: string;
displayedUrl: string;
snippet: string;
source: string;
}
interface CrawledPage {
url: string;
title: string;
description: string;
content: string;
wordCount: number;
links: string[];
excerpt: string;
relevanceScore: number;
depth: number;
sourceType: string;
}
interface ResearchResult {
query: string;
focus: string | null;
modelUsed: string | null;
rounds: number;
searchQueries: string[];
searchResults: SearchResult[];
sources: Array<{
rank: number;
title: string;
url: string;
domain: string;
sourceType: string;
excerpt: string;
wordCount: number;
relevanceScore: number;
depth: number;
}>;
contradictions: string[];
gaps: string[];
reportMarkdown: string;
}
interface DeepResearchParams {
query: string;
focus?: string;
maxRounds: number;
maxSearchesPerRound: number;
maxResultsPerSearch: number;
maxPages: number;
maxDepth: number;
maxCharsPerPage: number;
modelId?: string;
maxTokens: number;
temperature: number;
}
interface LoadedModel {
identifier: string;
model: Awaited<ReturnType<LMStudioClient["llm"]["listLoaded"]>>[number];
}
interface AnchorCandidate {
url: string;
text: string;
score: number;
}
interface QueueEntry {
url: string;
depth: number;
score: number;
}
// ─── Constants ───────────────────────────────────────────────────────────────
const DEFAULTS = {
timeoutMs: 15000,
maxRounds: 2,
maxSearchesPerRound: 4,
maxResultsPerSearch: 6,
maxPages: 12,
maxDepth: 2,
maxCharsPerPage: 16000,
maxTokens: 650,
temperature: 0.2,
maxLinksPerPage: 12,
};
const USER_AGENT = "Mozilla/5.0 (compatible; LMStudioDeepResearch/2.0; +https://lmstudio.ai)";
const BLOCKED_HOSTS = new Set([
"facebook.com", "instagram.com", "x.com", "twitter.com", "tiktok.com",
"reddit.com", "pinterest.com", "linkedin.com", "snapchat.com",
"discord.com", "discord.gg", "tumblr.com", "quora.com", "fandom.com",
"youtube.com", "youtu.be", "twitch.tv", "onlyfans.com",
]);
const BLOCKED_URL_PARTS = [
"/share", "/sharer", "/intent/", "/status/", "/posts/", "/reels/",
"/shorts/", "/video/", "/watch?", "/watch/", "/tiktok.com/", "/redd.it/",
];
const BOILERPLATE_LINK_TEXT = new Set([
"home", "menu", "log in", "login", "sign in", "sign up", "subscribe",
"newsletter", "privacy", "terms", "cookies", "cookie policy",
"accept cookies", "contact", "about us", "about", "sitemap", "search",
"share", "follow", "read more", "learn more",
]);
const STOP_WORDS = new Set([
"the", "and", "for", "with", "that", "this", "from", "into", "about",
"what", "when", "where", "which", "who", "how", "why", "can", "could",
"would", "should", "please", "need", "want", "best", "latest", "current",
"new", "old", "vs", "via", "of", "to", "in", "on", "by", "as", "is",
"are", "be", "it", "or", "an", "a",
]);
// Pre-compiled regexes
const RE_HTML_TAGS = /<[^>]+>/g;
const RE_SCRIPT = /<script[\s\S]*?<\/script>/gi;
const RE_STYLE = /<style[\s\S]*?<\/style>/gi;
const RE_NOSCRIPT = /<noscript[\s\S]*?<\/noscript>/gi;
const RE_SVG = /<svg[\s\S]*?<\/svg>/gi;
const RE_IFRAME = /<iframe[\s\S]*?<\/iframe>/gi;
const RE_NAV = /<nav[\s\S]*?<\/nav>/gi;
const RE_FOOTER = /<footer[\s\S]*?<\/footer>/gi;
const RE_HEADER = /<header[\s\S]*?<\/header>/gi;
const RE_FORM = /<form[\s\S]*?<\/form>/gi;
const RE_ASIDE = /<aside[\s\S]*?<\/aside>/gi;
const RE_BR = /<br\s*\/?>/gi;
const RE_BLOCK_END = /<\/(p|div|li|section|article|tr|table|blockquote|h[1-6])>/gi;
const RE_LI = /<li\b[^>]*>/gi;
const RE_H = /<h[1-6]\b[^>]*>/gi;
const RE_TITLE = /<title[^>]*>([\s\S]*?)<\/title>/i;
const RE_META_DESC = /<meta[^>]+name=["']description["'][^>]*content=["']([^"']+)["'][^>]*>/i;
const RE_META_OG_DESC = /<meta[^>]+property=["']og:description["'][^>]*content=["']([^"']+)["'][^>]*>/i;
const RE_META_OG_TITLE = /<meta[^>]+property=["']og:title["'][^>]*content=["']([^"']+)["'][^>]*>/i;
const RE_CANONICAL = /<link[^>]+rel=["']canonical["'][^>]*href=["']([^"']+)["'][^>]*>/i;
const RE_ANCHOR = /<a\b[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi;
const RE_NOFOLLOW_ANCHOR = /<a[^>]*rel="nofollow"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi;
const RE_BLOCKED_COOKIE = /(cookie|privacy|terms|subscribe|login)/i;
const RE_NON_ALPHANUM = /[^a-z0-9]+/i;
const RE_WHITESPACE = /\s+/g;
const RE_NEWLINES = /\n{3,}/g;
const RE_WORD_SPLIT = /\s+/;
// ─── Client singleton ────────────────────────────────────────────────────────
let client: LMStudioClient | null = null;
function getClient(): LMStudioClient {
if (!client) client = new LMStudioClient();
return client;
}
// ─── Clamping helpers ────────────────────────────────────────────────────────
function clampInt(value: unknown, fallback: number, min: number, max: number): number {
if (typeof value === "number" && Number.isFinite(value)) {
return clampRange(Math.trunc(value), min, max);
}
if (typeof value === "string" && /^-?\d+$/.test(value.trim())) {
return clampRange(Math.trunc(Number(value)), min, max);
}
return clampRange(fallback, min, max);
}
function clampFloat(value: unknown, fallback: number, min: number, max: number): number {
if (typeof value === "number" && Number.isFinite(value)) {
return clampRange(value, min, max);
}
if (typeof value === "string" && /^-?\d+(?:\.\d+)?$/.test(value.trim())) {
return clampRange(Number(value), min, max);
}
return clampRange(fallback, min, max);
}
function clampRange(value: number, min: number, max: number): number {
return value < min ? min : value > max ? max : value;
}
// ─── String utilities ────────────────────────────────────────────────────────
function normalizeWhitespace(value: string): string {
return value.replace(RE_WHITESPACE, " ").trim();
}
function trimString(value: string, maxChars: number): string {
const normalized = value.trim();
if (normalized.length <= maxChars) return normalized;
return `${normalized.slice(0, maxChars - 1).trimEnd()}…`;
}
function decodeHtmlEntities(value: string): string {
return value
.replace(/ /gi, " ")
.replace(/&/gi, "&")
.replace(/"/gi, '"')
.replace(/'/gi, "'")
.replace(/'/gi, "'")
.replace(/</gi, "<")
.replace(/>/gi, ">")
.replace(/'/gi, "'")
.replace(///gi, "/")
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)))
.replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCharCode(parseInt(n, 16)));
}
// ─── URL utilities ───────────────────────────────────────────────────────────
function getHostname(url: string): string {
try {
return new URL(url).hostname.replace(/^www\./i, "").toLowerCase();
} catch {
return "";
}
}
function getRootDomain(hostname: string): string {
const parts = hostname.split(".").filter(Boolean);
return parts.length <= 2 ? hostname : parts.slice(-2).join(".");
}
// URL normalization cache
const urlCache = new Map<string, string>();
function normalizeUrl(url: string): string {
const cached = urlCache.get(url);
if (cached) return cached;
try {
const parsed = new URL(url);
parsed.hash = "";
if (parsed.pathname !== "/" && parsed.pathname.endsWith("/")) {
parsed.pathname = parsed.pathname.replace(/\/+$/, "");
if (!parsed.pathname) parsed.pathname = "/";
}
const result = parsed.toString();
urlCache.set(url, result);
return result;
} catch {
const result = normalizeWhitespace(url);
urlCache.set(url, result);
return result;
}
}
function resolveUrl(raw: string, baseUrl: string): string | null {
const trimmed = raw.trim();
if (!trimmed) return null;
if (/^(javascript|mailto|tel|data):/i.test(trimmed)) return null;
try {
return new URL(trimmed, baseUrl).toString();
} catch {
return null;
}
}
// ─── Blocking checks ─────────────────────────────────────────────────────────
function isBlockedHost(hostname: string): boolean {
const lower = hostname.toLowerCase();
for (const blocked of BLOCKED_HOSTS) {
if (lower === blocked || lower.endsWith(`.${blocked}`)) return true;
}
return false;
}
function isBlockedUrl(url: string): boolean {
const lower = url.toLowerCase();
if (isBlockedHost(getHostname(url))) return true;
for (const part of BLOCKED_URL_PARTS) {
if (lower.includes(part)) return true;
}
return false;
}
// ─── Scoring ─────────────────────────────────────────────────────────────────
function scoreDomain(host: string): number {
if (!host) return 0;
const lower = host.toLowerCase();
if (lower.endsWith(".gov") || lower.endsWith(".edu") || lower.endsWith(".ac.uk")) return 8;
if (lower.includes("nih.gov") || lower.includes("who.int") || lower.includes("arxiv.org")) return 7;
if (lower.includes("wikipedia.org")) return 5;
if (lower.includes("docs.") || lower.includes("developer.")) return 4;
return 1;
}
function sourceTypeForHost(host: string): string {
const lower = host.toLowerCase();
if (isBlockedHost(lower)) return "blocked";
if (lower.endsWith(".gov") || lower.endsWith(".edu") || lower.includes("nih.gov")) return "authoritative";
if (lower.includes("wikipedia.org")) return "reference";
return "web";
}
function tokenizeQuery(query: string): string[] {
const tokens = query.toLowerCase().split(RE_NON_ALPHANUM);
const seen = new Set<string>();
const result: string[] = [];
for (const token of tokens) {
if (token.length >= 3 && !STOP_WORDS.has(token) && !seen.has(token)) {
seen.add(token);
result.push(token);
}
}
return result;
}
function countOccurrences(haystack: string, needle: string): number {
if (!needle) return 0;
let count = 0, start = 0;
while (true) {
const idx = haystack.indexOf(needle, start);
if (idx === -1) break;
count++;
start = idx + needle.length;
}
return count;
}
function scoreText(text: string, tokens: string[]): number {
const normalized = text.toLowerCase();
let score = 0;
for (const token of tokens) {
score += countOccurrences(normalized, token);
}
return score;
}
// ─── HTML extraction ─────────────────────────────────────────────────────────
function stripTags(html: string): string {
let t = html;
t = t.replace(RE_SCRIPT, " ");
t = t.replace(RE_STYLE, " ");
t = t.replace(RE_NOSCRIPT, " ");
t = t.replace(RE_SVG, " ");
t = t.replace(RE_IFRAME, " ");
t = t.replace(RE_NAV, " ");
t = t.replace(RE_FOOTER, " ");
t = t.replace(RE_HEADER, " ");
t = t.replace(RE_FORM, " ");
t = t.replace(RE_ASIDE, " ");
t = t.replace(RE_BR, "\n");
t = t.replace(RE_BLOCK_END, "\n");
t = t.replace(RE_LI, "• ");
t = t.replace(RE_H, "\n");
t = t.replace(RE_HTML_TAGS, " ");
t = decodeHtmlEntities(t);
const lines = t
.replace(/\r/g, "")
.replace(/\u00a0/g, " ")
.split("\n")
.map((l) => normalizeWhitespace(l))
.filter((l) => l.length > 0 && !isBoilerplateLine(l));
const seen = new Set<string>();
return lines
.filter((l) => {
const k = l.toLowerCase();
if (seen.has(k)) return false;
seen.add(k);
return true;
})
.join("\n")
.replace(RE_NEWLINES, "\n\n")
.trim();
}
function isBoilerplateLine(line: string): boolean {
const lower = line.toLowerCase();
if (!lower || lower.length <= 2) return true;
if (BOILERPLATE_LINK_TEXT.has(lower)) return true;
for (const part of BOILERPLATE_LINK_TEXT) {
if (lower === ` ${part}` || lower.startsWith(`${part} `)) return true;
}
if (RE_BLOCKED_COOKIE.test(lower) && lower.length < 90) return true;
return false;
}
function extractMetaDescription(html: string): string {
const match = html.match(RE_META_DESC) ?? html.match(RE_META_OG_DESC);
return match ? normalizeWhitespace(stripTags(match[1])) : "";
}
function extractTitle(html: string): string {
const titleMatch = html.match(RE_TITLE);
if (titleMatch) return normalizeWhitespace(stripTags(titleMatch[1]));
const ogTitle = html.match(RE_META_OG_TITLE);
return ogTitle ? normalizeWhitespace(stripTags(ogTitle[1])) : "";
}
function extractCanonical(html: string): string {
const match = html.match(RE_CANONICAL);
return match ? match[1].trim() : "";
}
function extractAnchors(html: string, baseUrl: string, limit: number): AnchorCandidate[] {
const anchors: AnchorCandidate[] = [];
const seen = new Set<string>();
const anchorRegex = RE_ANCHOR;
let match: RegExpExecArray | null;
while ((match = anchorRegex.exec(html)) !== null) {
const resolved = resolveUrl(match[1], baseUrl);
if (!resolved) continue;
const normalized = normalizeUrl(resolved);
if (seen.has(normalized) || !/^https?:/i.test(normalized) || isBlockedUrl(normalized)) continue;
const text = normalizeWhitespace(stripTags(match[2]));
const host = getHostname(normalized);
let score = 0;
if (!text || BOILERPLATE_LINK_TEXT.has(text.toLowerCase())) score -= 3;
score += scoreDomain(host);
if (getRootDomain(host) === getRootDomain(getHostname(baseUrl))) score += 4;
anchors.push({ url: normalized, text, score });
seen.add(normalized);
if (anchors.length >= limit * 2) break;
}
return anchors
.sort((a, b) => b.score - a.score)
.slice(0, limit);
}
// ─── Sentence extraction ─────────────────────────────────────────────────────
function extractUsefulSentences(text: string, tokens: string[], maxSentences = 4): string[] {
const sentences = normalizeWhitespace(text)
.split(/(?<=[.!?])\s+/)
.map((part) => part.trim())
.filter(Boolean);
const scored = sentences
.map((sentence) => ({
sentence,
score: scoreText(sentence, tokens) + Math.min(3, sentence.length / 120),
}))
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, maxSentences);
return scored.map((item) => trimString(item.sentence, 300));
}
// ─── Fetching ────────────────────────────────────────────────────────────────
async function fetchText(url: string, timeoutMs = DEFAULTS.timeoutMs): Promise<{ text: string; contentType: string }> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(new Error("Request timed out")), timeoutMs);
try {
const response = await fetch(url, {
signal: controller.signal,
redirect: "follow",
headers: {
"user-agent": USER_AGENT,
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
},
});
if (!response.ok) throw new Error(`Request failed with status ${response.status} ${response.statusText}`);
const contentType = (response.headers.get("content-type") ?? "").toLowerCase();
if (!contentType.includes("text/html") && !contentType.includes("application/xhtml") && !contentType.includes("text/plain")) {
throw new Error(`Unsupported content type: ${contentType || "unknown"}`);
}
return { text: await response.text(), contentType };
} finally {
clearTimeout(timeout);
}
}
// ─── DuckDuckGo search ───────────────────────────────────────────────────────
function decodeDuckDuckGoUrl(href: string): string {
try {
const parsed = new URL(href, "https://duckduckgo.com");
const uddg = parsed.searchParams.get("uddg");
if (uddg) return decodeURIComponent(uddg);
return parsed.toString();
} catch {
return href;
}
}
function parseDuckDuckGoResults(html: string, isLite: boolean): SearchResult[] {
const results: SearchResult[] = [];
if (isLite) {
const anchorRegex = RE_NOFOLLOW_ANCHOR;
let match: RegExpExecArray | null;
while ((match = anchorRegex.exec(html)) !== null) {
const rawUrl = decodeDuckDuckGoUrl(match[1]);
const title = normalizeWhitespace(stripTags(match[2]));
if (!rawUrl || !title || isBlockedUrl(rawUrl)) continue;
let displayedUrl = rawUrl;
try {
const parsed = new URL(rawUrl);
displayedUrl = `${parsed.hostname.replace(/^www\./i, "")}${parsed.pathname}`;
} catch { /* ignore */ }
results.push({ title, url: rawUrl, displayedUrl, snippet: "", source: "duckduckgo-lite" });
}
} else {
const blocks = html.split(/<div class="result\b/gi);
for (const block of blocks.slice(1)) {
const linkMatch = block.match(/<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i) ??
block.match(/<a[^>]*href="([^"]+)"[^>]*class="[^"]*result__a[^"]*"[^>]*>([\s\S]*?)<\/a>/i);
if (!linkMatch) continue;
const rawUrl = decodeDuckDuckGoUrl(linkMatch[1]);
const title = normalizeWhitespace(stripTags(linkMatch[2]));
if (!rawUrl || !title || isBlockedUrl(rawUrl)) continue;
const snippetMatch = block.match(/class="[^"]*result__snippet[^"]*"[^>]*>([\s\S]*?)<\/(?:a|div|span)>/i) ??
block.match(/class="[^"]*result__snippet[^"]*"[^>]*>([\s\S]*?)<\/div>/i);
const snippet = snippetMatch ? normalizeWhitespace(stripTags(snippetMatch[1])) : "";
let displayedUrl = rawUrl;
try {
const parsed = new URL(rawUrl);
displayedUrl = `${parsed.hostname.replace(/^www\./i, "")}${parsed.pathname}`;
} catch { /* ignore */ }
results.push({ title, url: rawUrl, displayedUrl, snippet, source: "duckduckgo" });
}
}
return results;
}
async function searchDuckDuckGo(query: string, limit: number): Promise<SearchResult[]> {
const urls = [
`https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`,
`https://lite.duckduckgo.com/lite/?q=${encodeURIComponent(query)}`,
];
for (let i = 0; i < urls.length; i++) {
try {
const html = (await fetchText(urls[i])).text;
const parsed = parseDuckDuckGoResults(html, i === 1);
if (parsed.length > 0) return parsed.slice(0, limit);
} catch { /* fall through */ }
}
return [];
}
// ─── Deduplication & ranking ─────────────────────────────────────────────────
function uniqueByUrl<T extends { url: string }>(items: T[]): T[] {
const seen = new Set<string>();
const out: T[] = [];
for (const item of items) {
const key = normalizeUrl(item.url);
if (seen.has(key)) continue;
seen.add(key);
out.push(item);
}
return out;
}
function uniqueStrings(items: string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const item of items) {
const trimmed = item.trim();
if (trimmed && !seen.has(trimmed)) {
seen.add(trimmed);
out.push(trimmed);
}
}
return out;
}
function buildBaseSearchQueries(query: string, focus: string | undefined): string[] {
const cleanQuery = normalizeWhitespace(query);
const tokens = tokenizeQuery(cleanQuery);
const rootPhrase = tokens.slice(0, Math.min(4, tokens.length)).join(" ");
const variations: string[] = [
cleanQuery,
`${cleanQuery} official`,
`${cleanQuery} documentation`,
`${cleanQuery} research`,
`${cleanQuery} analysis`,
`${cleanQuery} review`,
`${cleanQuery} key facts`,
];
if (focus?.trim()) variations.unshift(`${cleanQuery} ${focus.trim()}`);
if (rootPhrase && rootPhrase !== cleanQuery.toLowerCase()) variations.push(rootPhrase);
if (tokens.length >= 3) variations.push(`"${tokens.slice(0, 3).join(" ")}"`);
return uniqueStrings(variations);
}
function scoreSearchResult(result: SearchResult, query: string): number {
const tokens = tokenizeQuery(query);
const host = getHostname(result.url);
return (scoreText(result.title, tokens) * 6 +
scoreText(result.snippet, tokens) * 3 +
scoreDomain(host) +
Math.min(3, Math.floor((result.snippet.length || result.title.length) / 80)));
}
function rankSearchResults(results: SearchResult[], query: string, limit: number): SearchResult[] {
const scored = uniqueByUrl(results)
.filter((item) => !isBlockedUrl(item.url))
.map((item) => ({
item,
score: scoreSearchResult(item, query),
}))
.sort((a, b) => b.score - a.score);
const out: SearchResult[] = [];
const seenDomains = new Map<string, number>();
for (const entry of scored) {
const host = getRootDomain(getHostname(entry.item.url));
const count = seenDomains.get(host) ?? 0;
if (count >= 2) continue;
seenDomains.set(host, count + 1);
out.push(entry.item);
if (out.length >= limit) break;
}
return out;
}
function pickTopPages(pages: CrawledPage[], limit: number): CrawledPage[] {
const ranked = [...pages].sort((a, b) => b.relevanceScore - a.relevanceScore);
const out: CrawledPage[] = [];
const seenDomains = new Map<string, number>();
for (const page of ranked) {
const host = getRootDomain(getHostname(page.url));
const count = seenDomains.get(host) ?? 0;
if (count >= 2) continue;
seenDomains.set(host, count + 1);
out.push(page);
if (out.length >= limit) break;
}
return out;
}
// ─── Link scoring ────────────────────────────────────────────────────────────
const RE_DOCS_KEYWORD = /(docs|guide|help|support|manual|paper|study|report|blog|news|research|about|faq)/i;
function buildLinkCandidates(html: string, baseUrl: string, queryTokens: string[], maxLinks: number): AnchorCandidate[] {
return extractAnchors(html, baseUrl, maxLinks)
.map((link) => {
const host = getHostname(link.url);
let score = link.score;
score += scoreText(link.text, queryTokens) * 2;
score += scoreText(link.url, queryTokens);
if (RE_DOCS_KEYWORD.test(link.text + " " + link.url)) {
score += 2;
}
return { ...link, score: score + scoreDomain(host) };
})
.sort((a, b) => b.score - a.score);
}
// ─── Page crawling ───────────────────────────────────────────────────────────
function buildExcerpt(text: string, maxChars: number): string {
return trimString(normalizeWhitespace(text), maxChars);
}
function summarizePage(pageText: string, queryTokens: string[], maxSentences = 3): string {
const snippets = extractUsefulSentences(pageText, queryTokens, maxSentences);
return snippets.length > 0 ? snippets.join(" ") : trimString(pageText, 700);
}
async function crawlUrl(url: string, depth: number, queryTokens: string[], maxChars: number, maxLinkCount: number): Promise<{ page: CrawledPage; links: AnchorCandidate[] }> {
const { text: html } = await fetchText(url);
const title = extractTitle(html) || getHostname(url) || url;
const description = extractMetaDescription(html);
const canonical = extractCanonical(html);
const content = trimString(stripTags(html), maxChars);
const finalUrl = canonical && !isBlockedUrl(canonical)
? normalizeUrl(resolveUrl(canonical, url) ?? url)
: normalizeUrl(url);
const links = buildLinkCandidates(html, finalUrl, queryTokens, maxLinkCount);
const sourceHost = getHostname(finalUrl);
const sourceType = sourceTypeForHost(sourceHost);
const excerpt = buildExcerpt(description || summarizePage(content, queryTokens), 700);
const wordCount = content ? content.split(RE_WORD_SPLIT).filter(Boolean).length : 0;
const relevanceScore = scoreText(`${title}\n${description}\n${content}`, queryTokens) * 2 +
scoreDomain(sourceHost) +
Math.min(4, Math.floor(wordCount / 250));
return {
page: {
url: finalUrl,
title,
description,
content,
wordCount,
links: links.map((link) => link.url),
excerpt,
relevanceScore,
depth,
sourceType,
},
links,
};
}
// ─── LLM helpers ─────────────────────────────────────────────────────────────
async function getLoadedModel(modelId?: string): Promise<LoadedModel | null> {
const loaded = await getClient().llm.listLoaded();
if (loaded.length === 0) return null;
if (modelId) {
const match = loaded.find((m) => m.identifier === modelId);
if (!match) return null;
return { identifier: match.identifier ?? modelId, model: match };
}
const first = loaded[0];
return first?.identifier ? { identifier: first.identifier, model: first } : null;
}
async function completeWithModel(modelId: string | undefined, prompt: string, maxTokens: number, temperature: number): Promise<{ text: string | null; modelUsed: string | null }> {
const loaded = await getLoadedModel(modelId).catch(() => null);
if (!loaded) return { text: null, modelUsed: null };
try {
const prediction = await loaded.model.complete(prompt, { maxTokens, temperature });
let text = "";
for await (const chunk of prediction) {
text += chunk.content ?? "";
}
return { text: text.trim(), modelUsed: loaded.identifier };
} catch {
return { text: null, modelUsed: null };
}
}
// ─── Prompt builders ─────────────────────────────────────────────────────────
function buildPlannerPrompt(query: string, focus: string | undefined, pages: CrawledPage[], maxQueries: number): string {
const preview = pages
.slice(0, 6)
.map((page, index) => [
`Source ${index + 1}`,
`Title: ${page.title}`,
`URL: ${page.url}`,
`Type: ${page.sourceType}`,
`Snippet: ${trimString(page.excerpt || page.description || page.content, 280)}`,
].join("\n"))
.join("\n\n");
return [
"You are a local deep-research planner.",
`Task: ${query}`,
focus ? `Focus: ${focus}` : "",
"You must return ONLY valid JSON with this shape:",
`{ "followUpQueries": string[], "gaps": string[], "contradictions": string[] }`,
`Limit followUpQueries to at most ${maxQueries}.`,
"Prefer queries that test evidence, fill missing details, or inspect authoritative sources.",
"Avoid social media, video sites, and shallow listicle search terms.",
"Evidence packet:",
preview || "(no sources yet)",
]
.filter(Boolean)
.join("\n\n");
}
function buildSynthesisPrompt(query: string, focus: string | undefined, pages: CrawledPage[], searchResults: SearchResult[]): string {
const sources = pages
.slice(0, 10)
.map((page, index) => [
`Source ${index + 1}`,
`Title: ${page.title}`,
`URL: ${page.url}`,
`Type: ${page.sourceType}`,
`Excerpt: ${page.excerpt}`,
`Relevant sentences: ${trimString(page.content, 1800)}`,
].join("\n"))
.join("\n\n");
const trail = searchResults
.slice(0, 12)
.map((result, index) => `${index + 1}. ${result.title}\n ${result.url}\n ${trimString(result.snippet, 220)}`)
.join("\n");
return [
"You are a careful deep-research analyst.",
`Question: ${query}`,
focus ? `Focus: ${focus}` : "",
"Write a concise markdown report using only the evidence packet below.",
"Be explicit about uncertainty and conflicts.",
"Every important claim should be tied to one or more source numbers like [1] or [1][3].",
"Return sections in this order:",
"# Answer",
"# Key findings",
"# Conflicts / caveats",
"# Sources",
"Evidence packet:",
sources || "(no crawled sources)",
"Search trail:",
trail || "(no search trail)",
]
.filter(Boolean)
.join("\n\n");
}
function buildFallbackReport(query: string, focus: string | undefined, pages: CrawledPage[], searchResults: SearchResult[], gaps: string[] = [], contradictions: string[] = []): string {
const topPages = pages.slice(0, 10);
const sourceLines = topPages.length
? topPages
.map((page, index) => `${index + 1}. ${page.title} — ${page.url}\n ${trimString(page.excerpt || page.description || page.content, 220)}`)
.join("\n")
: "No pages could be crawled.";
const searchTrail = searchResults
.slice(0, 12)
.map((r, index) => `${index + 1}. ${r.title} — ${r.url}`);
const keyFindings = topPages
.slice(0, 6)
.map((page, index) => `- [${index + 1}] ${page.title}: ${trimString(page.excerpt || page.description || page.content, 180)}`);
return [
"# Answer",
`I gathered ${topPages.length} crawled sources${focus ? ` for the focus area "${focus}"` : ""}. This is a best-effort local synthesis for: ${query}.`,
"",
"# Key findings",
...(keyFindings.length ? keyFindings : ["- No strong findings extracted yet."]),
"",
"# Conflicts / caveats",
...(contradictions.length ? contradictions.map((item) => `- ${item}`) : ["- No clear contradictions were detected automatically."]),
...(gaps.length ? gaps.map((item) => `- ${item}`) : ["- The search may need more targeted follow-up queries."]),
"",
"# Sources",
sourceLines,
"",
"# Search trail",
...(searchTrail.length ? searchTrail : ["No search results were returned."]),
].join("\n");
}
// ─── JSON parsing ────────────────────────────────────────────────────────────
interface PlannerOutput {
followUpQueries?: string[];
gaps?: string[];
contradictions?: string[];
}
function parseJsonLoose(text: string): PlannerOutput | null {
const trimmed = text.trim();
const fenced = trimmed.match(/```json\s*([\s\S]*?)```/i);
const candidate = fenced ? fenced[1].trim() : trimmed;
try {
return JSON.parse(candidate);
} catch {
const firstBrace = candidate.indexOf("{");
const lastBrace = candidate.lastIndexOf("}");
if (firstBrace !== -1 && lastBrace !== -1 && lastBrace > firstBrace) {
try {
return JSON.parse(candidate.slice(firstBrace, lastBrace + 1));
} catch { /* ignore */ }
}
return null;
}
}
// ─── Heuristic follow-up queries ─────────────────────────────────────────────
function makeHeuristicFollowUps(query: string, focus: string | undefined, pages: CrawledPage[], round: number): string[] {
const followUps = new Set<string>();
const tokens = tokenizeQuery(query);
const topPages = pages.slice(0, 4);
for (const page of topPages) {
const keyBits = page.title
.split(RE_NON_ALPHANUM)
.map((part) => part.trim())
.filter((part) => part.length >= 4)
.slice(0, 4)
.join(" ");
if (keyBits) followUps.add(`${query} ${keyBits}`.trim());
const host = getHostname(page.url);
if (host && !isBlockedHost(host)) followUps.add(`${query} site:${host}`);
}
if (focus) followUps.add(`${query} ${focus}`);
if (tokens.length > 0) {
followUps.add(`${query} official`);
followUps.add(`${query} documentation`);
followUps.add(`${query} key facts`);
}
if (round > 1) {
followUps.add(`${query} controversy`);
followUps.add(`${query} analysis`);
followUps.add(`${query} evidence`);
}
return Array.from(followUps).slice(0, 8);
}
// ─── Main research workflow ──────────────────────────────────────────────────
async function runDeepResearch(params: DeepResearchParams, ctx: ToolCallContext): Promise<ResearchResult> {
const query = normalizeWhitespace(params.query);
const focus = params.focus?.trim() || undefined;
const maxRounds = clampInt(params.maxRounds, DEFAULTS.maxRounds, 1, 4);
const maxSearchesPerRound = clampInt(params.maxSearchesPerRound, DEFAULTS.maxSearchesPerRound, 1, 8);
const maxResultsPerSearch = clampInt(params.maxResultsPerSearch, DEFAULTS.maxResultsPerSearch, 1, 10);
const maxPages = clampInt(params.maxPages, DEFAULTS.maxPages, 1, 24);
const maxDepth = clampInt(params.maxDepth, DEFAULTS.maxDepth, 1, 3);
const maxCharsPerPage = clampInt(params.maxCharsPerPage, DEFAULTS.maxCharsPerPage, 3000, 40000);
const maxTokens = clampInt(params.maxTokens, DEFAULTS.maxTokens, 64, 1500);
const temperature = clampFloat(params.temperature, DEFAULTS.temperature, 0, 2);
const queryTokens = tokenizeQuery(`${query} ${focus ?? ""}`);
const issuedQueries = new Set<string>();
const visited = new Set<string>();
const allSearchResults: SearchResult[] = [];
const allPages: CrawledPage[] = [];
const queue: QueueEntry[] = [];
let frontier = buildBaseSearchQueries(query, focus).slice(0, maxSearchesPerRound);
ctx.status(`Planning research for ${query}`);
for (let round = 1; round <= maxRounds; round += 1) {
if (ctx.signal.aborted) throw new Error("Research was aborted");
const roundQueries = frontier
.map((item) => item.trim())
.filter((item) => item && !issuedQueries.has(item.toLowerCase()));
roundQueries.forEach((item) => issuedQueries.add(item.toLowerCase()));
if (roundQueries.length === 0) break;
ctx.status(`Round ${round}/${maxRounds}: searching ${roundQueries.length} queries`);
for (const searchQuery of roundQueries.slice(0, maxSearchesPerRound)) {
if (ctx.signal.aborted) throw new Error("Research was aborted");
ctx.status(`Searching: ${searchQuery}`);
let results = await searchDuckDuckGo(searchQuery, maxResultsPerSearch);
results = rankSearchResults(results, `${query} ${focus ?? ""}`, maxResultsPerSearch);
allSearchResults.push(...results);
for (const result of results) {
queue.push({ url: normalizeUrl(result.url), depth: 0, score: scoreSearchResult(result, query) });
}
}
queue.sort((a, b) => b.score - a.score);
const nextQueue: QueueEntry[] = [];
while (queue.length > 0) {
if (allPages.length >= maxPages) break;
if (ctx.signal.aborted) throw new Error("Research was aborted");
const current = queue.shift()!;
const url = normalizeUrl(current.url);
if (!url || visited.has(url) || isBlockedUrl(url)) continue;
if (current.depth > maxDepth) continue;
visited.add(url);
ctx.status(`Crawling ${allPages.length + 1}/${maxPages}: ${getHostname(url) || url}`);
try {
const { page, links } = await crawlUrl(url, current.depth, queryTokens, maxCharsPerPage, DEFAULTS.maxLinksPerPage);
page.relevanceScore =
scoreText(`${page.title}\n${page.description}\n${page.content}`, queryTokens) * 2 +
scoreDomain(getHostname(page.url)) +
Math.min(4, Math.floor(page.wordCount / 250));
allPages.push(page);
if (current.depth < maxDepth) {
for (const link of links) {
if (visited.has(link.url) || isBlockedUrl(link.url)) continue;
nextQueue.push({ url: link.url, depth: current.depth + 1, score: link.score });
}
}
} catch (error) {
ctx.warn(`Could not crawl ${url}: ${error instanceof Error ? error.message : String(error)}`);
}
}
queue.push(...nextQueue.sort((a, b) => b.score - a.score));
queue.sort((a, b) => b.score - a.score);
const rankedPages = pickTopPages(allPages, Math.min(maxPages, 10));
// Planner: attempt LLM-generated follow-ups
let followUps: string[] = [];
const modelInfo = await getLoadedModel(params.modelId).catch(() => null);
if (modelInfo) {
const plannerPrompt = buildPlannerPrompt(query, focus, rankedPages, 8);
const planner = await completeWithModel(params.modelId, plannerPrompt, 320, 0.15);
const parsed = planner.text ? parseJsonLoose(planner.text) : null;
if (parsed && Array.isArray(parsed.followUpQueries)) {
followUps = parsed.followUpQueries
.map((item) => String(item).trim())
.filter(Boolean);
}
}
if (followUps.length === 0) {
followUps = makeHeuristicFollowUps(query, focus, rankedPages, round);
}
frontier = uniqueStrings(followUps)
.filter((item) => !issuedQueries.has(item.toLowerCase()))
.slice(0, maxSearchesPerRound);
if (frontier.length === 0) break;
}
// ── Synthesis ──────────────────────────────────────────────────────────────
const finalPages = pickTopPages(allPages, maxPages);
const uniqueSearchResults = rankSearchResults(allSearchResults, `${query} ${focus ?? ""}`, Math.min(allSearchResults.length, maxPages * 2));
let contradictions: string[] = [];
let gaps: string[] = [];
let reportMarkdown = "";
let modelUsed: string | null = null;
// Synthesis step
const synthesisPrompt = buildSynthesisPrompt(query, focus, finalPages, uniqueSearchResults);
const synthesis = await completeWithModel(params.modelId, synthesisPrompt, maxTokens, temperature);
if (synthesis.text) {
reportMarkdown = synthesis.text;
modelUsed = synthesis.modelUsed;
} else {
reportMarkdown = buildFallbackReport(query, focus, finalPages, uniqueSearchResults);
}
// Audit step: detect contradictions and gaps
const auditEvidence = finalPages
.slice(0, 8)
.map((page) => `${page.title}: ${trimString(page.excerpt || page.description || page.content, 240)}`)
.join("\n");
const auditPrompt = [
"You are auditing a research packet for missing evidence, contradictions, and reliability concerns.",
`Question: ${query}`,
focus ? `Focus: ${focus}` : "",
"Evidence:",
auditEvidence || "(none)",
'Return ONLY valid JSON with keys "contradictions" and "gaps", both arrays of concise strings.',
]
.filter(Boolean)
.join("\n\n");
const audit = await completeWithModel(params.modelId, auditPrompt, 220, 0.1);
const parsedAudit = audit.text ? parseJsonLoose(audit.text) : null;
if (parsedAudit?.contradictions && Array.isArray(parsedAudit.contradictions)) {
contradictions = parsedAudit.contradictions
.map((item) => String(item).trim())
.filter(Boolean)
.slice(0, 8);
}
if (parsedAudit?.gaps && Array.isArray(parsedAudit.gaps)) {
gaps = parsedAudit.gaps
.map((item) => String(item).trim())
.filter(Boolean)
.slice(0, 8);
}
// Fallback if synthesis failed entirely
if (!reportMarkdown || reportMarkdown === buildFallbackReport(query, focus, finalPages, uniqueSearchResults)) {
reportMarkdown = buildFallbackReport(query, focus, finalPages, uniqueSearchResults, gaps, contradictions);
}
if (audit.modelUsed && !modelUsed) modelUsed = audit.modelUsed;
const sources = finalPages.map((page, index) => ({
rank: index + 1,
title: page.title,
url: page.url,
domain: getHostname(page.url),
sourceType: page.sourceType,
excerpt: page.excerpt,
wordCount: page.wordCount,
relevanceScore: page.relevanceScore,
depth: page.depth,
}));
return {
query,
focus: focus ?? null,
modelUsed,
rounds: maxRounds,
searchQueries: Array.from(issuedQueries),
searchResults: uniqueSearchResults,
sources,
contradictions,
gaps,
reportMarkdown,
};
}
// ─── Tool definition ─────────────────────────────────────────────────────────
const deepResearchTool = tool({
name: "deepResearch",
description: "Run a local autonomous deep-research workflow: it plans searches, crawls pages recursively, filters social sites, checks for gaps and contradictions, and returns a synthesized markdown report. No paid APIs are used.",
parameters: {
query: z.string().min(1),
focus: z.string().optional(),
maxRounds: z.number().int().min(1).max(4).default(DEFAULTS.maxRounds),
maxSearchesPerRound: z.number().int().min(1).max(8).default(DEFAULTS.maxSearchesPerRound),
maxResultsPerSearch: z.number().int().min(1).max(10).default(DEFAULTS.maxResultsPerSearch),
maxPages: z.number().int().min(1).max(24).default(DEFAULTS.maxPages),
maxDepth: z.number().int().min(1).max(3).default(DEFAULTS.maxDepth),
maxCharsPerPage: z.number().int().min(3000).max(40000).default(DEFAULTS.maxCharsPerPage),
modelId: z.string().optional(),
maxTokens: z.number().int().min(64).max(1500).default(DEFAULTS.maxTokens),
temperature: z.number().min(0).max(2).default(DEFAULTS.temperature),
},
implementation: async (params: DeepResearchParams, ctx: ToolCallContext) => {
return await runDeepResearch(params, ctx);
},
});
interface PluginContext {
withToolsProvider(provider: () => Promise<any>): void;
}
async function main(pluginContext: PluginContext): Promise<void> {
pluginContext.withToolsProvider(async () => [deepResearchTool]);
}
export { main };