@houndmcp/hound-mcp-pi 10.4.1 → 10.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/extensions/hound.ts +723 -722
  2. package/package.json +1 -1
@@ -1,722 +1,723 @@
1
- /**
2
- * Hound MCP extension for Pi agent.
3
- *
4
- * Six native tools, all routed through a single long-lived Hound MCP stdio
5
- * subprocess (singleton - Hound's startup prewarm persists for the whole
6
- * session, zero re-launch cost per call):
7
- * - web_fetch -> mcp_smart_fetch (auto anti-bot, PDF, archive fallback)
8
- * - web_search -> mcp_smart_search (10 keyless backends, consensus rank)
9
- * - web_crawl -> mcp_smart_crawl (best-first + sitemap-one-fetch map)
10
- * - web_screenshot -> mcp_screenshot (image capture for multimodal agents)
11
- * - cache_clear -> cache_clear (clear fetch cache)
12
- * - hound_version -> version (hound version + update status)
13
- *
14
- * Hound is fully keyless ($0, no API key, no account). The subprocess is
15
- * resolved from PATH so it tracks the installed Hound version automatically.
16
- *
17
- * Install: pip install hound-mcp[all]
18
- * Then: pi install npm:@houndmcp/hound-mcp-pi
19
- */
20
-
21
- import { Type } from "typebox";
22
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
23
- import { Text } from "@earendil-works/pi-tui";
24
- import { spawn, execSync, type ChildProcess } from "node:child_process";
25
- import * as path from "node:path";
26
- import * as os from "node:os";
27
- import * as fs from "node:fs";
28
-
29
- // -- Version (read from package.json at load time) --
30
-
31
- function readPkgVersion(): string {
32
- try {
33
- const pkgPath = path.join(__dirname, "..", "package.json");
34
- const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
35
- return pkg.version || "unknown";
36
- } catch {
37
- return "unknown";
38
- }
39
- }
40
-
41
- const EXTENSION_VERSION = readPkgVersion();
42
-
43
- // -- Hound executable resolution (lazy, re-tried on each ensureReady if null) --
44
-
45
- function resolveHoundExe(): string | null {
46
- // 1. PATH lookup (covers 99% of installs)
47
- try {
48
- const cmd = process.platform === "win32" ? "where hound.exe" : "which hound";
49
- const out = execSync(cmd, {
50
- stdio: ["ignore", "pipe", "ignore"],
51
- timeout: 2000,
52
- windowsHide: true,
53
- }).toString().trim();
54
- const first = out.split(/\r?\n/)[0];
55
- if (first && fs.existsSync(first)) return first;
56
- } catch {}
57
- // 2. Fallback: common Windows install paths (multiple Python versions)
58
- if (process.platform === "win32") {
59
- const home = os.homedir();
60
- for (const v of ["Python311", "Python312", "Python313", "Python310"]) {
61
- const p = path.join(home, "AppData", "Local", "Programs", "Python", v, "Scripts", "hound.exe");
62
- if (fs.existsSync(p)) return p;
63
- }
64
- }
65
- return null;
66
- }
67
-
68
- const INIT_TIMEOUT_MS = 15_000;
69
- const CALL_TIMEOUT_MS = 90_000;
70
- const CRAWL_TIMEOUT_MS = 150_000;
71
- const INIT_ATTEMPTS = 3;
72
- const INIT_BACKOFF_MS = 400;
73
-
74
- // -- MCP Stdio Client (singleton subprocess + JSON-RPC) --
75
-
76
- interface Pending {
77
- resolve: (v: any) => void;
78
- reject: (e: Error) => void;
79
- timer: NodeJS.Timeout;
80
- }
81
-
82
- class HoundClient {
83
- private proc: ChildProcess | null = null;
84
- private houndExe: string | null = null;
85
- private houndVersion: string | null = null;
86
- private ready: Promise<boolean> | null = null;
87
- private initInFlight: Promise<boolean> | null = null;
88
- private nextId = 0;
89
- private pending = new Map<number, Pending>();
90
- private stdoutBuf = "";
91
- private lastStderr = "";
92
-
93
- getExe(): string | null { return this.houndExe; }
94
-
95
- hasExe(): boolean { return this.houndExe !== null; }
96
-
97
- async ensureReady(): Promise<boolean> {
98
- if (this.ready) return this.ready;
99
- // Re-resolve hound exe if not found yet (handles install-after-load)
100
- if (!this.houndExe) {
101
- this.houndExe = resolveHoundExe();
102
- }
103
- if (!this.houndExe) return false;
104
- if (this.initInFlight) return this.initInFlight;
105
- const p = this._initWithRetry();
106
- this.initInFlight = p;
107
- p.then((ok) => {
108
- this.initInFlight = null;
109
- if (ok) this.ready = p;
110
- else this.ready = null;
111
- });
112
- return p;
113
- }
114
-
115
- private async _initWithRetry(): Promise<boolean> {
116
- for (let attempt = 1; attempt <= INIT_ATTEMPTS; attempt++) {
117
- if (await this._initOnce()) return true;
118
- if (attempt < INIT_ATTEMPTS) await new Promise((r) => setTimeout(r, INIT_BACKOFF_MS));
119
- }
120
- return false;
121
- }
122
-
123
- private _initOnce(): Promise<boolean> {
124
- // Kill any existing process before spawning a new one (prevents orphans)
125
- if (this.proc && !this.proc.killed) {
126
- try { this.proc.stdin?.end(); } catch {}
127
- try { this.proc.kill(); } catch {}
128
- }
129
- this.proc = null;
130
-
131
- return new Promise<boolean>((resolve) => {
132
- let settled = false;
133
- let initTimer: NodeJS.Timeout | undefined;
134
- let stderrBuf = "";
135
- const done = (ok: boolean) => {
136
- if (settled) return;
137
- settled = true;
138
- if (initTimer) clearTimeout(initTimer);
139
- if (!ok) this._killProcess();
140
- resolve(ok);
141
- };
142
- try {
143
- this.proc = spawn(this.houndExe!, [], {
144
- stdio: ["pipe", "pipe", "pipe"],
145
- windowsHide: true,
146
- env: { ...process.env },
147
- });
148
- } catch (e: any) {
149
- this.lastStderr = `spawn threw: ${e?.message ?? e}`;
150
- done(false);
151
- return;
152
- }
153
- this.proc.on("error", (e: any) => {
154
- this.lastStderr = `spawn error: ${e?.message ?? e}`;
155
- done(false);
156
- });
157
- this.proc.on("close", (code, signal) => {
158
- for (const [, { reject, timer }] of this.pending) {
159
- clearTimeout(timer);
160
- reject(new Error("Hound closed"));
161
- }
162
- this.pending.clear();
163
- if (!settled) this.lastStderr = `exited before init (code=${code} signal=${signal})`;
164
- this.ready = null;
165
- done(false);
166
- });
167
- this.proc.stderr?.on("data", (chunk: Buffer) => {
168
- stderrBuf += chunk.toString("utf-8");
169
- if (stderrBuf.length > 4000) stderrBuf = stderrBuf.slice(-4000);
170
- });
171
- this.proc.stdout?.on("data", (chunk: Buffer) => {
172
- this.stdoutBuf += chunk.toString("utf-8");
173
- this._drain();
174
- });
175
- initTimer = setTimeout(() => {
176
- this.lastStderr = `initialize timed out after ${INIT_TIMEOUT_MS}ms (dead spawn)`;
177
- done(false);
178
- }, INIT_TIMEOUT_MS);
179
- const id = ++this.nextId;
180
- this.pending.set(id, {
181
- resolve: () => {
182
- this._notify("notifications/initialized", {});
183
- this.lastStderr = "";
184
- done(true);
185
- },
186
- reject: (e: Error) => {
187
- this.lastStderr = `initialize rejected: ${e.message}`;
188
- done(false);
189
- },
190
- timer: initTimer,
191
- });
192
- try {
193
- this.proc.stdin!.write(
194
- JSON.stringify({
195
- jsonrpc: "2.0",
196
- method: "initialize",
197
- params: {
198
- protocolVersion: "2025-03-26",
199
- capabilities: {},
200
- clientInfo: { name: "pi-hound", version: EXTENSION_VERSION },
201
- },
202
- id,
203
- }) + "\n",
204
- );
205
- } catch (e: any) {
206
- this.pending.delete(id);
207
- this.lastStderr = `stdin write failed: ${e?.message ?? e}`;
208
- done(false);
209
- }
210
- });
211
- }
212
-
213
- private _drain() {
214
- let idx: number;
215
- while ((idx = this.stdoutBuf.indexOf("\n")) !== -1) {
216
- const line = this.stdoutBuf.slice(0, idx).trim();
217
- this.stdoutBuf = this.stdoutBuf.slice(idx + 1);
218
- if (!line) continue;
219
- try {
220
- const msg = JSON.parse(line);
221
- if (msg.id != null && this.pending.has(msg.id)) {
222
- const { resolve, reject, timer } = this.pending.get(msg.id)!;
223
- this.pending.delete(msg.id);
224
- clearTimeout(timer);
225
- if (msg.error) reject(new Error(msg.error?.message ?? JSON.stringify(msg.error)));
226
- else resolve(msg.result);
227
- }
228
- } catch {}
229
- }
230
- }
231
-
232
- private _notify(method: string, params: any) {
233
- if (!this.proc || this.proc.killed) return;
234
- try {
235
- this.proc.stdin!.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n");
236
- } catch {}
237
- }
238
-
239
- async call(
240
- name: string,
241
- args: Record<string, any>,
242
- timeoutMs = CALL_TIMEOUT_MS,
243
- signal?: AbortSignal,
244
- ): Promise<any> {
245
- const ready = await this.ensureReady();
246
- if (!ready) {
247
- const hint = this.houndExe
248
- ? `Hound failed to start after ${INIT_ATTEMPTS} attempts. ${this.lastStderr || "Silent dead hang."} Run: hound --doctor`
249
- : "Hound not found. Install: pip install hound-mcp[all]";
250
- throw new Error(hint);
251
- }
252
- return new Promise((resolve, reject) => {
253
- if (!this.proc || this.proc.killed) {
254
- reject(new Error("Hound not running"));
255
- return;
256
- }
257
- if (signal?.aborted) {
258
- reject(new Error(`Hound cancelled: ${name}`));
259
- return;
260
- }
261
- const id = ++this.nextId;
262
- let timer: NodeJS.Timeout;
263
- let onAbort: () => void;
264
- const cleanup = () => {
265
- clearTimeout(timer);
266
- this.pending.delete(id);
267
- if (signal && onAbort) signal.removeEventListener("abort", onAbort);
268
- };
269
- timer = setTimeout(() => {
270
- cleanup();
271
- reject(new Error(`Hound timeout: ${name}`));
272
- }, timeoutMs);
273
- onAbort = () => {
274
- cleanup();
275
- reject(new Error(`Hound cancelled: ${name}`));
276
- };
277
- signal?.addEventListener("abort", onAbort, { once: true });
278
- this.pending.set(id, {
279
- resolve: (v: any) => { cleanup(); resolve(v); },
280
- reject: (e: Error) => { cleanup(); reject(e); },
281
- timer,
282
- });
283
- try {
284
- this.proc.stdin!.write(
285
- JSON.stringify({ jsonrpc: "2.0", method: "tools/call", params: { name, arguments: args }, id }) + "\n",
286
- );
287
- } catch (e) {
288
- cleanup();
289
- reject(e as Error);
290
- }
291
- });
292
- }
293
-
294
- private _killProcess() {
295
- if (this.proc && !this.proc.killed) {
296
- try { this.proc.stdin?.end(); } catch {}
297
- try { this.proc.kill(); } catch {}
298
- }
299
- this.proc = null;
300
- this.ready = null;
301
- this.initInFlight = null;
302
- for (const [, { timer }] of this.pending) clearTimeout(timer);
303
- this.pending.clear();
304
- }
305
-
306
- kill() { this._killProcess(); }
307
- }
308
-
309
- const hound = new HoundClient();
310
-
311
- // -- Helpers --
312
-
313
- function getText(result: any): string {
314
- const content = result?.content ?? [];
315
- return content.filter((c: any) => c?.type === "text").map((c: any) => c.text).join("\n") || "(no output)";
316
- }
317
-
318
- function getImages(result: any): any[] {
319
- const content = result?.content ?? [];
320
- return content.filter((c: any) => c?.type === "image");
321
- }
322
-
323
- function tryJson(text: string): any {
324
- try { return JSON.parse(text); } catch { return {}; }
325
- }
326
-
327
- function pick(params: Record<string, any>, keys: string[]): Record<string, any> {
328
- const out: Record<string, any> = {};
329
- for (const k of keys) {
330
- if (params[k] !== undefined) out[k] = params[k];
331
- }
332
- return out;
333
- }
334
-
335
- // Map technical error strings from the hound server to clean, agent-friendly
336
- // messages. The server sets result.error for all failure modes; this function
337
- // translates it so the agent sees "Page doesn't exist (404)" instead of
338
- // "http_error_404: server returned error status".
339
- function cleanError(error: string, status?: number): string {
340
- if (!error) return "";
341
- const e = error.toLowerCase();
342
- // HTTP status errors
343
- if (e.includes("http_error_404") || status === 404) return "Page doesn't exist (404)";
344
- if (e.includes("http_error_410") || status === 410) return "Page has been removed (410)";
345
- if (e.includes("http_error_403") || status === 403) return "Access blocked by the site (403)";
346
- if (e.includes("http_error_429") || status === 429) return "Rate limited by the site (429)";
347
- if (e.includes("http_error_451") || status === 451) return "Page legally blocked (451)";
348
- if (e.includes("http_error_401") || status === 401) return "Login required (401)";
349
- if (e.includes("http_error_5") || (status && status >= 500)) return `Server error (${status})`;
350
- if (e.includes("http_error_4") || (status && status >= 400 && status < 500)) return `Request failed (${status})`;
351
- // Network errors
352
- if (e.includes("network_error") || status === 0) return "Couldn't reach the site";
353
- if (e.includes("timeout") || e.includes("timed out")) return "Request timed out";
354
- // Content quality errors
355
- if (e.includes("js_shell")) return "Page needs JavaScript (empty shell returned)";
356
- if (e.includes("bot_challenge")) return "Blocked by bot protection (Cloudflare)";
357
- if (e.includes("geo_redirect")) return "Page returned a region redirect";
358
- if (e.includes("all_tiers_failed")) return "Couldn't fetch this page - all methods failed";
359
- if (e.includes("auth_required")) return "Login required";
360
- if (e.includes("robots_txt")) return "Blocked by robots.txt";
361
- if (e.includes("encrypted_pdf")) return "PDF is password-protected";
362
- if (e.includes("pdf_deps_missing")) return "PDF support not installed (run: pip install hound-mcp[all])";
363
- if (e.includes("scanned_pdf")) return "Scanned PDF - needs OCR (install hound-mcp[all])";
364
- if (e.includes("not_a_pdf")) return "File is not a PDF";
365
- if (e.includes("pdf_open") || e.includes("pdf_extract")) return "Couldn't parse this PDF";
366
- // Fallback: strip the technical prefix for anything unmapped
367
- const colonIdx = error.indexOf(":");
368
- if (colonIdx > 0) return error.slice(colonIdx + 1).trim();
369
- return error;
370
- }
371
-
372
- // -- Extension --
373
-
374
- export default function (pi: ExtensionAPI) {
375
- pi.on("session_start", async (_event, ctx) => {
376
- const ok = await hound.ensureReady().catch(() => false);
377
- if (!ok) {
378
- if (ctx.hasUI) {
379
- ctx.ui.notify(
380
- hound.hasExe()
381
- ? "Hound found but failed to start. Run: hound --doctor"
382
- : "Hound not found. Install: pip install hound-mcp[all]",
383
- "warn",
384
- );
385
- }
386
- return;
387
- }
388
- // Best-effort version sync check (non-blocking)
389
- if (ctx.hasUI && EXTENSION_VERSION !== "unknown") {
390
- hound.call("version", {}, 5_000).then((result) => {
391
- const parsed = tryJson(getText(result));
392
- if (parsed.version) {
393
- hound["houndVersion"] = parsed.version;
394
- const extMajor = EXTENSION_VERSION.split(".")[0];
395
- const hMajor = String(parsed.version).split(".")[0];
396
- if (extMajor !== hMajor) {
397
- ctx.ui.notify(
398
- `Hound extension v${EXTENSION_VERSION} vs hound v${parsed.version} (major mismatch). Update: hound -u && pi update npm:@houndmcp/hound-mcp-pi`,
399
- "warn",
400
- );
401
- }
402
- }
403
- }).catch(() => {});
404
- }
405
- });
406
-
407
- pi.on("session_shutdown", () => { hound.kill(); });
408
-
409
- // -- web_fetch --
410
- pi.registerTool({
411
- name: "web_fetch",
412
- label: "Web Fetch",
413
- description: "Fetch a URL (or urls=[...] for parallel bulk). Auto HTTP -> stealthy escalation. Returns extracted text + metadata + signals: content_ok, next_action, summary, page_type, content_age_days/is_stale, source_type/is_official, source/archived_at. Hard-block (404/bot/auth) -> auto-recover from Internet Archive (source=archive.org, archived_at=snapshot date; archive_fallback=false in options to opt out). PDFs -> structured markdown + ToC + page ranges + auto-OCR. Long pages: paginate with offset/next_offset or focus='query' for only relevant blocks. actions=[...] for click/form/scroll. include_links/include_media via options.",
414
- promptSnippet: "web_fetch(url|urls, extraction_type, css_selector, focus, actions, include_links, pages, offset) - anti-bot fetch + clean extraction; paginates with next_offset; dead-link recovery from Internet Archive.",
415
- parameters: Type.Object({
416
- url: Type.Optional(Type.String({ description: "URL to fetch" })),
417
- urls: Type.Optional(Type.Array(Type.String(), { description: "Multiple URLs (parallel; returns per-URL results)" })),
418
- extraction_type: Type.Optional(Type.String({ description: "Content format (default markdown). html = raw HTML." })),
419
- css_selector: Type.Optional(Type.String({ description: "CSS selector to narrow extracted content (e.g. 'article', '.main'). Token saver." })),
420
- max_content_chars: Type.Optional(Type.Integer({ description: "Max chars of extracted content (default 40000, min 500). Lower = less context; rest paginated via offset/next_offset." })),
421
- timeout: Type.Optional(Type.Integer({ description: "Max request time in ms (default 30000)." })),
422
- cache_ttl: Type.Optional(Type.Integer({ description: "Cache seconds (default 3600). 0 = force fresh." })),
423
- force_fetcher: Type.Optional(Type.String({ description: "Pin to one tier, skip auto-escalation. 'http' = fast HTTP-only (fails on JS/bot walls). 'stealthy' = anti-detect browser. Default = auto." })),
424
- offset: Type.Optional(Type.Integer({ description: "Char offset into extracted text to resume a truncated page. Use next_offset from previous response." })),
425
- pages: Type.Optional(Type.String({ description: "PDF only: page spec like '1-5' or '1,3,5-7'. Use table_of_contents page/end_page ranges to pick. None = all pages." })),
426
- password: Type.Optional(Type.String({ description: "PDF only: password for an encrypted PDF." })),
427
- focus: Type.Optional(Type.String({ description: "Query-focused extraction: only BM25-relevant blocks returned. Context saver on long pages. Post-cache (no re-fetch). Re-pass same focus when paginating." })),
428
- actions: Type.Optional(Type.Array(Type.Object({}, { additionalProperties: true }), { description: "Page interactions on stealthy browser AFTER load, BEFORE extraction. Forces stealthy + bypasses cache. Each item: {click:'css'}, {fill:{selector:'css',text:'x'}}, {press:'Enter'}, {wait:500}, {scroll:3}, {wait_selector:'css'}." })),
429
- options: Type.Optional(Type.Object({}, { additionalProperties: true, description: "include_links (bool,false: response.links=citations/navigation/external+primary_source), include_media (bool,false: up to 20 page image URLs), archive_fallback (bool,true: recover from Internet Archive on hard-block; false=raw failure), proxy, cookies, extra_headers, useragent, wait, network_idle, headless, respect_robots, real_chrome/solve_cloudflare/block_webrtc/hide_canvas/main_content_only/use_trafilatura (anti-detect tuning, good defaults, rarely needed)." })),
430
- }),
431
- async execute(_id, params, signal, _onUpdate, _ctx) {
432
- try {
433
- const args = pick(params, ["url", "urls", "extraction_type", "css_selector", "max_content_chars", "timeout", "cache_ttl", "force_fetcher", "offset", "pages", "password", "focus", "actions", "options"]);
434
- const result = await hound.call("mcp_smart_fetch", args, CALL_TIMEOUT_MS, signal);
435
- const text = getText(result);
436
- const parsed = tryJson(text);
437
- if (Array.isArray(parsed.results)) {
438
- const ok = parsed.successful ?? 0;
439
- const total = parsed.total ?? parsed.results.length;
440
- const joined = parsed.results.map((r: any) => `# ${r.url}\n${(r.content ?? []).join("\n")}`).join("\n\n---\n\n");
441
- return { content: [{ type: "text", text: joined + `\n\n[${ok}/${total} OK]` }], details: { bulk: true, ok, total } };
442
- }
443
- const content = Array.isArray(parsed.content) ? parsed.content.join("\n") : text;
444
- const foot: string[] = [];
445
- if (parsed.summary) foot.push(parsed.summary);
446
- if (parsed.next_action) foot.push(`Next: ${parsed.next_action}`);
447
- if (parsed.content_ok === false) foot.push("WARNING: content_ok=false - content may be a JS shell/login wall/error page");
448
- const trunc = parsed.is_truncated ? ` | next_offset=${parsed.next_offset}` : "";
449
- const fetcher = parsed.fetcher_used ?? "";
450
- const src = parsed.source === "archive.org" ? ` | ARCHIVE ${parsed.archived_at ?? ""}` : "";
451
- // When the server signals an error (http_error_*, network_error, etc.)
452
- // AND content_ok is false, show a clean error instead of dumping the
453
- // error page HTML as if it were real content.
454
- if (parsed.error && parsed.content_ok === false && parsed.source !== "archive.org") {
455
- const msg = cleanError(parsed.error, parsed.status);
456
- return {
457
- content: [{ type: "text", text: msg + (args.url ? `\nURL: ${args.url}` : "") + (parsed.next_action ? `\nNext: ${parsed.next_action}` : "") }],
458
- details: { url: args.url, error: msg, content_ok: false, source: parsed.source ?? "live", status: parsed.status },
459
- };
460
- }
461
- return {
462
- content: [{ type: "text", text: content + (foot.length ? `\n\n${foot.join("\n")}` : "") + `\n[${fetcher}${trunc}${src}]` }],
463
- details: { url: args.url, chars: content.length, content_ok: parsed.content_ok, truncated: !!parsed.is_truncated, source: parsed.source ?? "live" },
464
- };
465
- } catch (e: any) {
466
- const msg = e.message.includes("Hound not found") || e.message.includes("Hound failed")
467
- ? e.message
468
- : e.message.includes("timeout")
469
- ? "Request timed out"
470
- : e.message.includes("cancelled")
471
- ? "Request cancelled"
472
- : e.message.includes("Hound closed")
473
- ? "Connection to hound lost"
474
- : e.message;
475
- return { content: [{ type: "text", text: msg }], details: { error: msg } };
476
- }
477
- },
478
- renderCall(args, theme) {
479
- const urlStr = (args.url ?? args.urls?.[0] ?? "").toString();
480
- const trunc = urlStr.length > 60 ? urlStr.slice(0, 57) + "..." : urlStr;
481
- return new Text(theme.fg("toolTitle", theme.bold("Web Fetch: ")) + theme.fg("accent", trunc), 0, 0);
482
- },
483
- renderResult(result, { isPartial }, theme) {
484
- if (isPartial) return new Text(theme.fg("dim", "fetching..."), 0, 0);
485
- const d = result.details as any;
486
- if (d?.error) return new Text(theme.fg("error", `${d.error}`), 0, 0);
487
- if (d?.content_ok === false) return new Text(theme.fg("error", "failed"), 0, 0);
488
- if (d?.bulk) return new Text(theme.fg("accent", `${d.ok}/${d.total} URLs`), 0, 0);
489
- const kb = ((d?.chars ?? 0) / 1024).toFixed(1);
490
- const src = d?.source === "archive.org" ? " (archive)" : "";
491
- return new Text(theme.fg("accent", `${kb}KB${src}`), 0, 0);
492
- },
493
- });
494
-
495
- // -- web_search --
496
- pi.registerTool({
497
- name: "web_search",
498
- label: "Web Search",
499
- description: "Keyless web search (no API key). 10 backends in parallel (ddg,brave,mojeek,yahoo,yandex,startpage,google,qwant + opt-in wikipedia,grokipedia), neural-reranked + cross-backend consensus. Returns URLs + ranking, NOT content -> web_fetch the ones that match. Each result: relevance_score + fetch_relevance (high/med/low) + engines_consensus. related_queries from result titles+snippets. Blocked backends circuit-broken 60s. NEVER answer from snippets alone. Filters in options: site, exclude_sites, location, language, region, page, freshness.",
500
- promptSnippet: "web_search(query) - keyless web search across 10 backends; returns ranked URLs (not content), related_queries, consensus. web_fetch the results that match.",
501
- parameters: Type.Object({
502
- query: Type.String({ description: "Search query" }),
503
- options: Type.Optional(Type.Object({}, { additionalProperties: true, description: "max_results (1-50,6), cache_ttl (300), mode (auto|neural|find_similar), engines (list), site, exclude_sites, location, language, region, page, freshness, url (for find_similar)" })),
504
- }),
505
- async execute(_id, params, signal, _onUpdate, _ctx) {
506
- try {
507
- const args: Record<string, any> = { query: params.query };
508
- if (params.options && Object.keys(params.options).length) args.options = params.options;
509
- const result = await hound.call("mcp_smart_search", args, 60_000, signal);
510
- const text = getText(result);
511
- const parsed = tryJson(text);
512
- const results: any[] = parsed.results ?? [];
513
- const body = results.length === 0
514
- ? `No results for "${params.query}".`
515
- : results.map((r: any, i: number) => {
516
- const cons = r.engines_consensus ? ` [consensus ${r.engines_consensus}]` : "";
517
- const tier = r.fetch_relevance ? ` (${r.fetch_relevance})` : "";
518
- return `[${i + 1}]${tier} ${r.title || "(untitled)"}${cons}\n ${r.url || ""}\n ${r.snippet || ""}`;
519
- }).join("\n\n");
520
- const foot: string[] = [];
521
- if (Array.isArray(parsed.related_queries) && parsed.related_queries.length)
522
- foot.push(`Related: ${parsed.related_queries.slice(0, 6).map((q: string) => `"${q}"`).join(", ")}`);
523
- if (Array.isArray(parsed.engine_blocked) && parsed.engine_blocked.length)
524
- foot.push(`Blocked: ${parsed.engine_blocked.join(", ")}`);
525
- if (parsed.next_action) foot.push(`Next: ${parsed.next_action}`);
526
- return {
527
- content: [{ type: "text", text: body + (foot.length ? `\n\n${foot.join("\n")}` : "") }],
528
- details: { query: params.query, count: results.length, related: parsed.related_queries ?? [], blocked: parsed.engine_blocked ?? [] },
529
- };
530
- } catch (e: any) {
531
- const msg = e.message.includes("Hound not found") || e.message.includes("Hound failed") ? e.message : e.message.includes("timeout") ? "Search timed out" : e.message.includes("cancelled") ? "Search cancelled" : e.message.includes("Hound closed") ? "Connection to hound lost" : `Search failed: ${e.message}`;
532
- return { content: [{ type: "text", text: msg }], details: { error: msg } };
533
- }
534
- },
535
- renderCall(args, theme) {
536
- const q = (args.query ?? "").toString();
537
- const trunc = q.length > 50 ? q.slice(0, 47) + "..." : q;
538
- return new Text(theme.fg("toolTitle", theme.bold("Web Search: ")) + theme.fg("accent", `"${trunc}"`), 0, 0);
539
- },
540
- renderResult(result, { isPartial }, theme) {
541
- if (isPartial) return new Text(theme.fg("dim", "searching..."), 0, 0);
542
- const d = result.details as any;
543
- if (d?.error) return new Text(theme.fg("error", `${d.error}`), 0, 0);
544
- if (!d?.count) return new Text(theme.fg("dim", "no results"), 0, 0);
545
- const rel = d.related?.length ? ` + ${d.related.length} related` : "";
546
- return new Text(theme.fg("accent", `${d.count} results${rel}`), 0, 0);
547
- },
548
- });
549
-
550
- // -- web_crawl --
551
- pi.registerTool({
552
- name: "web_crawl",
553
- label: "Web Crawl",
554
- description: "Deep-crawl a site: best-first same-domain walk, each page as markdown + content_ok + page_type. List pages -> structured link list. sitemap=true (in options) maps whole site from sitemap.xml in one fetch; sitemap='auto' = use if present else BFS. discover_only=true = URL map only. focus='query' prioritizes relevant pages + focus-filters each. crawl_urls=[...] fetches a chosen subset. Caps: max_pages (10), max_depth (2), max_total_chars, deadline_ms. Reuses web_fetch anti-bot + cache.",
555
- promptSnippet: "web_crawl(url, focus, options.sitemap, discover_only, crawl_urls, max_pages) - site crawl; sitemap=true maps a whole site in one fetch; crawl_urls fetches a chosen subset.",
556
- parameters: Type.Object({
557
- url: Type.String({ description: "Start URL (crawl stays on this domain)" }),
558
- focus: Type.Optional(Type.String({ description: "Query: prioritize crawling links relevant to this + focus-filter each page. Token saver on doc sites." })),
559
- discover_only: Type.Optional(Type.Boolean({ description: "true = return URL map only, no page content. For big sites prefer options sitemap=true." })),
560
- crawl_urls: Type.Optional(Type.Array(Type.String(), { description: "Chosen subset of URLs to fetch (second-phase selective crawl, no re-discovery). Use after sitemap=true or discover_only=true." })),
561
- options: Type.Optional(Type.Object({}, { additionalProperties: true, description: "sitemap (true|'auto'|false,false: true=map from sitemap.xml in one fetch; 'auto'=use if present else BFS), max_pages (1-100,10), max_depth (0-5,2), path_include (list of path prefixes), path_exclude (list to skip), max_content_chars_per (8000), max_total_chars (token budget), concurrency (1-5,3), cache_ttl (3600;0=fresh), respect_robots (false), force_fetcher ('http'|'stealthy'), timeout (ms,30000), deadline_ms (120000)." })),
562
- }),
563
- async execute(_id, params, signal, _onUpdate, _ctx) {
564
- try {
565
- const args = pick(params, ["url", "focus", "discover_only", "crawl_urls", "options"]);
566
- const result = await hound.call("mcp_smart_crawl", args, CRAWL_TIMEOUT_MS, signal);
567
- const text = getText(result);
568
- const parsed = tryJson(text);
569
- const pages: any[] = Array.isArray(parsed.pages) ? parsed.pages : [];
570
- let body: string;
571
- if (parsed.sitemap_used || (args.discover_only && pages.length)) {
572
- body = pages.map((p: any, i: number) => `[${i + 1}] ${p.url}${p.lastmod ? ` (${p.lastmod})` : ""}`).join("\n");
573
- } else {
574
- body = pages.map((p: any) => {
575
- const tag = p.page_type ? `[${p.page_type}]` : "";
576
- const ok = p.content_ok ? "" : " WARNING";
577
- const c = Array.isArray(p.content) ? p.content.join("\n") : "";
578
- return `## ${p.url} ${tag}${ok}\n${c}`;
579
- }).join("\n\n---\n\n");
580
- }
581
- const foot: string[] = [];
582
- if (parsed.summary) foot.push(parsed.summary);
583
- if (parsed.next_action) foot.push(`Next: ${parsed.next_action}`);
584
- return {
585
- content: [{ type: "text", text: body + (foot.length ? `\n\n${foot.join("\n")}` : "") }],
586
- details: { url: params.url, pages: pages.length, sitemap: !!parsed.sitemap_used },
587
- };
588
- } catch (e: any) {
589
- const msg = e.message.includes("Hound not found") || e.message.includes("Hound failed") ? e.message : e.message.includes("timeout") ? "Crawl timed out" : e.message.includes("cancelled") ? "Crawl cancelled" : e.message.includes("Hound closed") ? "Connection to hound lost" : `Crawl failed: ${e.message}`;
590
- return { content: [{ type: "text", text: msg }], details: { error: msg } };
591
- }
592
- },
593
- renderCall(args, theme) {
594
- const urlStr = (args.url ?? "").toString();
595
- const trunc = urlStr.length > 55 ? urlStr.slice(0, 52) + "..." : urlStr;
596
- const sm = args.options?.sitemap ? " (sitemap)" : "";
597
- return new Text(theme.fg("toolTitle", theme.bold("Web Crawl: ")) + theme.fg("accent", trunc + sm), 0, 0);
598
- },
599
- renderResult(result, { isPartial }, theme) {
600
- if (isPartial) return new Text(theme.fg("dim", "crawling..."), 0, 0);
601
- const d = result.details as any;
602
- if (d?.error) return new Text(theme.fg("error", `${d.error}`), 0, 0);
603
- const sm = d.sitemap ? " (sitemap)" : "";
604
- return new Text(theme.fg("accent", `${d.pages} pages${sm}`), 0, 0);
605
- },
606
- });
607
-
608
- // -- web_screenshot --
609
- pi.registerTool({
610
- name: "web_screenshot",
611
- label: "Web Screenshot",
612
- description: "Screenshot a URL as an image. Multimodal agents only (content as images/canvas/visual layout). Text agents: use web_fetch. Stealthy browser auto-managed.",
613
- promptSnippet: "web_screenshot(url, options.full_page, options.image_type) - anti-bot one-shot screenshot of a URL (stealthy browser). Multimodal agents only.",
614
- parameters: Type.Object({
615
- url: Type.String({ description: "URL to screenshot" }),
616
- options: Type.Optional(Type.Object({}, { additionalProperties: true, description: "full_page (bool,false), image_type (png|jpeg,png), quality (0-100,jpeg), wait (ms), wait_selector (css), network_idle (bool), timeout (ms,30000)." })),
617
- }),
618
- async execute(_id, params, signal, _onUpdate, _ctx) {
619
- try {
620
- const args: Record<string, any> = { url: params.url };
621
- if (params.options && Object.keys(params.options).length) args.options = params.options;
622
- const result = await hound.call("mcp_screenshot", args, CALL_TIMEOUT_MS, signal);
623
- const images = getImages(result);
624
- if (images.length) {
625
- const out: any[] = [];
626
- for (const img of images) out.push({ type: "image", data: img.data, mimeType: img.mimeType || "image/png" });
627
- out.push({ type: "text", text: `Screenshot captured for ${params.url}` });
628
- return { content: out, details: { url: params.url, images: images.length } };
629
- }
630
- const parsed = tryJson(getText(result));
631
- return {
632
- content: [{ type: "text", text: parsed.error ? `Capture failed: ${parsed.error}` : `Screenshot captured for ${params.url} (no image payload)` }],
633
- details: { url: params.url, images: 0, error: parsed.error ?? "" },
634
- };
635
- } catch (e: any) {
636
- const msg = e.message.includes("Hound not found") || e.message.includes("Hound failed") ? e.message : e.message.includes("timeout") ? "Screenshot timed out" : e.message.includes("cancelled") ? "Screenshot cancelled" : e.message.includes("Hound closed") ? "Connection to hound lost" : `Screenshot failed: ${e.message}`;
637
- return { content: [{ type: "text", text: msg }], details: { error: msg } };
638
- }
639
- },
640
- renderCall(args, theme) {
641
- const urlStr = (args.url ?? "").toString();
642
- const trunc = urlStr.length > 55 ? urlStr.slice(0, 52) + "..." : urlStr;
643
- return new Text(theme.fg("toolTitle", theme.bold("Web Screenshot: ")) + theme.fg("accent", trunc), 0, 0);
644
- },
645
- renderResult(result, { isPartial }, theme) {
646
- if (isPartial) return new Text(theme.fg("dim", "capturing..."), 0, 0);
647
- const d = result.details as any;
648
- if (d?.error) return new Text(theme.fg("error", `${d.error}`), 0, 0);
649
- if (!d?.images) return new Text(theme.fg("dim", "no image"), 0, 0);
650
- return new Text(theme.fg("accent", `${d.images} image`), 0, 0);
651
- },
652
- });
653
-
654
- // -- cache_clear --
655
- pi.registerTool({
656
- name: "cache_clear",
657
- label: "Clear Cache",
658
- description: "Clear fetch cache. all=true wipes all (default: expired only). To re-fetch one URL fresh, pass cache_ttl=0 to web_fetch/web_crawl instead. Cache stores extracted text per URL+extraction_type+css_selector+pages (+ per query+filters for search); default TTL 1hr.",
659
- promptSnippet: "cache_clear(all) - clear fetch cache. all=true wipes all (default: expired only). For one URL fresh, use cache_ttl=0 on web_fetch instead.",
660
- parameters: Type.Object({
661
- all: Type.Optional(Type.Boolean({ description: "Wipe all (default: expired only)" })),
662
- }),
663
- async execute(_id, params, signal, _onUpdate, _ctx) {
664
- try {
665
- const args = { all: params.all ?? false };
666
- const result = await hound.call("cache_clear", args, 10_000, signal);
667
- return { content: [{ type: "text", text: getText(result) }], details: { all: args.all } };
668
- } catch (e: any) {
669
- return { content: [{ type: "text", text: e.message.includes("Hound") ? e.message : `Cache clear failed: ${e.message}` }], details: { error: e.message } };
670
- }
671
- },
672
- renderCall(_args, theme) {
673
- return new Text(theme.fg("toolTitle", theme.bold("Clear Cache")), 0, 0);
674
- },
675
- renderResult(result, _meta, theme) {
676
- const d = result.details as any;
677
- if (d?.error) return new Text(theme.fg("error", `${d.error}`), 0, 0);
678
- return new Text(theme.fg("accent", "cleared"), 0, 0);
679
- },
680
- });
681
-
682
- // -- hound_version --
683
- pi.registerTool({
684
- name: "hound_version",
685
- label: "Hound Version",
686
- description: "Hound version + update status.",
687
- promptSnippet: "hound_version() - hound version + update status.",
688
- parameters: Type.Object({}),
689
- async execute(_id, _params, signal, _onUpdate, _ctx) {
690
- try {
691
- const result = await hound.call("version", {}, 10_000, signal);
692
- const parsed = tryJson(getText(result));
693
- const v = parsed.version || "unknown";
694
- const latest = parsed.latest || "";
695
- const upToDate = parsed.up_to_date;
696
- const extInfo = EXTENSION_VERSION !== "unknown" ? ` | extension v${EXTENSION_VERSION}` : "";
697
- const text = upToDate
698
- ? `Hound ${v} (up to date)${extInfo}`
699
- : `Hound ${v} (update available: ${latest})${extInfo}. Run: hound -u`;
700
- return { content: [{ type: "text", text }], details: { version: v, latest, up_to_date: upToDate, extension: EXTENSION_VERSION } };
701
- } catch (e: any) {
702
- // Fallback: try hound --version via execSync (works even when MCP server is down)
703
- try {
704
- const exe = hound.getExe();
705
- if (exe) {
706
- const out = execSync(`"${exe}" --version`, { timeout: 5000, windowsHide: true }).toString().trim();
707
- return { content: [{ type: "text", text: out }], details: { fallback: true, extension: EXTENSION_VERSION } };
708
- }
709
- } catch {}
710
- return { content: [{ type: "text", text: e.message.includes("Hound") ? e.message : `Version check failed: ${e.message}` }], details: { error: e.message, extension: EXTENSION_VERSION } };
711
- }
712
- },
713
- renderCall(_args, theme) {
714
- return new Text(theme.fg("toolTitle", theme.bold("Hound Version")), 0, 0);
715
- },
716
- renderResult(result, _meta, theme) {
717
- const d = result.details as any;
718
- if (d?.error) return new Text(theme.fg("error", `${d.error}`), 0, 0);
719
- return new Text(theme.fg("accent", "ok"), 0, 0);
720
- },
721
- });
722
- }
1
+ /**
2
+ * Hound MCP extension for Pi agent.
3
+ *
4
+ * Six native tools, all routed through a single long-lived Hound MCP stdio
5
+ * subprocess (singleton - Hound's startup prewarm persists for the whole
6
+ * session, zero re-launch cost per call):
7
+ * - web_fetch -> mcp_smart_fetch (auto anti-bot, PDF, archive fallback)
8
+ * - web_search -> mcp_smart_search (10 keyless backends, consensus rank)
9
+ * - web_crawl -> mcp_smart_crawl (best-first + sitemap-one-fetch map)
10
+ * - web_screenshot -> mcp_screenshot (image capture for multimodal agents)
11
+ * - cache_clear -> cache_clear (clear fetch cache)
12
+ * - hound_version -> version (hound version + update status)
13
+ *
14
+ * Hound is fully keyless ($0, no API key, no account). The subprocess is
15
+ * resolved from PATH so it tracks the installed Hound version automatically.
16
+ *
17
+ * Install: pip install hound-mcp[all]
18
+ * Then: pi install npm:@houndmcp/hound-mcp-pi
19
+ */
20
+
21
+ import { Type } from "typebox";
22
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
23
+ import { Text } from "@earendil-works/pi-tui";
24
+ import { spawn, execSync, type ChildProcess } from "node:child_process";
25
+ import * as path from "node:path";
26
+ import * as os from "node:os";
27
+ import * as fs from "node:fs";
28
+
29
+ // -- Version (read from package.json at load time) --
30
+
31
+ function readPkgVersion(): string {
32
+ try {
33
+ const pkgPath = path.join(__dirname, "..", "package.json");
34
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
35
+ return pkg.version || "unknown";
36
+ } catch {
37
+ return "unknown";
38
+ }
39
+ }
40
+
41
+ const EXTENSION_VERSION = readPkgVersion();
42
+
43
+ // -- Hound executable resolution (lazy, re-tried on each ensureReady if null) --
44
+
45
+ function resolveHoundExe(): string | null {
46
+ // 1. PATH lookup (covers 99% of installs)
47
+ try {
48
+ const cmd = process.platform === "win32" ? "where hound.exe" : "which hound";
49
+ const out = execSync(cmd, {
50
+ stdio: ["ignore", "pipe", "ignore"],
51
+ timeout: 2000,
52
+ windowsHide: true,
53
+ }).toString().trim();
54
+ const first = out.split(/\r?\n/)[0];
55
+ if (first && fs.existsSync(first)) return first;
56
+ } catch {}
57
+ // 2. Fallback: common Windows install paths (multiple Python versions)
58
+ if (process.platform === "win32") {
59
+ const home = os.homedir();
60
+ for (const v of ["Python311", "Python312", "Python313", "Python310"]) {
61
+ const p = path.join(home, "AppData", "Local", "Programs", "Python", v, "Scripts", "hound.exe");
62
+ if (fs.existsSync(p)) return p;
63
+ }
64
+ }
65
+ return null;
66
+ }
67
+
68
+ const INIT_TIMEOUT_MS = 15_000;
69
+ const CALL_TIMEOUT_MS = 90_000;
70
+ const CRAWL_TIMEOUT_MS = 150_000;
71
+ const INIT_ATTEMPTS = 3;
72
+ const INIT_BACKOFF_MS = 400;
73
+
74
+ // -- MCP Stdio Client (singleton subprocess + JSON-RPC) --
75
+
76
+ interface Pending {
77
+ resolve: (v: any) => void;
78
+ reject: (e: Error) => void;
79
+ timer: NodeJS.Timeout;
80
+ }
81
+
82
+ class HoundClient {
83
+ private proc: ChildProcess | null = null;
84
+ private houndExe: string | null = null;
85
+ private houndVersion: string | null = null;
86
+ private ready: Promise<boolean> | null = null;
87
+ private initInFlight: Promise<boolean> | null = null;
88
+ private nextId = 0;
89
+ private pending = new Map<number, Pending>();
90
+ private stdoutBuf = "";
91
+ private lastStderr = "";
92
+
93
+ getExe(): string | null { return this.houndExe; }
94
+
95
+ hasExe(): boolean { return this.houndExe !== null; }
96
+
97
+ async ensureReady(): Promise<boolean> {
98
+ if (this.ready) return this.ready;
99
+ // Re-resolve hound exe if not found yet (handles install-after-load)
100
+ if (!this.houndExe) {
101
+ this.houndExe = resolveHoundExe();
102
+ }
103
+ if (!this.houndExe) return false;
104
+ if (this.initInFlight) return this.initInFlight;
105
+ const p = this._initWithRetry();
106
+ this.initInFlight = p;
107
+ p.then((ok) => {
108
+ this.initInFlight = null;
109
+ if (ok) this.ready = p;
110
+ else this.ready = null;
111
+ });
112
+ return p;
113
+ }
114
+
115
+ private async _initWithRetry(): Promise<boolean> {
116
+ for (let attempt = 1; attempt <= INIT_ATTEMPTS; attempt++) {
117
+ if (await this._initOnce()) return true;
118
+ if (attempt < INIT_ATTEMPTS) await new Promise((r) => setTimeout(r, INIT_BACKOFF_MS));
119
+ }
120
+ return false;
121
+ }
122
+
123
+ private _initOnce(): Promise<boolean> {
124
+ // Kill any existing process before spawning a new one (prevents orphans)
125
+ if (this.proc && !this.proc.killed) {
126
+ try { this.proc.stdin?.end(); } catch {}
127
+ try { this.proc.kill(); } catch {}
128
+ }
129
+ this.proc = null;
130
+
131
+ return new Promise<boolean>((resolve) => {
132
+ let settled = false;
133
+ let initTimer: NodeJS.Timeout | undefined;
134
+ let stderrBuf = "";
135
+ const done = (ok: boolean) => {
136
+ if (settled) return;
137
+ settled = true;
138
+ if (initTimer) clearTimeout(initTimer);
139
+ if (!ok) this._killProcess();
140
+ resolve(ok);
141
+ };
142
+ try {
143
+ this.proc = spawn(this.houndExe!, [], {
144
+ stdio: ["pipe", "pipe", "pipe"],
145
+ windowsHide: true,
146
+ env: { ...process.env },
147
+ });
148
+ } catch (e: any) {
149
+ this.lastStderr = `spawn threw: ${e?.message ?? e}`;
150
+ done(false);
151
+ return;
152
+ }
153
+ this.proc.on("error", (e: any) => {
154
+ this.lastStderr = `spawn error: ${e?.message ?? e}`;
155
+ done(false);
156
+ });
157
+ this.proc.on("close", (code, signal) => {
158
+ for (const [, { reject, timer }] of this.pending) {
159
+ clearTimeout(timer);
160
+ reject(new Error("Hound closed"));
161
+ }
162
+ this.pending.clear();
163
+ if (!settled) this.lastStderr = `exited before init (code=${code} signal=${signal})`;
164
+ this.ready = null;
165
+ done(false);
166
+ });
167
+ this.proc.stderr?.on("data", (chunk: Buffer) => {
168
+ stderrBuf += chunk.toString("utf-8");
169
+ if (stderrBuf.length > 4000) stderrBuf = stderrBuf.slice(-4000);
170
+ });
171
+ this.proc.stdout?.on("data", (chunk: Buffer) => {
172
+ this.stdoutBuf += chunk.toString("utf-8");
173
+ this._drain();
174
+ });
175
+ initTimer = setTimeout(() => {
176
+ this.lastStderr = `initialize timed out after ${INIT_TIMEOUT_MS}ms (dead spawn)`;
177
+ done(false);
178
+ }, INIT_TIMEOUT_MS);
179
+ const id = ++this.nextId;
180
+ this.pending.set(id, {
181
+ resolve: () => {
182
+ this._notify("notifications/initialized", {});
183
+ this.lastStderr = "";
184
+ done(true);
185
+ },
186
+ reject: (e: Error) => {
187
+ this.lastStderr = `initialize rejected: ${e.message}`;
188
+ done(false);
189
+ },
190
+ timer: initTimer,
191
+ });
192
+ try {
193
+ this.proc.stdin!.write(
194
+ JSON.stringify({
195
+ jsonrpc: "2.0",
196
+ method: "initialize",
197
+ params: {
198
+ protocolVersion: "2025-03-26",
199
+ capabilities: {},
200
+ clientInfo: { name: "pi-hound", version: EXTENSION_VERSION },
201
+ },
202
+ id,
203
+ }) + "\n",
204
+ );
205
+ } catch (e: any) {
206
+ this.pending.delete(id);
207
+ this.lastStderr = `stdin write failed: ${e?.message ?? e}`;
208
+ done(false);
209
+ }
210
+ });
211
+ }
212
+
213
+ private _drain() {
214
+ let idx: number;
215
+ while ((idx = this.stdoutBuf.indexOf("\n")) !== -1) {
216
+ const line = this.stdoutBuf.slice(0, idx).trim();
217
+ this.stdoutBuf = this.stdoutBuf.slice(idx + 1);
218
+ if (!line) continue;
219
+ try {
220
+ const msg = JSON.parse(line);
221
+ if (msg.id != null && this.pending.has(msg.id)) {
222
+ const { resolve, reject, timer } = this.pending.get(msg.id)!;
223
+ this.pending.delete(msg.id);
224
+ clearTimeout(timer);
225
+ if (msg.error) reject(new Error(msg.error?.message ?? JSON.stringify(msg.error)));
226
+ else resolve(msg.result);
227
+ }
228
+ } catch {}
229
+ }
230
+ }
231
+
232
+ private _notify(method: string, params: any) {
233
+ if (!this.proc || this.proc.killed) return;
234
+ try {
235
+ this.proc.stdin!.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n");
236
+ } catch {}
237
+ }
238
+
239
+ async call(
240
+ name: string,
241
+ args: Record<string, any>,
242
+ timeoutMs = CALL_TIMEOUT_MS,
243
+ signal?: AbortSignal,
244
+ ): Promise<any> {
245
+ const ready = await this.ensureReady();
246
+ if (!ready) {
247
+ const hint = this.houndExe
248
+ ? `Hound failed to start after ${INIT_ATTEMPTS} attempts. ${this.lastStderr || "Silent dead hang."} Run: hound --doctor`
249
+ : "Hound not found. Install: pip install hound-mcp[all]";
250
+ throw new Error(hint);
251
+ }
252
+ return new Promise((resolve, reject) => {
253
+ if (!this.proc || this.proc.killed) {
254
+ reject(new Error("Hound not running"));
255
+ return;
256
+ }
257
+ if (signal?.aborted) {
258
+ reject(new Error(`Hound cancelled: ${name}`));
259
+ return;
260
+ }
261
+ const id = ++this.nextId;
262
+ let timer: NodeJS.Timeout;
263
+ let onAbort: () => void;
264
+ const cleanup = () => {
265
+ clearTimeout(timer);
266
+ this.pending.delete(id);
267
+ if (signal && onAbort) signal.removeEventListener("abort", onAbort);
268
+ };
269
+ timer = setTimeout(() => {
270
+ cleanup();
271
+ reject(new Error(`Hound timeout: ${name}`));
272
+ }, timeoutMs);
273
+ onAbort = () => {
274
+ cleanup();
275
+ reject(new Error(`Hound cancelled: ${name}`));
276
+ };
277
+ signal?.addEventListener("abort", onAbort, { once: true });
278
+ this.pending.set(id, {
279
+ resolve: (v: any) => { cleanup(); resolve(v); },
280
+ reject: (e: Error) => { cleanup(); reject(e); },
281
+ timer,
282
+ });
283
+ try {
284
+ this.proc.stdin!.write(
285
+ JSON.stringify({ jsonrpc: "2.0", method: "tools/call", params: { name, arguments: args }, id }) + "\n",
286
+ );
287
+ } catch (e) {
288
+ cleanup();
289
+ reject(e as Error);
290
+ }
291
+ });
292
+ }
293
+
294
+ private _killProcess() {
295
+ if (this.proc && !this.proc.killed) {
296
+ try { this.proc.stdin?.end(); } catch {}
297
+ try { this.proc.kill(); } catch {}
298
+ }
299
+ this.proc = null;
300
+ this.ready = null;
301
+ this.initInFlight = null;
302
+ for (const [, { timer }] of this.pending) clearTimeout(timer);
303
+ this.pending.clear();
304
+ }
305
+
306
+ kill() { this._killProcess(); }
307
+ }
308
+
309
+ const hound = new HoundClient();
310
+
311
+ // -- Helpers --
312
+
313
+ function getText(result: any): string {
314
+ const content = result?.content ?? [];
315
+ return content.filter((c: any) => c?.type === "text").map((c: any) => c.text).join("\n") || "(no output)";
316
+ }
317
+
318
+ function getImages(result: any): any[] {
319
+ const content = result?.content ?? [];
320
+ return content.filter((c: any) => c?.type === "image");
321
+ }
322
+
323
+ function tryJson(text: string): any {
324
+ try { return JSON.parse(text); } catch { return {}; }
325
+ }
326
+
327
+ function pick(params: Record<string, any>, keys: string[]): Record<string, any> {
328
+ const out: Record<string, any> = {};
329
+ for (const k of keys) {
330
+ if (params[k] !== undefined) out[k] = params[k];
331
+ }
332
+ return out;
333
+ }
334
+
335
+ // Map technical error strings from the hound server to clean, agent-friendly
336
+ // messages. The server sets result.error for all failure modes; this function
337
+ // translates it so the agent sees "Page doesn't exist (404)" instead of
338
+ // "http_error_404: server returned error status".
339
+ function cleanError(error: string, status?: number): string {
340
+ if (!error) return "";
341
+ const e = error.toLowerCase();
342
+ // HTTP status errors
343
+ if (e.includes("http_error_404") || status === 404) return "Page doesn't exist (404)";
344
+ if (e.includes("http_error_410") || status === 410) return "Page has been removed (410)";
345
+ if (e.includes("http_error_403") || status === 403) return "Access blocked by the site (403)";
346
+ if (e.includes("http_error_429") || status === 429) return "Rate limited by the site (429)";
347
+ if (e.includes("http_error_451") || status === 451) return "Page legally blocked (451)";
348
+ if (e.includes("http_error_401") || status === 401) return "Login required (401)";
349
+ if (e.includes("http_error_5") || (status && status >= 500)) return `Server error (${status})`;
350
+ if (e.includes("http_error_4") || (status && status >= 400 && status < 500)) return `Request failed (${status})`;
351
+ // Network errors
352
+ if (e.includes("network_error") || status === 0) return "Couldn't reach the site";
353
+ if (e.includes("timeout") || e.includes("timed out")) return "Request timed out";
354
+ // Content quality errors
355
+ if (e.includes("js_shell")) return "Page needs JavaScript (empty shell returned)";
356
+ if (e.includes("bot_challenge")) return "Blocked by bot protection (Cloudflare)";
357
+ if (e.includes("geo_redirect")) return "Page returned a region redirect";
358
+ if (e.includes("all_tiers_failed")) return "Couldn't fetch this page - all methods failed";
359
+ if (e.includes("browser_unavailable")) return "Browser not installed (HTTP-only mode). Stealthy/screenshot disabled. Install: pip install hound-mcp[all]";
360
+ if (e.includes("auth_required")) return "Login required";
361
+ if (e.includes("robots_txt")) return "Blocked by robots.txt";
362
+ if (e.includes("encrypted_pdf")) return "PDF is password-protected";
363
+ if (e.includes("pdf_deps_missing")) return "PDF support not installed (run: pip install hound-mcp[all])";
364
+ if (e.includes("scanned_pdf")) return "Scanned PDF - needs OCR (install hound-mcp[all])";
365
+ if (e.includes("not_a_pdf")) return "File is not a PDF";
366
+ if (e.includes("pdf_open") || e.includes("pdf_extract")) return "Couldn't parse this PDF";
367
+ // Fallback: strip the technical prefix for anything unmapped
368
+ const colonIdx = error.indexOf(":");
369
+ if (colonIdx > 0) return error.slice(colonIdx + 1).trim();
370
+ return error;
371
+ }
372
+
373
+ // -- Extension --
374
+
375
+ export default function (pi: ExtensionAPI) {
376
+ pi.on("session_start", async (_event, ctx) => {
377
+ const ok = await hound.ensureReady().catch(() => false);
378
+ if (!ok) {
379
+ if (ctx.hasUI) {
380
+ ctx.ui.notify(
381
+ hound.hasExe()
382
+ ? "Hound found but failed to start. Run: hound --doctor"
383
+ : "Hound not found. Install: pip install hound-mcp[all]",
384
+ "warn",
385
+ );
386
+ }
387
+ return;
388
+ }
389
+ // Best-effort version sync check (non-blocking)
390
+ if (ctx.hasUI && EXTENSION_VERSION !== "unknown") {
391
+ hound.call("version", {}, 5_000).then((result) => {
392
+ const parsed = tryJson(getText(result));
393
+ if (parsed.version) {
394
+ hound["houndVersion"] = parsed.version;
395
+ const extMajor = EXTENSION_VERSION.split(".")[0];
396
+ const hMajor = String(parsed.version).split(".")[0];
397
+ if (extMajor !== hMajor) {
398
+ ctx.ui.notify(
399
+ `Hound extension v${EXTENSION_VERSION} vs hound v${parsed.version} (major mismatch). Update: hound -u && pi update npm:@houndmcp/hound-mcp-pi`,
400
+ "warn",
401
+ );
402
+ }
403
+ }
404
+ }).catch(() => {});
405
+ }
406
+ });
407
+
408
+ pi.on("session_shutdown", () => { hound.kill(); });
409
+
410
+ // -- web_fetch --
411
+ pi.registerTool({
412
+ name: "web_fetch",
413
+ label: "Web Fetch",
414
+ description: "Fetch a URL (or urls=[...] for parallel bulk). Auto HTTP -> stealthy escalation. Returns extracted text + metadata + signals: content_ok, next_action, summary, page_type, content_age_days/is_stale, source_type/is_official, source/archived_at. Hard-block (404/bot/auth) -> auto-recover from Internet Archive (source=archive.org, archived_at=snapshot date; archive_fallback=false in options to opt out). PDFs -> structured markdown + ToC + page ranges + auto-OCR. Long pages: paginate with offset/next_offset or focus='query' for only relevant blocks. actions=[...] for click/form/scroll. include_links/include_media via options.",
415
+ promptSnippet: "web_fetch(url|urls, extraction_type, css_selector, focus, actions, include_links, pages, offset) - anti-bot fetch + clean extraction; paginates with next_offset; dead-link recovery from Internet Archive.",
416
+ parameters: Type.Object({
417
+ url: Type.Optional(Type.String({ description: "URL to fetch" })),
418
+ urls: Type.Optional(Type.Array(Type.String(), { description: "Multiple URLs (parallel; returns per-URL results)" })),
419
+ extraction_type: Type.Optional(Type.String({ description: "Content format (default markdown). html = raw HTML." })),
420
+ css_selector: Type.Optional(Type.String({ description: "CSS selector to narrow extracted content (e.g. 'article', '.main'). Token saver." })),
421
+ max_content_chars: Type.Optional(Type.Integer({ description: "Max chars of extracted content (default 40000, min 500). Lower = less context; rest paginated via offset/next_offset." })),
422
+ timeout: Type.Optional(Type.Integer({ description: "Max request time in ms (default 30000)." })),
423
+ cache_ttl: Type.Optional(Type.Integer({ description: "Cache seconds (default 3600). 0 = force fresh." })),
424
+ force_fetcher: Type.Optional(Type.String({ description: "Pin to one tier, skip auto-escalation. 'http' = fast HTTP-only (fails on JS/bot walls). 'stealthy' = anti-detect browser. Default = auto." })),
425
+ offset: Type.Optional(Type.Integer({ description: "Char offset into extracted text to resume a truncated page. Use next_offset from previous response." })),
426
+ pages: Type.Optional(Type.String({ description: "PDF only: page spec like '1-5' or '1,3,5-7'. Use table_of_contents page/end_page ranges to pick. None = all pages." })),
427
+ password: Type.Optional(Type.String({ description: "PDF only: password for an encrypted PDF." })),
428
+ focus: Type.Optional(Type.String({ description: "Query-focused extraction: only BM25-relevant blocks returned. Context saver on long pages. Post-cache (no re-fetch). Re-pass same focus when paginating." })),
429
+ actions: Type.Optional(Type.Array(Type.Object({}, { additionalProperties: true }), { description: "Page interactions on stealthy browser AFTER load, BEFORE extraction. Forces stealthy + bypasses cache. Each item: {click:'css'}, {fill:{selector:'css',text:'x'}}, {press:'Enter'}, {wait:500}, {scroll:3}, {wait_selector:'css'}." })),
430
+ options: Type.Optional(Type.Object({}, { additionalProperties: true, description: "include_links (bool,false: response.links=citations/navigation/external+primary_source), include_media (bool,false: up to 20 page image URLs), archive_fallback (bool,true: recover from Internet Archive on hard-block; false=raw failure), proxy, cookies, extra_headers, useragent, wait, network_idle, headless, respect_robots, real_chrome/solve_cloudflare/block_webrtc/hide_canvas/main_content_only/use_trafilatura (anti-detect tuning, good defaults, rarely needed)." })),
431
+ }),
432
+ async execute(_id, params, signal, _onUpdate, _ctx) {
433
+ try {
434
+ const args = pick(params, ["url", "urls", "extraction_type", "css_selector", "max_content_chars", "timeout", "cache_ttl", "force_fetcher", "offset", "pages", "password", "focus", "actions", "options"]);
435
+ const result = await hound.call("mcp_smart_fetch", args, CALL_TIMEOUT_MS, signal);
436
+ const text = getText(result);
437
+ const parsed = tryJson(text);
438
+ if (Array.isArray(parsed.results)) {
439
+ const ok = parsed.successful ?? 0;
440
+ const total = parsed.total ?? parsed.results.length;
441
+ const joined = parsed.results.map((r: any) => `# ${r.url}\n${(r.content ?? []).join("\n")}`).join("\n\n---\n\n");
442
+ return { content: [{ type: "text", text: joined + `\n\n[${ok}/${total} OK]` }], details: { bulk: true, ok, total } };
443
+ }
444
+ const content = Array.isArray(parsed.content) ? parsed.content.join("\n") : text;
445
+ const foot: string[] = [];
446
+ if (parsed.summary) foot.push(parsed.summary);
447
+ if (parsed.next_action) foot.push(`Next: ${parsed.next_action}`);
448
+ if (parsed.content_ok === false) foot.push("WARNING: content_ok=false - content may be a JS shell/login wall/error page");
449
+ const trunc = parsed.is_truncated ? ` | next_offset=${parsed.next_offset}` : "";
450
+ const fetcher = parsed.fetcher_used ?? "";
451
+ const src = parsed.source === "archive.org" ? ` | ARCHIVE ${parsed.archived_at ?? ""}` : "";
452
+ // When the server signals an error (http_error_*, network_error, etc.)
453
+ // AND content_ok is false, show a clean error instead of dumping the
454
+ // error page HTML as if it were real content.
455
+ if (parsed.error && parsed.content_ok === false && parsed.source !== "archive.org") {
456
+ const msg = cleanError(parsed.error, parsed.status);
457
+ return {
458
+ content: [{ type: "text", text: msg + (args.url ? `\nURL: ${args.url}` : "") + (parsed.next_action ? `\nNext: ${parsed.next_action}` : "") }],
459
+ details: { url: args.url, error: msg, content_ok: false, source: parsed.source ?? "live", status: parsed.status },
460
+ };
461
+ }
462
+ return {
463
+ content: [{ type: "text", text: content + (foot.length ? `\n\n${foot.join("\n")}` : "") + `\n[${fetcher}${trunc}${src}]` }],
464
+ details: { url: args.url, chars: content.length, content_ok: parsed.content_ok, truncated: !!parsed.is_truncated, source: parsed.source ?? "live" },
465
+ };
466
+ } catch (e: any) {
467
+ const msg = e.message.includes("Hound not found") || e.message.includes("Hound failed")
468
+ ? e.message
469
+ : e.message.includes("timeout")
470
+ ? "Request timed out"
471
+ : e.message.includes("cancelled")
472
+ ? "Request cancelled"
473
+ : e.message.includes("Hound closed")
474
+ ? "Connection to hound lost"
475
+ : e.message;
476
+ return { content: [{ type: "text", text: msg }], details: { error: msg } };
477
+ }
478
+ },
479
+ renderCall(args, theme) {
480
+ const urlStr = (args.url ?? args.urls?.[0] ?? "").toString();
481
+ const trunc = urlStr.length > 60 ? urlStr.slice(0, 57) + "..." : urlStr;
482
+ return new Text(theme.fg("toolTitle", theme.bold("Web Fetch: ")) + theme.fg("accent", trunc), 0, 0);
483
+ },
484
+ renderResult(result, { isPartial }, theme) {
485
+ if (isPartial) return new Text(theme.fg("dim", "fetching..."), 0, 0);
486
+ const d = result.details as any;
487
+ if (d?.error) return new Text(theme.fg("error", `${d.error}`), 0, 0);
488
+ if (d?.content_ok === false) return new Text(theme.fg("error", "failed"), 0, 0);
489
+ if (d?.bulk) return new Text(theme.fg("accent", `${d.ok}/${d.total} URLs`), 0, 0);
490
+ const kb = ((d?.chars ?? 0) / 1024).toFixed(1);
491
+ const src = d?.source === "archive.org" ? " (archive)" : "";
492
+ return new Text(theme.fg("accent", `${kb}KB${src}`), 0, 0);
493
+ },
494
+ });
495
+
496
+ // -- web_search --
497
+ pi.registerTool({
498
+ name: "web_search",
499
+ label: "Web Search",
500
+ description: "Keyless web search (no API key). 10 backends in parallel (ddg,brave,mojeek,yahoo,yandex,startpage,google,qwant + opt-in wikipedia,grokipedia), neural-reranked + cross-backend consensus. Returns URLs + ranking, NOT content -> web_fetch the ones that match. Each result: relevance_score + fetch_relevance (high/med/low) + engines_consensus. related_queries from result titles+snippets. Blocked backends circuit-broken 60s. NEVER answer from snippets alone. Filters in options: site, exclude_sites, location, language, region, page, freshness.",
501
+ promptSnippet: "web_search(query) - keyless web search across 10 backends; returns ranked URLs (not content), related_queries, consensus. web_fetch the results that match.",
502
+ parameters: Type.Object({
503
+ query: Type.String({ description: "Search query" }),
504
+ options: Type.Optional(Type.Object({}, { additionalProperties: true, description: "max_results (1-50,6), cache_ttl (300), mode (auto|neural|find_similar), engines (list), site, exclude_sites, location, language, region, page, freshness, url (for find_similar)" })),
505
+ }),
506
+ async execute(_id, params, signal, _onUpdate, _ctx) {
507
+ try {
508
+ const args: Record<string, any> = { query: params.query };
509
+ if (params.options && Object.keys(params.options).length) args.options = params.options;
510
+ const result = await hound.call("mcp_smart_search", args, 60_000, signal);
511
+ const text = getText(result);
512
+ const parsed = tryJson(text);
513
+ const results: any[] = parsed.results ?? [];
514
+ const body = results.length === 0
515
+ ? `No results for "${params.query}".`
516
+ : results.map((r: any, i: number) => {
517
+ const cons = r.engines_consensus ? ` [consensus ${r.engines_consensus}]` : "";
518
+ const tier = r.fetch_relevance ? ` (${r.fetch_relevance})` : "";
519
+ return `[${i + 1}]${tier} ${r.title || "(untitled)"}${cons}\n ${r.url || ""}\n ${r.snippet || ""}`;
520
+ }).join("\n\n");
521
+ const foot: string[] = [];
522
+ if (Array.isArray(parsed.related_queries) && parsed.related_queries.length)
523
+ foot.push(`Related: ${parsed.related_queries.slice(0, 6).map((q: string) => `"${q}"`).join(", ")}`);
524
+ if (Array.isArray(parsed.engine_blocked) && parsed.engine_blocked.length)
525
+ foot.push(`Blocked: ${parsed.engine_blocked.join(", ")}`);
526
+ if (parsed.next_action) foot.push(`Next: ${parsed.next_action}`);
527
+ return {
528
+ content: [{ type: "text", text: body + (foot.length ? `\n\n${foot.join("\n")}` : "") }],
529
+ details: { query: params.query, count: results.length, related: parsed.related_queries ?? [], blocked: parsed.engine_blocked ?? [] },
530
+ };
531
+ } catch (e: any) {
532
+ const msg = e.message.includes("Hound not found") || e.message.includes("Hound failed") ? e.message : e.message.includes("timeout") ? "Search timed out" : e.message.includes("cancelled") ? "Search cancelled" : e.message.includes("Hound closed") ? "Connection to hound lost" : `Search failed: ${e.message}`;
533
+ return { content: [{ type: "text", text: msg }], details: { error: msg } };
534
+ }
535
+ },
536
+ renderCall(args, theme) {
537
+ const q = (args.query ?? "").toString();
538
+ const trunc = q.length > 50 ? q.slice(0, 47) + "..." : q;
539
+ return new Text(theme.fg("toolTitle", theme.bold("Web Search: ")) + theme.fg("accent", `"${trunc}"`), 0, 0);
540
+ },
541
+ renderResult(result, { isPartial }, theme) {
542
+ if (isPartial) return new Text(theme.fg("dim", "searching..."), 0, 0);
543
+ const d = result.details as any;
544
+ if (d?.error) return new Text(theme.fg("error", `${d.error}`), 0, 0);
545
+ if (!d?.count) return new Text(theme.fg("dim", "no results"), 0, 0);
546
+ const rel = d.related?.length ? ` + ${d.related.length} related` : "";
547
+ return new Text(theme.fg("accent", `${d.count} results${rel}`), 0, 0);
548
+ },
549
+ });
550
+
551
+ // -- web_crawl --
552
+ pi.registerTool({
553
+ name: "web_crawl",
554
+ label: "Web Crawl",
555
+ description: "Deep-crawl a site: best-first same-domain walk, each page as markdown + content_ok + page_type. List pages -> structured link list. sitemap=true (in options) maps whole site from sitemap.xml in one fetch; sitemap='auto' = use if present else BFS. discover_only=true = URL map only. focus='query' prioritizes relevant pages + focus-filters each. crawl_urls=[...] fetches a chosen subset. Caps: max_pages (10), max_depth (2), max_total_chars, deadline_ms. Reuses web_fetch anti-bot + cache.",
556
+ promptSnippet: "web_crawl(url, focus, options.sitemap, discover_only, crawl_urls, max_pages) - site crawl; sitemap=true maps a whole site in one fetch; crawl_urls fetches a chosen subset.",
557
+ parameters: Type.Object({
558
+ url: Type.String({ description: "Start URL (crawl stays on this domain)" }),
559
+ focus: Type.Optional(Type.String({ description: "Query: prioritize crawling links relevant to this + focus-filter each page. Token saver on doc sites." })),
560
+ discover_only: Type.Optional(Type.Boolean({ description: "true = return URL map only, no page content. For big sites prefer options sitemap=true." })),
561
+ crawl_urls: Type.Optional(Type.Array(Type.String(), { description: "Chosen subset of URLs to fetch (second-phase selective crawl, no re-discovery). Use after sitemap=true or discover_only=true." })),
562
+ options: Type.Optional(Type.Object({}, { additionalProperties: true, description: "sitemap (true|'auto'|false,false: true=map from sitemap.xml in one fetch; 'auto'=use if present else BFS), max_pages (1-100,10), max_depth (0-5,2), path_include (list of path prefixes), path_exclude (list to skip), max_content_chars_per (8000), max_total_chars (token budget), concurrency (1-5,3), cache_ttl (3600;0=fresh), respect_robots (false), force_fetcher ('http'|'stealthy'), timeout (ms,30000), deadline_ms (120000)." })),
563
+ }),
564
+ async execute(_id, params, signal, _onUpdate, _ctx) {
565
+ try {
566
+ const args = pick(params, ["url", "focus", "discover_only", "crawl_urls", "options"]);
567
+ const result = await hound.call("mcp_smart_crawl", args, CRAWL_TIMEOUT_MS, signal);
568
+ const text = getText(result);
569
+ const parsed = tryJson(text);
570
+ const pages: any[] = Array.isArray(parsed.pages) ? parsed.pages : [];
571
+ let body: string;
572
+ if (parsed.sitemap_used || (args.discover_only && pages.length)) {
573
+ body = pages.map((p: any, i: number) => `[${i + 1}] ${p.url}${p.lastmod ? ` (${p.lastmod})` : ""}`).join("\n");
574
+ } else {
575
+ body = pages.map((p: any) => {
576
+ const tag = p.page_type ? `[${p.page_type}]` : "";
577
+ const ok = p.content_ok ? "" : " WARNING";
578
+ const c = Array.isArray(p.content) ? p.content.join("\n") : "";
579
+ return `## ${p.url} ${tag}${ok}\n${c}`;
580
+ }).join("\n\n---\n\n");
581
+ }
582
+ const foot: string[] = [];
583
+ if (parsed.summary) foot.push(parsed.summary);
584
+ if (parsed.next_action) foot.push(`Next: ${parsed.next_action}`);
585
+ return {
586
+ content: [{ type: "text", text: body + (foot.length ? `\n\n${foot.join("\n")}` : "") }],
587
+ details: { url: params.url, pages: pages.length, sitemap: !!parsed.sitemap_used },
588
+ };
589
+ } catch (e: any) {
590
+ const msg = e.message.includes("Hound not found") || e.message.includes("Hound failed") ? e.message : e.message.includes("timeout") ? "Crawl timed out" : e.message.includes("cancelled") ? "Crawl cancelled" : e.message.includes("Hound closed") ? "Connection to hound lost" : `Crawl failed: ${e.message}`;
591
+ return { content: [{ type: "text", text: msg }], details: { error: msg } };
592
+ }
593
+ },
594
+ renderCall(args, theme) {
595
+ const urlStr = (args.url ?? "").toString();
596
+ const trunc = urlStr.length > 55 ? urlStr.slice(0, 52) + "..." : urlStr;
597
+ const sm = args.options?.sitemap ? " (sitemap)" : "";
598
+ return new Text(theme.fg("toolTitle", theme.bold("Web Crawl: ")) + theme.fg("accent", trunc + sm), 0, 0);
599
+ },
600
+ renderResult(result, { isPartial }, theme) {
601
+ if (isPartial) return new Text(theme.fg("dim", "crawling..."), 0, 0);
602
+ const d = result.details as any;
603
+ if (d?.error) return new Text(theme.fg("error", `${d.error}`), 0, 0);
604
+ const sm = d.sitemap ? " (sitemap)" : "";
605
+ return new Text(theme.fg("accent", `${d.pages} pages${sm}`), 0, 0);
606
+ },
607
+ });
608
+
609
+ // -- web_screenshot --
610
+ pi.registerTool({
611
+ name: "web_screenshot",
612
+ label: "Web Screenshot",
613
+ description: "Screenshot a URL as an image. Multimodal agents only (content as images/canvas/visual layout). Text agents: use web_fetch. Stealthy browser auto-managed.",
614
+ promptSnippet: "web_screenshot(url, options.full_page, options.image_type) - anti-bot one-shot screenshot of a URL (stealthy browser). Multimodal agents only.",
615
+ parameters: Type.Object({
616
+ url: Type.String({ description: "URL to screenshot" }),
617
+ options: Type.Optional(Type.Object({}, { additionalProperties: true, description: "full_page (bool,false), image_type (png|jpeg,png), quality (0-100,jpeg), wait (ms), wait_selector (css), network_idle (bool), timeout (ms,30000)." })),
618
+ }),
619
+ async execute(_id, params, signal, _onUpdate, _ctx) {
620
+ try {
621
+ const args: Record<string, any> = { url: params.url };
622
+ if (params.options && Object.keys(params.options).length) args.options = params.options;
623
+ const result = await hound.call("mcp_screenshot", args, CALL_TIMEOUT_MS, signal);
624
+ const images = getImages(result);
625
+ if (images.length) {
626
+ const out: any[] = [];
627
+ for (const img of images) out.push({ type: "image", data: img.data, mimeType: img.mimeType || "image/png" });
628
+ out.push({ type: "text", text: `Screenshot captured for ${params.url}` });
629
+ return { content: out, details: { url: params.url, images: images.length } };
630
+ }
631
+ const parsed = tryJson(getText(result));
632
+ return {
633
+ content: [{ type: "text", text: parsed.error ? `Capture failed: ${parsed.error}` : `Screenshot captured for ${params.url} (no image payload)` }],
634
+ details: { url: params.url, images: 0, error: parsed.error ?? "" },
635
+ };
636
+ } catch (e: any) {
637
+ const msg = e.message.includes("Hound not found") || e.message.includes("Hound failed") ? e.message : e.message.includes("timeout") ? "Screenshot timed out" : e.message.includes("cancelled") ? "Screenshot cancelled" : e.message.includes("Hound closed") ? "Connection to hound lost" : `Screenshot failed: ${e.message}`;
638
+ return { content: [{ type: "text", text: msg }], details: { error: msg } };
639
+ }
640
+ },
641
+ renderCall(args, theme) {
642
+ const urlStr = (args.url ?? "").toString();
643
+ const trunc = urlStr.length > 55 ? urlStr.slice(0, 52) + "..." : urlStr;
644
+ return new Text(theme.fg("toolTitle", theme.bold("Web Screenshot: ")) + theme.fg("accent", trunc), 0, 0);
645
+ },
646
+ renderResult(result, { isPartial }, theme) {
647
+ if (isPartial) return new Text(theme.fg("dim", "capturing..."), 0, 0);
648
+ const d = result.details as any;
649
+ if (d?.error) return new Text(theme.fg("error", `${d.error}`), 0, 0);
650
+ if (!d?.images) return new Text(theme.fg("dim", "no image"), 0, 0);
651
+ return new Text(theme.fg("accent", `${d.images} image`), 0, 0);
652
+ },
653
+ });
654
+
655
+ // -- cache_clear --
656
+ pi.registerTool({
657
+ name: "cache_clear",
658
+ label: "Clear Cache",
659
+ description: "Clear fetch cache. all=true wipes all (default: expired only). To re-fetch one URL fresh, pass cache_ttl=0 to web_fetch/web_crawl instead. Cache stores extracted text per URL+extraction_type+css_selector+pages (+ per query+filters for search); default TTL 1hr.",
660
+ promptSnippet: "cache_clear(all) - clear fetch cache. all=true wipes all (default: expired only). For one URL fresh, use cache_ttl=0 on web_fetch instead.",
661
+ parameters: Type.Object({
662
+ all: Type.Optional(Type.Boolean({ description: "Wipe all (default: expired only)" })),
663
+ }),
664
+ async execute(_id, params, signal, _onUpdate, _ctx) {
665
+ try {
666
+ const args = { all: params.all ?? false };
667
+ const result = await hound.call("cache_clear", args, 10_000, signal);
668
+ return { content: [{ type: "text", text: getText(result) }], details: { all: args.all } };
669
+ } catch (e: any) {
670
+ return { content: [{ type: "text", text: e.message.includes("Hound") ? e.message : `Cache clear failed: ${e.message}` }], details: { error: e.message } };
671
+ }
672
+ },
673
+ renderCall(_args, theme) {
674
+ return new Text(theme.fg("toolTitle", theme.bold("Clear Cache")), 0, 0);
675
+ },
676
+ renderResult(result, _meta, theme) {
677
+ const d = result.details as any;
678
+ if (d?.error) return new Text(theme.fg("error", `${d.error}`), 0, 0);
679
+ return new Text(theme.fg("accent", "cleared"), 0, 0);
680
+ },
681
+ });
682
+
683
+ // -- hound_version --
684
+ pi.registerTool({
685
+ name: "hound_version",
686
+ label: "Hound Version",
687
+ description: "Hound version + update status.",
688
+ promptSnippet: "hound_version() - hound version + update status.",
689
+ parameters: Type.Object({}),
690
+ async execute(_id, _params, signal, _onUpdate, _ctx) {
691
+ try {
692
+ const result = await hound.call("version", {}, 10_000, signal);
693
+ const parsed = tryJson(getText(result));
694
+ const v = parsed.version || "unknown";
695
+ const latest = parsed.latest || "";
696
+ const upToDate = parsed.up_to_date;
697
+ const extInfo = EXTENSION_VERSION !== "unknown" ? ` | extension v${EXTENSION_VERSION}` : "";
698
+ const text = upToDate
699
+ ? `Hound ${v} (up to date)${extInfo}`
700
+ : `Hound ${v} (update available: ${latest})${extInfo}. Run: hound -u`;
701
+ return { content: [{ type: "text", text }], details: { version: v, latest, up_to_date: upToDate, extension: EXTENSION_VERSION } };
702
+ } catch (e: any) {
703
+ // Fallback: try hound --version via execSync (works even when MCP server is down)
704
+ try {
705
+ const exe = hound.getExe();
706
+ if (exe) {
707
+ const out = execSync(`"${exe}" --version`, { timeout: 5000, windowsHide: true }).toString().trim();
708
+ return { content: [{ type: "text", text: out }], details: { fallback: true, extension: EXTENSION_VERSION } };
709
+ }
710
+ } catch {}
711
+ return { content: [{ type: "text", text: e.message.includes("Hound") ? e.message : `Version check failed: ${e.message}` }], details: { error: e.message, extension: EXTENSION_VERSION } };
712
+ }
713
+ },
714
+ renderCall(_args, theme) {
715
+ return new Text(theme.fg("toolTitle", theme.bold("Hound Version")), 0, 0);
716
+ },
717
+ renderResult(result, _meta, theme) {
718
+ const d = result.details as any;
719
+ if (d?.error) return new Text(theme.fg("error", `${d.error}`), 0, 0);
720
+ return new Text(theme.fg("accent", "ok"), 0, 0);
721
+ },
722
+ });
723
+ }