import { Type } from "@sinclair/typebox"; import { StringEnum } from "@mariozechner/pi-ai"; import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { trimOutput } from "@hyperprior/pi-shared"; import { createBrowserResultDetails } from "../src/result-schema.ts"; const BrowserAction = StringEnum(["open", "navigate", "snapshot", "extract", "links", "close"] as const, { description: "Browser-like action", }); const BrowserParams = Type.Object({ action: BrowserAction, url: Type.Optional(Type.String({ description: "Target URL" })), max_links: Type.Optional(Type.Number({ minimum: 1, maximum: 200, default: 40, description: "Max links to return for links action" })), include_text: Type.Optional(Type.Boolean({ description: "For snapshot: include extracted text" })), include_meta: Type.Optional(Type.Boolean({ description: "For snapshot: include response metadata" })), timeout: Type.Optional(Type.Number({ minimum: 1_000, maximum: 180_000, default: 30_000, description: "Fetch timeout (ms)" })), }); type BrowserState = { url: string; finalUrl: string; status: number; statusText: string; contentType: string; html: string; text: string; links: string[]; }; function normalizeUrl(raw: string): string { const u = raw.trim(); if (!u) return ""; if (/^https?:\/\//i.test(u)) return u; return `https://${u}`; } function htmlToText(html: string): string { const cleaned = html .replace(//gi, "") .replace(//gi, "") .replace(//gi, ""); const withLineBreaks = cleaned .replace(/<\/(div|p|section|article|header|footer|main|li|ol|ul|h[1-6])>/gi, "\n") .replace(//gi, "\n") .replace(/<[^>]+>/g, " "); return trimOutput(withLineBreaks.replace(/\s+/g, " ").trim(), 16_000); } function htmlToTitle(html: string): string { const match = html.match(/([\s\S]*?)<\/title>/i); return match ? match[1].trim() : ""; } function normalizeLink(rawBase: string, href: string): string { const trimmed = href.trim(); if (!trimmed) return ""; try { return new URL(trimmed, rawBase).href; } catch { return ""; } } function extractLinks(html: string, base: string, maxLinks = 40): string[] { const matches = Array.from(html.matchAll(/<a\b[^>]*\bhref\s*=\s*(["'])(.*?)\1/gi)); const links = new Set<string>(); for (const match of matches) { const href = match[2]; const url = normalizeLink(base, href); if (!url) continue; if (url.startsWith("mailto:") || url.startsWith("javascript:") || url.startsWith("tel:")) continue; links.add(url); if (links.size >= maxLinks) break; } return Array.from(links); } async function fetchPage(url: string, timeout: number) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); try { const response = await fetch(url, { headers: { "user-agent": "hyperpi-browser/0.2", accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", }, signal: controller.signal, }); const contentType = response.headers.get("content-type") || ""; const text = await response.text(); return { ok: response.ok, finalUrl: response.url, status: response.status, statusText: response.statusText, contentType, text, textOnly: htmlToText(text), title: htmlToTitle(text), links: extractLinks(text, response.url), }; } finally { clearTimeout(timer); } } function buildSnapshot(state: BrowserState, includeMeta: boolean, includeText: boolean): string { const parts: string[] = []; if (includeMeta) { parts.push(`URL: ${state.finalUrl}`); parts.push(`Status: ${state.status} ${state.statusText}`); if (state.contentType) parts.push(`Content-Type: ${state.contentType}`); parts.push(`Links: ${state.links.length}`); } if (state.text) { const title = htmlToTitle(state.html); if (title) parts.push(`\nTitle: ${title}`); } if (includeText) { const body = htmlToText(state.html); if (body) parts.push(`\nText:\n${body}`); } return parts.join("\n"); } export default function (pi: ExtensionAPI) { let session: BrowserState | null = null; const isValidOpenTarget = (url?: string) => { if (!url) return false; const normalized = normalizeUrl(url); return /^https?:\/\//i.test(normalized); }; pi.registerTool({ name: "hyperpi_browser", label: "Browser", description: "Lightweight browser helper using remote fetch + content extraction. Supports open/snapshot/extract/links/close semantics.", parameters: BrowserParams, async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { const action = params.action; const timeout = params.timeout || 30_000; if ((action === "open" || action === "navigate" || action === "snapshot" || action === "extract" || action === "links") && !isValidOpenTarget(params.url || session?.url)) { return { content: [{ type: "text", text: `Action '${action}' requires url when no active session exists.` }], isError: true, }; } if (action === "close") { session = null; return { content: [{ type: "text", text: "Browser session closed." }], }; } if (action === "open" || action === "navigate") { const target = normalizeUrl(params.url || session!.url); const result = await fetchPage(target, timeout); if (!result.ok) { return { content: [{ type: "text", text: `Unable to load ${target}: ${result.status} ${result.statusText}`, }], isError: true, }; } session = { url: params.url || session!.url, finalUrl: result.finalUrl, status: result.status, statusText: result.statusText, contentType: result.contentType, html: result.text, text: result.textOnly, links: result.links, }; const includeMeta = params.include_meta !== false; const includeText = params.include_text !== false; return { content: [{ type: "text", text: buildSnapshot(session, includeMeta, includeText), }], details: createBrowserResultDetails(action, { url: session.url, finalUrl: session.finalUrl, status: session.status, title: htmlToTitle(session.html), statusText: session.statusText, contentType: session.contentType, links: session.links.length, }), }; } if (!session) { return { content: [{ type: "text", text: "No active browser page. Use action='open' first." }], isError: true, }; } if (action === "snapshot") { const includeMeta = params.include_meta !== false; const includeText = params.include_text !== false; return { content: [{ type: "text", text: buildSnapshot( session, includeMeta, includeText, ), }], details: createBrowserResultDetails(action, { url: session.url, finalUrl: session.finalUrl, status: session.status, statusText: session.statusText, links: session.links.length, contentType: session.contentType, }), }; } if (action === "extract") { return { content: [{ type: "text", text: trimOutput(htmlToText(session.html), 16_000), }], details: createBrowserResultDetails(action, { url: session.url, finalUrl: session.finalUrl, textLength: session.text.length, }), }; } const links = extractLinks(session.html, session.finalUrl, params.max_links || 40); session.links = links; return { content: [ { type: "text", text: links.length ? links.map((link, idx) => `${idx + 1}. ${link}`).join("\n") : "No links found.", }, ], details: createBrowserResultDetails(action, { url: session.url, finalUrl: session.finalUrl, count: links.length, links: links.length, }), }; }, }); }