src / toolsProvider.ts
import { tool, type Tool, type ToolsProviderController } from "@lmstudio/sdk";
import { z } from "zod";
import net from "node:net";
import { Readability } from "@mozilla/readability";
import { JSDOM, VirtualConsole } from "jsdom";
import {
configSchematics,
buildSearchUrl,
type SearchEngineId,
} from "./config";
/** Minimal Marionette client (protocol v3, length-prefixed JSON over TCP). */
class Marionette {
private socket: net.Socket | null = null;
private buffer = Buffer.alloc(0);
private msgId = 0;
private pending = new Map<number, { resolve: (v: any) => void; reject: (e: Error) => void }>();
private host: string;
private port: number;
constructor(host = "127.0.0.1", port = 2828) {
this.host = host;
this.port = port;
}
async connect(timeoutMs = 5000): Promise<void> {
if (this.socket) return;
await new Promise<void>((resolve, reject) => {
const sock = net.createConnection({ host: this.host, port: this.port }, () => {
sock.once("data", (data) => {
this.buffer = Buffer.concat([this.buffer, data]);
this.drain();
resolve();
});
});
sock.setTimeout(timeoutMs);
sock.on("timeout", () => {
sock.destroy();
reject(new Error(`Marionette connect timeout (${this.host}:${this.port})`));
});
sock.on("error", reject);
sock.on("data", (chunk) => {
this.buffer = Buffer.concat([this.buffer, chunk]);
this.drain();
});
sock.on("close", () => {
this.socket = null;
for (const [, p] of this.pending) p.reject(new Error("Marionette connection closed"));
this.pending.clear();
});
this.socket = sock;
});
}
private drain() {
while (true) {
const str = this.buffer.toString("utf8");
const colon = str.indexOf(":");
if (colon === -1) {
try {
const obj = JSON.parse(str);
if (obj.applicationType === "gecko") {
this.buffer = Buffer.alloc(0);
return;
}
} catch {
/* wait */
}
return;
}
const len = parseInt(str.slice(0, colon), 10);
if (Number.isNaN(len) || len < 0) return;
const total = colon + 1 + len;
if (this.buffer.length < total) return;
const body = this.buffer.subarray(colon + 1, total).toString("utf8");
this.buffer = this.buffer.subarray(total);
let msg: any;
try {
msg = JSON.parse(body);
} catch {
continue;
}
if (Array.isArray(msg) && msg[0] === 1) {
const id = msg[1];
const err = msg[2];
const result = msg[3];
const p = this.pending.get(id);
if (p) {
this.pending.delete(id);
if (err) p.reject(new Error(err.message || JSON.stringify(err)));
else p.resolve(result);
}
}
}
}
private send(command: string, params: Record<string, unknown> = {}): Promise<any> {
if (!this.socket) return Promise.reject(new Error("Not connected"));
const id = this.msgId++;
const payload = JSON.stringify([0, id, command, params]);
const frame = `${Buffer.byteLength(payload, "utf8")}:${payload}`;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
this.socket!.write(frame, "utf8", (err) => {
if (err) {
this.pending.delete(id);
reject(err);
}
});
});
}
async newSession(): Promise<void> {
await this.send("WebDriver:NewSession", { capabilities: {} });
}
async navigate(url: string): Promise<void> {
await this.send("WebDriver:Navigate", { url });
}
async executeScript(script: string, args: unknown[] = []): Promise<any> {
const r = await this.send("WebDriver:ExecuteScript", { script, args });
return r?.value;
}
async deleteSession(): Promise<void> {
try {
await this.send("WebDriver:DeleteSession", {});
} catch {
/* ignore */
}
}
close() {
if (this.socket) {
this.socket.destroy();
this.socket = null;
}
}
}
const MAX_ARTICLE_CHARS = 120_000;
const MAX_SEARCH_RESULTS = 8;
const MAX_SEARCH_CHARS = 12_000;
export type ExtractMode = "article" | "search";
function htmlForExtract(html: string): string {
return html
.replace(/<script\b[\s\S]*?<\/script>/gi, "")
.replace(/<style\b[\s\S]*?<\/style>/gi, "")
.replace(/<link\b[^>]*rel=["']?stylesheet["']?[^>]*>/gi, "");
}
function makeDom(html: string, url: string): JSDOM {
const virtualConsole = new VirtualConsole();
virtualConsole.on("error", (err: Error) => {
if (String(err?.message || err).includes("Could not parse CSS stylesheet")) return;
});
return new JSDOM(htmlForExtract(html), {
url,
contentType: "text/html",
pretendToBeVisual: false,
virtualConsole,
});
}
function fallbackArticle(document: Document): string {
const BLOCK = new Set([
"SCRIPT", "STYLE", "NOSCRIPT", "IFRAME", "SVG", "CANVAS", "TEMPLATE",
"NAV", "FOOTER", "HEADER", "ASIDE", "FORM", "BUTTON", "SELECT", "OPTION",
"INPUT", "TEXTAREA", "LABEL", "MENU", "DIALOG",
]);
const root =
document.querySelector("article") ||
document.querySelector("main") ||
document.querySelector("[role='main']") ||
document.body;
if (!root) return "";
const clone = root.cloneNode(true) as HTMLElement;
clone.querySelectorAll([...BLOCK].join(",")).forEach((n) => n.remove());
return (clone.textContent || "").replace(/\s+/g, " ").trim();
}
function isSearchEngineHost(host: string): boolean {
return /(?:^|\.)(google|bing|duckduckgo|brave|startpage|microsoft|yahoo|yandex|baidu)\./i.test(
host,
);
}
function isJunkTitle(title: string): boolean {
const t = title.trim();
if (t.length < 12 || t.length > 180) return true;
if (
/^(images?|videos?|news|maps?|shopping|books?|flights?|more|settings|tools|sign in|log in|privacy|terms|help|about|all|web|any time|past (hour|day|week|month|year))$/i.test(
t,
)
) {
return true;
}
if (/^(next|previous|page \d+)$/i.test(t) || /^\d+$/.test(t)) return true;
return false;
}
function extractSearchResults(
document: Document,
baseUrl: string,
): {
text: string;
results: { title: string; url: string; snippet: string }[];
} {
const results: { title: string; url: string; snippet: string }[] = [];
const seen = new Set<string>();
const resolve = (href: string): string | null => {
try {
const u = new URL(href, baseUrl);
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
const q =
u.searchParams.get("q") ||
u.searchParams.get("url") ||
u.searchParams.get("uddg");
if (q && /^https?:\/\//i.test(q)) return q.split("&")[0];
return u.href.split("#")[0];
} catch {
return null;
}
};
const anchors = Array.from(document.querySelectorAll("a[href]"));
for (const a of anchors) {
if (results.length >= MAX_SEARCH_RESULTS) break;
const title = (a.textContent || "").replace(/\s+/g, " ").trim();
if (isJunkTitle(title)) continue;
const abs = resolve(a.getAttribute("href") || "");
if (!abs) continue;
let host = "";
try {
host = new URL(abs).hostname;
} catch {
continue;
}
if (isSearchEngineHost(host)) continue;
const key = abs.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
let snippet = "";
const block = a.closest(
"li, article, .result, .g, .b_algo, .fdb, [data-testid='result'], [data-testid='web-vertical']",
);
if (block) {
const ps = block.querySelectorAll("p, span, div");
for (const el of Array.from(ps)) {
const t = (el.textContent || "").replace(/\s+/g, " ").trim();
if (t.length < 40 || t.length > 320) continue;
if (t === title || t.includes(title.slice(0, 40))) continue;
if (/cookie|consent|sign in|javascript/i.test(t)) continue;
snippet = t.slice(0, 220);
break;
}
}
results.push({ title, url: abs, snippet });
}
if (results.length === 0) {
return {
text: "No organic results parsed from the SERP (blocked, scripted, or layout changed).",
results: [],
};
}
const text = results
.map((r, i) => {
const sn = r.snippet ? `\n${r.snippet}` : "";
return `${i + 1}. ${r.title}\n${r.url}${sn}`;
})
.join("\n\n")
.slice(0, MAX_SEARCH_CHARS);
return { text, results };
}
function extractContent(
html: string,
url: string,
mode: ExtractMode,
): {
title: string;
text: string;
byline: string | null;
excerpt: string | null;
siteName: string | null;
method: string;
results?: { title: string; url: string; snippet: string }[];
} {
const dom = makeDom(html, url);
const doc = dom.window.document;
if (mode === "search") {
const { text, results } = extractSearchResults(doc, url);
return {
title: doc.title || "Search results",
text,
byline: null,
excerpt: null,
siteName: null,
method: results.length ? "search-results" : "search-empty",
results,
};
}
const article = new Readability(doc, { charThreshold: 100 }).parse();
if (article?.textContent && article.textContent.trim().length > 80) {
let text = article.textContent.replace(/\s+/g, " ").trim();
if (text.length > MAX_ARTICLE_CHARS) {
text = text.slice(0, MAX_ARTICLE_CHARS) + "\n…[truncated]";
}
return {
title: article.title || doc.title || "",
text,
byline: article.byline ?? null,
excerpt: article.excerpt ?? null,
siteName: article.siteName ?? null,
method: "readability",
};
}
let text = fallbackArticle(doc);
if (text.length > MAX_ARTICLE_CHARS) {
text = text.slice(0, MAX_ARTICLE_CHARS) + "\n…[truncated]";
}
return {
title: doc.title || "",
text,
byline: null,
excerpt: null,
siteName: null,
method: "fallback",
};
}
async function browseAndExtract(
client: Marionette,
url: string,
mode: ExtractMode,
status: (s: string) => void,
): Promise<Record<string, unknown>> {
status(`Navigating to ${url}…`);
await client.navigate(url);
await client.executeScript(`
return new Promise(r => {
if (document.readyState === 'complete') r();
else window.addEventListener('load', () => r(), { once: true });
setTimeout(r, 5000);
});
`);
if (mode === "search") {
await client.executeScript(`return new Promise(r => setTimeout(r, 1500));`);
}
status("Fetching page HTML…");
const html: string = await client.executeScript(
"return document.documentElement.outerHTML;",
);
const pageUrl: string =
(await client.executeScript("return location.href;")) || url;
status(mode === "search" ? "Extracting search results…" : "Extracting readable content…");
const extracted = extractContent(html, pageUrl, mode);
if (mode === "search") {
return {
url: pageUrl,
title: extracted.title,
content: extracted.text,
results: extracted.results ?? [],
count: extracted.results?.length ?? 0,
method: extracted.method,
};
}
return {
url: pageUrl,
title: extracted.title,
byline: extracted.byline,
siteName: extracted.siteName,
excerpt: extracted.excerpt,
content: extracted.text,
chars: extracted.text.length,
method: extracted.method,
};
}
export async function toolsProvider(ctl: ToolsProviderController): Promise<Tool[]> {
const visitWebsite = tool({
name: "visit_website",
description:
"Browse a URL with the running Firefox Marionette instance and return cleaned main-content text (Mozilla Readability).",
parameters: {
url: z.string().url().describe("Absolute URL to visit"),
},
implementation: async ({ url }, ctx) => {
const { status, signal } = ctx;
const cfg = ctl.getPluginConfig(configSchematics);
const host = (cfg.get("marionetteHost") as string) || "127.0.0.1";
const port = Number(cfg.get("marionettePort")) || 2828;
status("Connecting to Firefox Marionette…");
const client = new Marionette(host, port);
try {
if (signal.aborted) return { error: "Aborted" };
await client.connect();
status("Starting session…");
await client.newSession();
const out = await browseAndExtract(client, url, "article", status);
await client.deleteSession();
return out;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return {
error: msg,
hint: "Ensure Firefox is running with --marionette on the configured host/port.",
};
} finally {
client.close();
}
},
});
const webSearch = tool({
name: "web_search",
description:
"Search the web with the configured search engine (plugin setting). Opens the results page in Firefox Marionette and returns a compact ranked list of titles, URLs, and snippets.",
parameters: {
query: z.string().min(1).describe("Search query"),
},
implementation: async ({ query }, ctx) => {
const { status, signal } = ctx;
const cfg = ctl.getPluginConfig(configSchematics);
const host = (cfg.get("marionetteHost") as string) || "127.0.0.1";
const port = Number(cfg.get("marionettePort")) || 2828;
const engine = (cfg.get("searchEngine") as SearchEngineId) || "duckduckgo";
const searchUrl = buildSearchUrl(engine, query);
status("Connecting to Firefox Marionette…");
const client = new Marionette(host, port);
try {
if (signal.aborted) return { error: "Aborted" };
await client.connect();
status("Starting session…");
await client.newSession();
status(`Searching via ${engine}: ${query}`);
const out = await browseAndExtract(client, searchUrl, "search", status);
await client.deleteSession();
return { query, engine, searchUrl, ...out };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return {
error: msg,
query,
engine,
searchUrl,
hint: "Ensure Firefox is running with --marionette on the configured host/port.",
};
} finally {
client.close();
}
},
});
return [visitWebsite, webSearch];
}
src / toolsProvider.ts
import { tool, type Tool, type ToolsProviderController } from "@lmstudio/sdk";
import { z } from "zod";
import net from "node:net";
import { Readability } from "@mozilla/readability";
import { JSDOM, VirtualConsole } from "jsdom";
import {
configSchematics,
buildSearchUrl,
type SearchEngineId,
} from "./config";
/** Minimal Marionette client (protocol v3, length-prefixed JSON over TCP). */
class Marionette {
private socket: net.Socket | null = null;
private buffer = Buffer.alloc(0);
private msgId = 0;
private pending = new Map<number, { resolve: (v: any) => void; reject: (e: Error) => void }>();
private host: string;
private port: number;
constructor(host = "127.0.0.1", port = 2828) {
this.host = host;
this.port = port;
}
async connect(timeoutMs = 5000): Promise<void> {
if (this.socket) return;
await new Promise<void>((resolve, reject) => {
const sock = net.createConnection({ host: this.host, port: this.port }, () => {
sock.once("data", (data) => {
this.buffer = Buffer.concat([this.buffer, data]);
this.drain();
resolve();
});
});
sock.setTimeout(timeoutMs);
sock.on("timeout", () => {
sock.destroy();
reject(new Error(`Marionette connect timeout (${this.host}:${this.port})`));
});
sock.on("error", reject);
sock.on("data", (chunk) => {
this.buffer = Buffer.concat([this.buffer, chunk]);
this.drain();
});
sock.on("close", () => {
this.socket = null;
for (const [, p] of this.pending) p.reject(new Error("Marionette connection closed"));
this.pending.clear();
});
this.socket = sock;
});
}
private drain() {
while (true) {
const str = this.buffer.toString("utf8");
const colon = str.indexOf(":");
if (colon === -1) {
try {
const obj = JSON.parse(str);
if (obj.applicationType === "gecko") {
this.buffer = Buffer.alloc(0);
return;
}
} catch {
/* wait */
}
return;
}
const len = parseInt(str.slice(0, colon), 10);
if (Number.isNaN(len) || len < 0) return;
const total = colon + 1 + len;
if (this.buffer.length < total) return;
const body = this.buffer.subarray(colon + 1, total).toString("utf8");
this.buffer = this.buffer.subarray(total);
let msg: any;
try {
msg = JSON.parse(body);
} catch {
continue;
}
if (Array.isArray(msg) && msg[0] === 1) {
const id = msg[1];
const err = msg[2];
const result = msg[3];
const p = this.pending.get(id);
if (p) {
this.pending.delete(id);
if (err) p.reject(new Error(err.message || JSON.stringify(err)));
else p.resolve(result);
}
}
}
}
private send(command: string, params: Record<string, unknown> = {}): Promise<any> {
if (!this.socket) return Promise.reject(new Error("Not connected"));
const id = this.msgId++;
const payload = JSON.stringify([0, id, command, params]);
const frame = `${Buffer.byteLength(payload, "utf8")}:${payload}`;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
this.socket!.write(frame, "utf8", (err) => {
if (err) {
this.pending.delete(id);
reject(err);
}
});
});
}
async newSession(): Promise<void> {
await this.send("WebDriver:NewSession", { capabilities: {} });
}
async navigate(url: string): Promise<void> {
await this.send("WebDriver:Navigate", { url });
}
async executeScript(script: string, args: unknown[] = []): Promise<any> {
const r = await this.send("WebDriver:ExecuteScript", { script, args });
return r?.value;
}
async deleteSession(): Promise<void> {
try {
await this.send("WebDriver:DeleteSession", {});
} catch {
/* ignore */
}
}
close() {
if (this.socket) {
this.socket.destroy();
this.socket = null;
}
}
}
const MAX_ARTICLE_CHARS = 120_000;
const MAX_SEARCH_RESULTS = 8;
const MAX_SEARCH_CHARS = 12_000;
export type ExtractMode = "article" | "search";
function htmlForExtract(html: string): string {
return html
.replace(/<script\b[\s\S]*?<\/script>/gi, "")
.replace(/<style\b[\s\S]*?<\/style>/gi, "")
.replace(/<link\b[^>]*rel=["']?stylesheet["']?[^>]*>/gi, "");
}
function makeDom(html: string, url: string): JSDOM {
const virtualConsole = new VirtualConsole();
virtualConsole.on("error", (err: Error) => {
if (String(err?.message || err).includes("Could not parse CSS stylesheet")) return;
});
return new JSDOM(htmlForExtract(html), {
url,
contentType: "text/html",
pretendToBeVisual: false,
virtualConsole,
});
}
function fallbackArticle(document: Document): string {
const BLOCK = new Set([
"SCRIPT", "STYLE", "NOSCRIPT", "IFRAME", "SVG", "CANVAS", "TEMPLATE",
"NAV", "FOOTER", "HEADER", "ASIDE", "FORM", "BUTTON", "SELECT", "OPTION",
"INPUT", "TEXTAREA", "LABEL", "MENU", "DIALOG",
]);
const root =
document.querySelector("article") ||
document.querySelector("main") ||
document.querySelector("[role='main']") ||
document.body;
if (!root) return "";
const clone = root.cloneNode(true) as HTMLElement;
clone.querySelectorAll([...BLOCK].join(",")).forEach((n) => n.remove());
return (clone.textContent || "").replace(/\s+/g, " ").trim();
}
function isSearchEngineHost(host: string): boolean {
return /(?:^|\.)(google|bing|duckduckgo|brave|startpage|microsoft|yahoo|yandex|baidu)\./i.test(
host,
);
}
function isJunkTitle(title: string): boolean {
const t = title.trim();
if (t.length < 12 || t.length > 180) return true;
if (
/^(images?|videos?|news|maps?|shopping|books?|flights?|more|settings|tools|sign in|log in|privacy|terms|help|about|all|web|any time|past (hour|day|week|month|year))$/i.test(
t,
)
) {
return true;
}
if (/^(next|previous|page \d+)$/i.test(t) || /^\d+$/.test(t)) return true;
return false;
}
function extractSearchResults(
document: Document,
baseUrl: string,
): {
text: string;
results: { title: string; url: string; snippet: string }[];
} {
const results: { title: string; url: string; snippet: string }[] = [];
const seen = new Set<string>();
const resolve = (href: string): string | null => {
try {
const u = new URL(href, baseUrl);
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
const q =
u.searchParams.get("q") ||
u.searchParams.get("url") ||
u.searchParams.get("uddg");
if (q && /^https?:\/\//i.test(q)) return q.split("&")[0];
return u.href.split("#")[0];
} catch {
return null;
}
};
const anchors = Array.from(document.querySelectorAll("a[href]"));
for (const a of anchors) {
if (results.length >= MAX_SEARCH_RESULTS) break;
const title = (a.textContent || "").replace(/\s+/g, " ").trim();
if (isJunkTitle(title)) continue;
const abs = resolve(a.getAttribute("href") || "");
if (!abs) continue;
let host = "";
try {
host = new URL(abs).hostname;
} catch {
continue;
}
if (isSearchEngineHost(host)) continue;
const key = abs.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
let snippet = "";
const block = a.closest(
"li, article, .result, .g, .b_algo, .fdb, [data-testid='result'], [data-testid='web-vertical']",
);
if (block) {
const ps = block.querySelectorAll("p, span, div");
for (const el of Array.from(ps)) {
const t = (el.textContent || "").replace(/\s+/g, " ").trim();
if (t.length < 40 || t.length > 320) continue;
if (t === title || t.includes(title.slice(0, 40))) continue;
if (/cookie|consent|sign in|javascript/i.test(t)) continue;
snippet = t.slice(0, 220);
break;
}
}
results.push({ title, url: abs, snippet });
}
if (results.length === 0) {
return {
text: "No organic results parsed from the SERP (blocked, scripted, or layout changed).",
results: [],
};
}
const text = results
.map((r, i) => {
const sn = r.snippet ? `\n${r.snippet}` : "";
return `${i + 1}. ${r.title}\n${r.url}${sn}`;
})
.join("\n\n")
.slice(0, MAX_SEARCH_CHARS);
return { text, results };
}
function extractContent(
html: string,
url: string,
mode: ExtractMode,
): {
title: string;
text: string;
byline: string | null;
excerpt: string | null;
siteName: string | null;
method: string;
results?: { title: string; url: string; snippet: string }[];
} {
const dom = makeDom(html, url);
const doc = dom.window.document;
if (mode === "search") {
const { text, results } = extractSearchResults(doc, url);
return {
title: doc.title || "Search results",
text,
byline: null,
excerpt: null,
siteName: null,
method: results.length ? "search-results" : "search-empty",
results,
};
}
const article = new Readability(doc, { charThreshold: 100 }).parse();
if (article?.textContent && article.textContent.trim().length > 80) {
let text = article.textContent.replace(/\s+/g, " ").trim();
if (text.length > MAX_ARTICLE_CHARS) {
text = text.slice(0, MAX_ARTICLE_CHARS) + "\n…[truncated]";
}
return {
title: article.title || doc.title || "",
text,
byline: article.byline ?? null,
excerpt: article.excerpt ?? null,
siteName: article.siteName ?? null,
method: "readability",
};
}
let text = fallbackArticle(doc);
if (text.length > MAX_ARTICLE_CHARS) {
text = text.slice(0, MAX_ARTICLE_CHARS) + "\n…[truncated]";
}
return {
title: doc.title || "",
text,
byline: null,
excerpt: null,
siteName: null,
method: "fallback",
};
}
async function browseAndExtract(
client: Marionette,
url: string,
mode: ExtractMode,
status: (s: string) => void,
): Promise<Record<string, unknown>> {
status(`Navigating to ${url}…`);
await client.navigate(url);
await client.executeScript(`
return new Promise(r => {
if (document.readyState === 'complete') r();
else window.addEventListener('load', () => r(), { once: true });
setTimeout(r, 5000);
});
`);
if (mode === "search") {
await client.executeScript(`return new Promise(r => setTimeout(r, 1500));`);
}
status("Fetching page HTML…");
const html: string = await client.executeScript(
"return document.documentElement.outerHTML;",
);
const pageUrl: string =
(await client.executeScript("return location.href;")) || url;
status(mode === "search" ? "Extracting search results…" : "Extracting readable content…");
const extracted = extractContent(html, pageUrl, mode);
if (mode === "search") {
return {
url: pageUrl,
title: extracted.title,
content: extracted.text,
results: extracted.results ?? [],
count: extracted.results?.length ?? 0,
method: extracted.method,
};
}
return {
url: pageUrl,
title: extracted.title,
byline: extracted.byline,
siteName: extracted.siteName,
excerpt: extracted.excerpt,
content: extracted.text,
chars: extracted.text.length,
method: extracted.method,
};
}
export async function toolsProvider(ctl: ToolsProviderController): Promise<Tool[]> {
const visitWebsite = tool({
name: "visit_website",
description:
"Browse a URL with the running Firefox Marionette instance and return cleaned main-content text (Mozilla Readability).",
parameters: {
url: z.string().url().describe("Absolute URL to visit"),
},
implementation: async ({ url }, ctx) => {
const { status, signal } = ctx;
const cfg = ctl.getPluginConfig(configSchematics);
const host = (cfg.get("marionetteHost") as string) || "127.0.0.1";
const port = Number(cfg.get("marionettePort")) || 2828;
status("Connecting to Firefox Marionette…");
const client = new Marionette(host, port);
try {
if (signal.aborted) return { error: "Aborted" };
await client.connect();
status("Starting session…");
await client.newSession();
const out = await browseAndExtract(client, url, "article", status);
await client.deleteSession();
return out;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return {
error: msg,
hint: "Ensure Firefox is running with --marionette on the configured host/port.",
};
} finally {
client.close();
}
},
});
const webSearch = tool({
name: "web_search",
description:
"Search the web with the configured search engine (plugin setting). Opens the results page in Firefox Marionette and returns a compact ranked list of titles, URLs, and snippets.",
parameters: {
query: z.string().min(1).describe("Search query"),
},
implementation: async ({ query }, ctx) => {
const { status, signal } = ctx;
const cfg = ctl.getPluginConfig(configSchematics);
const host = (cfg.get("marionetteHost") as string) || "127.0.0.1";
const port = Number(cfg.get("marionettePort")) || 2828;
const engine = (cfg.get("searchEngine") as SearchEngineId) || "duckduckgo";
const searchUrl = buildSearchUrl(engine, query);
status("Connecting to Firefox Marionette…");
const client = new Marionette(host, port);
try {
if (signal.aborted) return { error: "Aborted" };
await client.connect();
status("Starting session…");
await client.newSession();
status(`Searching via ${engine}: ${query}`);
const out = await browseAndExtract(client, searchUrl, "search", status);
await client.deleteSession();
return { query, engine, searchUrl, ...out };
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return {
error: msg,
query,
engine,
searchUrl,
hint: "Ensure Firefox is running with --marionette on the configured host/port.",
};
} finally {
client.close();
}
},
});
return [visitWebsite, webSearch];
}