src / toolsProvider.ts
/**
* Web Search Advanced Plugin — Optimized for minimal context usage
*
* Strategy: Fewer tools, concise outputs, smart defaults
*/
import { tool, text, type ToolCallContext, type ToolsProvider } from "@lmstudio/sdk";
import { z } from "zod";
import { pluginConfigSchematics } from "./config";
// ---------------------------------------------------------------------------
// Minimal Helpers
// ---------------------------------------------------------------------------
const json = (obj: unknown): string => JSON.stringify(obj, null, 2);
function safe_impl<T extends Record<string, unknown>>(
name: string,
fn: (params: T, ctx: ToolCallContext) => Promise<string>
) {
return async (params: T, ctx: ToolCallContext) => {
if (ctx.signal.aborted) return JSON.stringify({ tool_error: true, tool: name, error: "cancelled" });
try {
return await fn(params, ctx);
} catch (err: unknown) {
if (err instanceof Error && err.name === "AbortError") {
return JSON.stringify({ tool_error: true, tool: name, error: "cancelled" });
}
return JSON.stringify({ tool_error: true, tool: name, error: err instanceof Error ? err.message : String(err) });
}
};
}
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
const truncate = (s: string, n: number) => s.length <= n ? s : s.slice(0, s.lastIndexOf(" ", n) || n);
function rootDomain(url: string): string {
try {
const h = new URL(url).hostname.replace(/^www\./, "");
const p = h.split(".");
return p.length >= 2 ? p.slice(-2).join(".") : h;
} catch { return url; }
}
// ---------------------------------------------------------------------------
// Cache (Simple LRU)
// ---------------------------------------------------------------------------
class Cache {
private map = new Map<string, { data: any; ts: number }>();
constructor(private maxSize = 100, private ttlMs = 3600000) {}
get(key: string) {
const e = this.map.get(key);
if (!e || Date.now() - e.ts > this.ttlMs) { this.map.delete(key); return null; }
return e.data;
}
set(key: string, data: any) {
if (this.map.size >= this.maxSize) this.map.delete(this.map.keys().next().value!);
this.map.set(key, { data, ts: Date.now() });
}
clear() { this.map.clear(); }
get size() { return this.map.size; }
}
// ---------------------------------------------------------------------------
// Search Backends (Minimal)
// ---------------------------------------------------------------------------
const HEADERS = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
"Accept-Language": "en-US,en;q=0.9",
};
function cleanHtml(html: string): string {
return html
.replace(/<script[\s\S]*?<\/script>/gi, " ")
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<nav|<footer|<aside|<header[\s\S]*?<\/(?:nav|footer|aside|header)>/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/&[a-z]+;/gi, " ")
.replace(/\s{2,}/g, " ")
.trim();
}
function extractText(html: string, max = 6000): string {
const m = html.match(/<article[^>]*>([\s\S]*?)<\/article>/i) ??
html.match(/<main[^>]*>([\s\S]*?)<\/main>/i);
return cleanHtml(m ? m[1] : html).slice(0, max);
}
function extractTitle(html: string): string {
const m = html.match(/<title[^>]*>([^<]{1,150})<\/title>/i);
return m ? m[1].trim() : "Untitled";
}
function extractMeta(html: string) {
const author = html.match(/<meta[^>]+name="author"[^>]+content="([^"]*?)"/i)?.[1];
const date = html.match(/<meta[^>]+property="article:published_time"[^>]+content="([^"]*?)"/i)?.[1]
?? html.match(/<time[^>]+datetime="([^"]*?)"/i)?.[1];
return { author, date };
}
async function fetchPage(url: string, timeout = 8000, maxChars = 6000, signal?: AbortSignal) {
try {
const res = await fetch(url, {
signal: AbortSignal.any([signal!, AbortSignal.timeout(timeout)]),
headers: HEADERS,
});
if (!res.ok) return { url, title: "", text: "", error: `HTTP ${res.status}`, meta: {} };
const html = await res.text();
return { url, title: extractTitle(html), text: extractText(html, maxChars), meta: extractMeta(html) };
} catch (e: any) {
return { url, title: "", text: "", error: e.message, meta: {} };
}
}
async function fetchSearchHtml(url: string, signal?: AbortSignal): Promise<string> {
const res = await fetch(url, { signal: AbortSignal.any([signal!, AbortSignal.timeout(8000)]), headers: HEADERS });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
}
function strip(html: string): string {
return html.replace(/<[^>]+>/g, " ").replace(/&[a-z]+;/gi, " ").replace(/\s+/g, " ").trim();
}
async function ddgSearch(query: string, max: number, time?: string, signal?: AbortSignal) {
const params = new URLSearchParams({ q: query, kl: "en-us" });
if (time) params.set("df", time);
const html = await fetchSearchHtml(`https://html.duckduckgo.com/html/?${params}`, signal);
const results: { title: string; url: string; snippet: string }[] = [];
const linkRe = /class="result__a"[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g;
const snipRe = /class="result__snippet"[^>]*>([\s\S]*?)<\/(?:div|a)>/g;
let m;
while ((m = linkRe.exec(html)) && results.length < max) {
const uddg = m[1].match(/[?]uddg=([^&"]+)/);
const url = uddg ? decodeURIComponent(uddg[1]) : m[1];
if (!url.startsWith("http")) continue;
const title = strip(m[2]);
if (title) results.push({ url, title, snippet: "" });
}
const snippets: string[] = [];
while ((m = snipRe.exec(html))) snippets.push(strip(m[1]));
return results.map((r, i) => ({ ...r, snippet: snippets[i] ?? "" }));
}
async function bingSearch(query: string, max: number, signal?: AbortSignal) {
const html = await fetchSearchHtml(
`https://www.bing.com/search?q=${encodeURIComponent(query)}&count=${max}`, signal
);
const results: { title: string; url: string; snippet: string }[] = [];
const liRe = /<li class="[^"]*b_algo[^"]*"[^>]*>([\s\S]*?)<\/li>/g;
let m;
while ((m = liRe.exec(html)) && results.length < max) {
const linkM = m[1].match(/<a[^>]+href="(https?:\/\/[^"]+)"[^>]*>([\s\S]*?)<\/a>/);
if (!linkM) continue;
const title = strip(linkM[2]);
const snipM = m[1].match(/<p[^>]*>([\s\S]*?)<\/p>/);
results.push({ title, url: linkM[1], snippet: snipM ? strip(snipM[1]) : "" });
}
return results;
}
async function search(query: string, max: number, time?: string, signal?: AbortSignal) {
try {
const r = await ddgSearch(query, max, time, signal);
if (r.length > 0) return r;
} catch {}
try {
return await bingSearch(query, max, signal);
} catch {}
return [];
}
// ---------------------------------------------------------------------------
// Source Credibility (Inline)
// ---------------------------------------------------------------------------
const HIGH_CRED = /\.(gov|mil|edu)(\.[\w]+)?$/i;
const NEWS_DOMAINS = new Set(["reuters.com", "apnews.com", "bbc.com", "nytimes.com", "theguardian.com", "washingtonpost.com", "nature.com", "science.org"]);
const ACAD_DOMAINS = new Set(["arxiv.org", "pubmed.ncbi.nlm.nih.gov", "semanticscholar.org", "ieee.org"]);
function credLevel(url: string): "high" | "medium" | "low" {
try {
const h = new URL(url).hostname.replace(/^www\./, "");
if (HIGH_CRED.test(h)) return "high";
if (NEWS_DOMAINS.has(h) || ACAD_DOMAINS.has(h)) return "high";
if (h === "wikipedia.org" || h.endsWith(".wikipedia.org")) return "medium";
if (["blogspot.com", "wordpress.com", "reddit.com", "medium.com"].some(s => h.includes(s))) return "low";
return "medium";
} catch { return "medium"; }
}
// ---------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------
export const toolsProvider: ToolsProvider = async (ctl) => {
const cfg = ctl.getPluginConfig(pluginConfigSchematics);
const cache = new Cache(cfg.get("maxCacheSize"), cfg.get("cacheTTLMinutes") * 60000);
const maxR = () => cfg.get("maxSearchResults");
const timeout = () => cfg.get("fetchTimeoutMs");
const timeMap: Record<string, string> = { day: "d", week: "w", month: "m", year: "y" };
const defaultTime = () => timeMap[cfg.get("searchRecencyWindow").trim().toLowerCase()] ?? "y";
// Helper: search + read top pages (cached)
async function searchAndRead(query: string, pages: number, time?: string, signal?: AbortSignal) {
const cacheKey = `${query}:${time ?? "default"}:${pages}`;
const cached = cache.get(cacheKey);
if (cached) return cached;
const hits = await search(query, maxR(), time ?? defaultTime(), signal);
const read: any[] = [];
for (const h of hits) {
if (read.filter(p => !p.error).length >= pages) break;
if (signal?.aborted) break;
const p = await fetchPage(h.url, timeout(), 6000, signal);
read.push({ ...h, ...p, cred: credLevel(h.url) });
await sleep(200);
}
const result = { hits, pages: read, domains: [...new Set(read.map(p => rootDomain(p.url)))] };
cache.set(cacheKey, result);
return result;
}
const tools = [
// =========================================================================
// SEARCH — The one tool for most queries
// =========================================================================
tool({
name: "search",
description: `Search the web and read top results. Returns facts with sources.
Use for: most factual questions. For verification use fact_check. For deep research use deep_search.`,
parameters: {
q: z.string().describe("Search query"),
n: z.coerce.number().int().min(1).max(6).default(3).describe("Pages to read (1-6)"),
time: z.enum(["day", "week", "month", "year"]).optional().describe("Time filter"),
},
implementation: safe_impl("search", async ({ q, n, time }, ctx) => {
ctx.status(`Searching: ${q}`);
const t = time ? timeMap[time] : undefined;
const r = await searchAndRead(q, n, t, ctx.signal);
return json({
q,
found: r.hits.length,
read: r.pages.filter((p: any) => !p.error).length,
publishers: r.domains.length,
results: r.pages.map((p: any) => ({
t: p.title,
u: p.url,
c: p.cred,
...(p.error ? { err: p.error } : { text: truncate(p.text, 2000) }),
})),
tip: r.domains.length === 1 ? "⚠️ Single source — verify independently" : `${r.domains.length} sources — cross-reference claims`,
});
}),
}),
// =========================================================================
// DEEP — Multi-angle research (parallel)
// =========================================================================
tool({
name: "deep",
description: `Parallel multi-angle research. Runs 3-5 searches simultaneously.
Use for: complex topics needing comprehensive coverage.`,
parameters: {
topic: z.string().describe("Research topic"),
angles: z.array(z.string()).max(5).default([]).describe("Custom angles (or auto)"),
},
implementation: safe_impl("deep", async ({ topic, angles }, ctx) => {
const defaults = [`${topic} overview`, `${topic} latest research`, `${topic} criticism`, `${topic} expert opinion`];
const searchAngles = angles.length ? angles : defaults;
const queries = searchAngles.map(a => angles.length ? `${topic} ${a}` : a);
ctx.status(`Researching ${searchAngles.length} angles…`);
// Parallel search
const results = await Promise.all(queries.map(async q => {
const hits = await search(q, 4, defaultTime(), ctx.signal);
const pages = [];
for (const h of hits.slice(0, 2)) {
const p = await fetchPage(h.url, timeout(), 4000, ctx.signal);
pages.push({ ...h, ...p, cred: credLevel(h.url) });
}
return { q, pages };
}));
const allDomains = [...new Set(results.flatMap(r => r.pages.map(p => rootDomain(p.url))))];
return json({
topic,
angles: results.map(r => ({
q: r.q,
pages: r.pages.map((p: any) => ({
t: p.title,
u: p.url,
c: p.cred,
...(p.error ? { err: p.error } : { text: truncate(p.text, 1500) }),
})),
})),
sources: allDomains.length,
tip: "Synthesize across angles. Note agreements and conflicts.",
});
}),
}),
// =========================================================================
// VERIFY — Fact-check a specific claim
// =========================================================================
tool({
name: "verify",
description: `Cross-check a claim against multiple sources. Returns verdict.
Use for: "is it true that…", verifying statistics, checking facts.`,
parameters: {
claim: z.string().describe("Claim to verify"),
},
implementation: safe_impl("verify", async ({ claim }, ctx) => {
ctx.status("Verifying claim…");
const queries = [
claim,
`"${truncate(claim, 50)}" false wrong`,
`evidence study "${truncate(claim, 60)}"`,
];
const results = await Promise.all(queries.map(async q => {
const hits = await search(q, 3, undefined, ctx.signal);
const pages = [];
for (const h of hits.slice(0, 1)) {
const p = await fetchPage(h.url, timeout(), 4000, ctx.signal);
pages.push({ ...h, ...p, cred: credLevel(h.url) });
}
return { q, pages };
}));
const allPages = results.flatMap(r => r.pages);
const highCred = allPages.filter(p => p.cred === "high" && !p.error);
return json({
claim,
sources_checked: allPages.length,
high_credibility: highCred.length,
evidence: allPages.map((p: any) => ({
t: p.title,
u: p.url,
c: p.cred,
...(p.error ? {} : { text: truncate(p.text, 1000) }),
})),
verdict: highCred.length >= 2 ? "LIKELY TRUE" : highCred.length === 1 ? "SINGLE SOURCE" : "UNVERIFIED",
tip: "Assign final verdict based on evidence strength and source quality.",
});
}),
}),
// =========================================================================
// FETCH — Read a specific URL
// =========================================================================
tool({
name: "fetch",
description: `Read a specific URL and extract content.
Use for: reading articles, verifying what a source actually says.`,
parameters: {
url: z.string().url().describe("URL to read"),
max: z.coerce.number().int().min(1000).max(15000).default(6000).describe("Max characters"),
},
implementation: safe_impl("fetch", async ({ url, max }, ctx) => {
ctx.status(`Reading: ${url}`);
const p = await fetchPage(url, timeout(), max, ctx.signal);
return json({
url,
title: p.title,
cred: credLevel(url),
meta: p.meta,
...(p.error ? { error: p.error } : { text: p.text }),
});
}),
}),
// =========================================================================
// NEWS — Recent news search
// =========================================================================
tool({
name: "news",
description: `Search recent news. Filters for journalistic sources.
Use for: current events, breaking news, recent developments.`,
parameters: {
q: z.string().describe("News query"),
time: z.enum(["day", "week", "month"]).default("week").describe("Time window"),
n: z.coerce.number().int().min(1).max(4).default(2).describe("Articles to read"),
},
implementation: safe_impl("news", async ({ q, time, n }, ctx) => {
ctx.status(`News (last ${time}): ${q}`);
const t = timeMap[time];
const r = await searchAndRead(q, n, t, ctx.signal);
return json({
q,
time,
results: r.pages.map((p: any) => ({
t: p.title,
u: p.url,
c: p.cred,
...(p.error ? {} : { text: truncate(p.text, 2000) }),
})),
tip: "Focus on established news outlets (high credibility).",
});
}),
}),
// =========================================================================
// ACADEMIC — Search papers
// =========================================================================
tool({
name: "academic",
description: `Search academic papers (arXiv, PubMed, Semantic Scholar).
Use for: scientific research, technical topics, peer-reviewed sources.`,
parameters: {
q: z.string().describe("Research topic"),
source: z.enum(["arxiv", "pubmed", "semantic", "all"]).default("all"),
year: z.coerce.number().int().min(2000).max(2030).optional().describe("From year"),
},
implementation: safe_impl("academic", async ({ q, source, year }, ctx) => {
ctx.status(`Academic search: ${q}`);
const yr = year ? ` ${year}` : "";
const sites: Record<string, string> = {
arxiv: "site:arxiv.org",
pubmed: "site:pubmed.ncbi.nlm.nih.gov",
semantic: "site:semanticscholar.org",
};
const queries = source === "all"
? Object.values(sites).map(s => `${s} ${q}${yr}`)
: [`${sites[source]} ${q}${yr}`];
const hits = (await Promise.all(queries.map(query => search(query, 5, undefined, ctx.signal)))).flat();
const unique = [...new Map(hits.map(h => [h.url, h])).values()].slice(0, 8);
return json({
q,
papers: unique.map(h => ({ t: h.title, u: h.url, s: h.snippet })),
tip: "Extract: authors, year, methodology, key findings.",
});
}),
}),
// =========================================================================
// CITATION — Generate formatted citation
// =========================================================================
tool({
name: "cite",
description: `Generate a citation for a URL. Formats: apa, mla, chicago, ieee.`,
parameters: {
url: z.string().url().describe("URL to cite"),
format: z.enum(["apa", "mla", "chicago", "ieee"]).default("apa"),
},
implementation: safe_impl("cite", async ({ url, format }, ctx) => {
ctx.status("Generating citation…");
const p = await fetchPage(url, timeout(), 2000, ctx.signal);
const d = new Date();
const metaDate = (p.meta as any)?.date;
const metaAuthor = (p.meta as any)?.author;
const year = metaDate ? new Date(metaDate).getFullYear() : "n.d.";
const author = metaAuthor ?? "Unknown";
const cits: Record<string, string> = {
apa: `${author} (${year}). ${p.title}. ${rootDomain(url)}. ${url}`,
mla: `"${p.title}." ${rootDomain(url)}, ${year}. ${url}`,
chicago: `${author}. "${p.title}." ${year}. ${url}`,
ieee: `[${author}, "${p.title}," ${rootDomain(url)}, ${year}. [Online]. Available: ${url}`,
};
return json({ url, format, citation: cits[format as keyof typeof cits] ?? cits.apa });
}),
}),
// =========================================================================
// CACHE — Clear cache
// =========================================================================
tool({
name: "clear_cache",
description: "Clear search cache for fresh results.",
parameters: {},
implementation: safe_impl("clear_cache", async () => {
const n = cache.size;
cache.clear();
return json({ cleared: n });
}),
}),
];
return tools;
};
src / toolsProvider.ts
/**
* Web Search Advanced Plugin — Optimized for minimal context usage
*
* Strategy: Fewer tools, concise outputs, smart defaults
*/
import { tool, text, type ToolCallContext, type ToolsProvider } from "@lmstudio/sdk";
import { z } from "zod";
import { pluginConfigSchematics } from "./config";
// ---------------------------------------------------------------------------
// Minimal Helpers
// ---------------------------------------------------------------------------
const json = (obj: unknown): string => JSON.stringify(obj, null, 2);
function safe_impl<T extends Record<string, unknown>>(
name: string,
fn: (params: T, ctx: ToolCallContext) => Promise<string>
) {
return async (params: T, ctx: ToolCallContext) => {
if (ctx.signal.aborted) return JSON.stringify({ tool_error: true, tool: name, error: "cancelled" });
try {
return await fn(params, ctx);
} catch (err: unknown) {
if (err instanceof Error && err.name === "AbortError") {
return JSON.stringify({ tool_error: true, tool: name, error: "cancelled" });
}
return JSON.stringify({ tool_error: true, tool: name, error: err instanceof Error ? err.message : String(err) });
}
};
}
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
const truncate = (s: string, n: number) => s.length <= n ? s : s.slice(0, s.lastIndexOf(" ", n) || n);
function rootDomain(url: string): string {
try {
const h = new URL(url).hostname.replace(/^www\./, "");
const p = h.split(".");
return p.length >= 2 ? p.slice(-2).join(".") : h;
} catch { return url; }
}
// ---------------------------------------------------------------------------
// Cache (Simple LRU)
// ---------------------------------------------------------------------------
class Cache {
private map = new Map<string, { data: any; ts: number }>();
constructor(private maxSize = 100, private ttlMs = 3600000) {}
get(key: string) {
const e = this.map.get(key);
if (!e || Date.now() - e.ts > this.ttlMs) { this.map.delete(key); return null; }
return e.data;
}
set(key: string, data: any) {
if (this.map.size >= this.maxSize) this.map.delete(this.map.keys().next().value!);
this.map.set(key, { data, ts: Date.now() });
}
clear() { this.map.clear(); }
get size() { return this.map.size; }
}
// ---------------------------------------------------------------------------
// Search Backends (Minimal)
// ---------------------------------------------------------------------------
const HEADERS = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
"Accept-Language": "en-US,en;q=0.9",
};
function cleanHtml(html: string): string {
return html
.replace(/<script[\s\S]*?<\/script>/gi, " ")
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<nav|<footer|<aside|<header[\s\S]*?<\/(?:nav|footer|aside|header)>/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/&[a-z]+;/gi, " ")
.replace(/\s{2,}/g, " ")
.trim();
}
function extractText(html: string, max = 6000): string {
const m = html.match(/<article[^>]*>([\s\S]*?)<\/article>/i) ??
html.match(/<main[^>]*>([\s\S]*?)<\/main>/i);
return cleanHtml(m ? m[1] : html).slice(0, max);
}
function extractTitle(html: string): string {
const m = html.match(/<title[^>]*>([^<]{1,150})<\/title>/i);
return m ? m[1].trim() : "Untitled";
}
function extractMeta(html: string) {
const author = html.match(/<meta[^>]+name="author"[^>]+content="([^"]*?)"/i)?.[1];
const date = html.match(/<meta[^>]+property="article:published_time"[^>]+content="([^"]*?)"/i)?.[1]
?? html.match(/<time[^>]+datetime="([^"]*?)"/i)?.[1];
return { author, date };
}
async function fetchPage(url: string, timeout = 8000, maxChars = 6000, signal?: AbortSignal) {
try {
const res = await fetch(url, {
signal: AbortSignal.any([signal!, AbortSignal.timeout(timeout)]),
headers: HEADERS,
});
if (!res.ok) return { url, title: "", text: "", error: `HTTP ${res.status}`, meta: {} };
const html = await res.text();
return { url, title: extractTitle(html), text: extractText(html, maxChars), meta: extractMeta(html) };
} catch (e: any) {
return { url, title: "", text: "", error: e.message, meta: {} };
}
}
async function fetchSearchHtml(url: string, signal?: AbortSignal): Promise<string> {
const res = await fetch(url, { signal: AbortSignal.any([signal!, AbortSignal.timeout(8000)]), headers: HEADERS });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
}
function strip(html: string): string {
return html.replace(/<[^>]+>/g, " ").replace(/&[a-z]+;/gi, " ").replace(/\s+/g, " ").trim();
}
async function ddgSearch(query: string, max: number, time?: string, signal?: AbortSignal) {
const params = new URLSearchParams({ q: query, kl: "en-us" });
if (time) params.set("df", time);
const html = await fetchSearchHtml(`https://html.duckduckgo.com/html/?${params}`, signal);
const results: { title: string; url: string; snippet: string }[] = [];
const linkRe = /class="result__a"[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g;
const snipRe = /class="result__snippet"[^>]*>([\s\S]*?)<\/(?:div|a)>/g;
let m;
while ((m = linkRe.exec(html)) && results.length < max) {
const uddg = m[1].match(/[?]uddg=([^&"]+)/);
const url = uddg ? decodeURIComponent(uddg[1]) : m[1];
if (!url.startsWith("http")) continue;
const title = strip(m[2]);
if (title) results.push({ url, title, snippet: "" });
}
const snippets: string[] = [];
while ((m = snipRe.exec(html))) snippets.push(strip(m[1]));
return results.map((r, i) => ({ ...r, snippet: snippets[i] ?? "" }));
}
async function bingSearch(query: string, max: number, signal?: AbortSignal) {
const html = await fetchSearchHtml(
`https://www.bing.com/search?q=${encodeURIComponent(query)}&count=${max}`, signal
);
const results: { title: string; url: string; snippet: string }[] = [];
const liRe = /<li class="[^"]*b_algo[^"]*"[^>]*>([\s\S]*?)<\/li>/g;
let m;
while ((m = liRe.exec(html)) && results.length < max) {
const linkM = m[1].match(/<a[^>]+href="(https?:\/\/[^"]+)"[^>]*>([\s\S]*?)<\/a>/);
if (!linkM) continue;
const title = strip(linkM[2]);
const snipM = m[1].match(/<p[^>]*>([\s\S]*?)<\/p>/);
results.push({ title, url: linkM[1], snippet: snipM ? strip(snipM[1]) : "" });
}
return results;
}
async function search(query: string, max: number, time?: string, signal?: AbortSignal) {
try {
const r = await ddgSearch(query, max, time, signal);
if (r.length > 0) return r;
} catch {}
try {
return await bingSearch(query, max, signal);
} catch {}
return [];
}
// ---------------------------------------------------------------------------
// Source Credibility (Inline)
// ---------------------------------------------------------------------------
const HIGH_CRED = /\.(gov|mil|edu)(\.[\w]+)?$/i;
const NEWS_DOMAINS = new Set(["reuters.com", "apnews.com", "bbc.com", "nytimes.com", "theguardian.com", "washingtonpost.com", "nature.com", "science.org"]);
const ACAD_DOMAINS = new Set(["arxiv.org", "pubmed.ncbi.nlm.nih.gov", "semanticscholar.org", "ieee.org"]);
function credLevel(url: string): "high" | "medium" | "low" {
try {
const h = new URL(url).hostname.replace(/^www\./, "");
if (HIGH_CRED.test(h)) return "high";
if (NEWS_DOMAINS.has(h) || ACAD_DOMAINS.has(h)) return "high";
if (h === "wikipedia.org" || h.endsWith(".wikipedia.org")) return "medium";
if (["blogspot.com", "wordpress.com", "reddit.com", "medium.com"].some(s => h.includes(s))) return "low";
return "medium";
} catch { return "medium"; }
}
// ---------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------
export const toolsProvider: ToolsProvider = async (ctl) => {
const cfg = ctl.getPluginConfig(pluginConfigSchematics);
const cache = new Cache(cfg.get("maxCacheSize"), cfg.get("cacheTTLMinutes") * 60000);
const maxR = () => cfg.get("maxSearchResults");
const timeout = () => cfg.get("fetchTimeoutMs");
const timeMap: Record<string, string> = { day: "d", week: "w", month: "m", year: "y" };
const defaultTime = () => timeMap[cfg.get("searchRecencyWindow").trim().toLowerCase()] ?? "y";
// Helper: search + read top pages (cached)
async function searchAndRead(query: string, pages: number, time?: string, signal?: AbortSignal) {
const cacheKey = `${query}:${time ?? "default"}:${pages}`;
const cached = cache.get(cacheKey);
if (cached) return cached;
const hits = await search(query, maxR(), time ?? defaultTime(), signal);
const read: any[] = [];
for (const h of hits) {
if (read.filter(p => !p.error).length >= pages) break;
if (signal?.aborted) break;
const p = await fetchPage(h.url, timeout(), 6000, signal);
read.push({ ...h, ...p, cred: credLevel(h.url) });
await sleep(200);
}
const result = { hits, pages: read, domains: [...new Set(read.map(p => rootDomain(p.url)))] };
cache.set(cacheKey, result);
return result;
}
const tools = [
// =========================================================================
// SEARCH — The one tool for most queries
// =========================================================================
tool({
name: "search",
description: `Search the web and read top results. Returns facts with sources.
Use for: most factual questions. For verification use fact_check. For deep research use deep_search.`,
parameters: {
q: z.string().describe("Search query"),
n: z.coerce.number().int().min(1).max(6).default(3).describe("Pages to read (1-6)"),
time: z.enum(["day", "week", "month", "year"]).optional().describe("Time filter"),
},
implementation: safe_impl("search", async ({ q, n, time }, ctx) => {
ctx.status(`Searching: ${q}`);
const t = time ? timeMap[time] : undefined;
const r = await searchAndRead(q, n, t, ctx.signal);
return json({
q,
found: r.hits.length,
read: r.pages.filter((p: any) => !p.error).length,
publishers: r.domains.length,
results: r.pages.map((p: any) => ({
t: p.title,
u: p.url,
c: p.cred,
...(p.error ? { err: p.error } : { text: truncate(p.text, 2000) }),
})),
tip: r.domains.length === 1 ? "⚠️ Single source — verify independently" : `${r.domains.length} sources — cross-reference claims`,
});
}),
}),
// =========================================================================
// DEEP — Multi-angle research (parallel)
// =========================================================================
tool({
name: "deep",
description: `Parallel multi-angle research. Runs 3-5 searches simultaneously.
Use for: complex topics needing comprehensive coverage.`,
parameters: {
topic: z.string().describe("Research topic"),
angles: z.array(z.string()).max(5).default([]).describe("Custom angles (or auto)"),
},
implementation: safe_impl("deep", async ({ topic, angles }, ctx) => {
const defaults = [`${topic} overview`, `${topic} latest research`, `${topic} criticism`, `${topic} expert opinion`];
const searchAngles = angles.length ? angles : defaults;
const queries = searchAngles.map(a => angles.length ? `${topic} ${a}` : a);
ctx.status(`Researching ${searchAngles.length} angles…`);
// Parallel search
const results = await Promise.all(queries.map(async q => {
const hits = await search(q, 4, defaultTime(), ctx.signal);
const pages = [];
for (const h of hits.slice(0, 2)) {
const p = await fetchPage(h.url, timeout(), 4000, ctx.signal);
pages.push({ ...h, ...p, cred: credLevel(h.url) });
}
return { q, pages };
}));
const allDomains = [...new Set(results.flatMap(r => r.pages.map(p => rootDomain(p.url))))];
return json({
topic,
angles: results.map(r => ({
q: r.q,
pages: r.pages.map((p: any) => ({
t: p.title,
u: p.url,
c: p.cred,
...(p.error ? { err: p.error } : { text: truncate(p.text, 1500) }),
})),
})),
sources: allDomains.length,
tip: "Synthesize across angles. Note agreements and conflicts.",
});
}),
}),
// =========================================================================
// VERIFY — Fact-check a specific claim
// =========================================================================
tool({
name: "verify",
description: `Cross-check a claim against multiple sources. Returns verdict.
Use for: "is it true that…", verifying statistics, checking facts.`,
parameters: {
claim: z.string().describe("Claim to verify"),
},
implementation: safe_impl("verify", async ({ claim }, ctx) => {
ctx.status("Verifying claim…");
const queries = [
claim,
`"${truncate(claim, 50)}" false wrong`,
`evidence study "${truncate(claim, 60)}"`,
];
const results = await Promise.all(queries.map(async q => {
const hits = await search(q, 3, undefined, ctx.signal);
const pages = [];
for (const h of hits.slice(0, 1)) {
const p = await fetchPage(h.url, timeout(), 4000, ctx.signal);
pages.push({ ...h, ...p, cred: credLevel(h.url) });
}
return { q, pages };
}));
const allPages = results.flatMap(r => r.pages);
const highCred = allPages.filter(p => p.cred === "high" && !p.error);
return json({
claim,
sources_checked: allPages.length,
high_credibility: highCred.length,
evidence: allPages.map((p: any) => ({
t: p.title,
u: p.url,
c: p.cred,
...(p.error ? {} : { text: truncate(p.text, 1000) }),
})),
verdict: highCred.length >= 2 ? "LIKELY TRUE" : highCred.length === 1 ? "SINGLE SOURCE" : "UNVERIFIED",
tip: "Assign final verdict based on evidence strength and source quality.",
});
}),
}),
// =========================================================================
// FETCH — Read a specific URL
// =========================================================================
tool({
name: "fetch",
description: `Read a specific URL and extract content.
Use for: reading articles, verifying what a source actually says.`,
parameters: {
url: z.string().url().describe("URL to read"),
max: z.coerce.number().int().min(1000).max(15000).default(6000).describe("Max characters"),
},
implementation: safe_impl("fetch", async ({ url, max }, ctx) => {
ctx.status(`Reading: ${url}`);
const p = await fetchPage(url, timeout(), max, ctx.signal);
return json({
url,
title: p.title,
cred: credLevel(url),
meta: p.meta,
...(p.error ? { error: p.error } : { text: p.text }),
});
}),
}),
// =========================================================================
// NEWS — Recent news search
// =========================================================================
tool({
name: "news",
description: `Search recent news. Filters for journalistic sources.
Use for: current events, breaking news, recent developments.`,
parameters: {
q: z.string().describe("News query"),
time: z.enum(["day", "week", "month"]).default("week").describe("Time window"),
n: z.coerce.number().int().min(1).max(4).default(2).describe("Articles to read"),
},
implementation: safe_impl("news", async ({ q, time, n }, ctx) => {
ctx.status(`News (last ${time}): ${q}`);
const t = timeMap[time];
const r = await searchAndRead(q, n, t, ctx.signal);
return json({
q,
time,
results: r.pages.map((p: any) => ({
t: p.title,
u: p.url,
c: p.cred,
...(p.error ? {} : { text: truncate(p.text, 2000) }),
})),
tip: "Focus on established news outlets (high credibility).",
});
}),
}),
// =========================================================================
// ACADEMIC — Search papers
// =========================================================================
tool({
name: "academic",
description: `Search academic papers (arXiv, PubMed, Semantic Scholar).
Use for: scientific research, technical topics, peer-reviewed sources.`,
parameters: {
q: z.string().describe("Research topic"),
source: z.enum(["arxiv", "pubmed", "semantic", "all"]).default("all"),
year: z.coerce.number().int().min(2000).max(2030).optional().describe("From year"),
},
implementation: safe_impl("academic", async ({ q, source, year }, ctx) => {
ctx.status(`Academic search: ${q}`);
const yr = year ? ` ${year}` : "";
const sites: Record<string, string> = {
arxiv: "site:arxiv.org",
pubmed: "site:pubmed.ncbi.nlm.nih.gov",
semantic: "site:semanticscholar.org",
};
const queries = source === "all"
? Object.values(sites).map(s => `${s} ${q}${yr}`)
: [`${sites[source]} ${q}${yr}`];
const hits = (await Promise.all(queries.map(query => search(query, 5, undefined, ctx.signal)))).flat();
const unique = [...new Map(hits.map(h => [h.url, h])).values()].slice(0, 8);
return json({
q,
papers: unique.map(h => ({ t: h.title, u: h.url, s: h.snippet })),
tip: "Extract: authors, year, methodology, key findings.",
});
}),
}),
// =========================================================================
// CITATION — Generate formatted citation
// =========================================================================
tool({
name: "cite",
description: `Generate a citation for a URL. Formats: apa, mla, chicago, ieee.`,
parameters: {
url: z.string().url().describe("URL to cite"),
format: z.enum(["apa", "mla", "chicago", "ieee"]).default("apa"),
},
implementation: safe_impl("cite", async ({ url, format }, ctx) => {
ctx.status("Generating citation…");
const p = await fetchPage(url, timeout(), 2000, ctx.signal);
const d = new Date();
const metaDate = (p.meta as any)?.date;
const metaAuthor = (p.meta as any)?.author;
const year = metaDate ? new Date(metaDate).getFullYear() : "n.d.";
const author = metaAuthor ?? "Unknown";
const cits: Record<string, string> = {
apa: `${author} (${year}). ${p.title}. ${rootDomain(url)}. ${url}`,
mla: `"${p.title}." ${rootDomain(url)}, ${year}. ${url}`,
chicago: `${author}. "${p.title}." ${year}. ${url}`,
ieee: `[${author}, "${p.title}," ${rootDomain(url)}, ${year}. [Online]. Available: ${url}`,
};
return json({ url, format, citation: cits[format as keyof typeof cits] ?? cits.apa });
}),
}),
// =========================================================================
// CACHE — Clear cache
// =========================================================================
tool({
name: "clear_cache",
description: "Clear search cache for fresh results.",
parameters: {},
implementation: safe_impl("clear_cache", async () => {
const n = cache.size;
cache.clear();
return json({ cleared: n });
}),
}),
];
return tools;
};