@houndmcp/hound-mcp-pi 10.3.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.
package/README.md ADDED
@@ -0,0 +1,35 @@
1
+ # hound-mcp-pi
2
+
3
+ Hound web research for the Pi agent. Six native tools, all routed through a single long-lived Hound MCP subprocess:
4
+
5
+ - **web_fetch** - anti-bot fetch + clean extraction + Internet Archive dead-link recovery
6
+ - **web_search** - keyless web search across 10 backends, neural-reranked
7
+ - **web_crawl** - deep site crawl, sitemap one-fetch map
8
+ - **web_screenshot** - anti-bot screenshot for multimodal agents
9
+ - **cache_clear** - clear fetch cache
10
+ - **hound_version** - version + update status
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ # 1. Install Hound (the MCP server / engine)
16
+ pip install hound-mcp[all]
17
+
18
+ # 2. Install this Pi extension
19
+ pi install git:github.com/dondai1234/master-fetch@v10.3.0
20
+ ```
21
+
22
+ That's it. The extension auto-discovers `hound` on your PATH, spawns it as a singleton subprocess, and prewarms it at session start. No API key, no account, no config file.
23
+
24
+ ## How it works
25
+
26
+ The extension spawns `hound` (the MCP server) as a stdio subprocess and speaks MCP JSON-RPC to it. The subprocess is a singleton — it stays alive for the whole Pi session, so Hound's startup prewarm (stealthy browser, search engines, neural reranker) runs once and persists. Zero re-launch cost per call.
27
+
28
+ ## Requirements
29
+
30
+ - [Pi coding agent](https://github.com/earendil-works/pi-coding-agent)
31
+ - Python 3.11+ with `hound-mcp[all]` installed (`pip install hound-mcp[all]`)
32
+
33
+ ## License
34
+
35
+ MIT. Same as Hound itself.
@@ -0,0 +1,569 @@
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 git:github.com/dondai1234/master-fetch@v10.3.0
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
+ // -- Hound executable resolution --
30
+
31
+ function resolveHoundExe(): string | null {
32
+ try {
33
+ const cmd = process.platform === "win32" ? "where hound.exe" : "which hound";
34
+ const out = execSync(cmd, {
35
+ stdio: ["ignore", "pipe", "ignore"],
36
+ timeout: 2000,
37
+ windowsHide: true,
38
+ }).toString().trim();
39
+ const first = out.split(/\r?\n/)[0];
40
+ if (first && fs.existsSync(first)) return first;
41
+ } catch {}
42
+ const fallback = path.join(
43
+ os.homedir(),
44
+ "AppData", "Local", "Programs", "Python", "Python311", "Scripts", "hound.exe",
45
+ );
46
+ return fs.existsSync(fallback) ? fallback : null;
47
+ }
48
+
49
+ const HOUND_EXE = resolveHoundExe();
50
+ const INIT_TIMEOUT_MS = 15_000;
51
+ const CALL_TIMEOUT_MS = 90_000;
52
+ const CRAWL_TIMEOUT_MS = 150_000;
53
+ const INIT_ATTEMPTS = 3;
54
+ const INIT_BACKOFF_MS = 400;
55
+
56
+ // -- MCP Stdio Client (singleton subprocess + JSON-RPC) --
57
+ //
58
+ // Hound is spawned once per Pi session and kept alive. Its startup prewarm
59
+ // (stealthy browser + search-engine sessions + neural reranker ONNX load)
60
+ // runs in the background during session_start, so by the time the agent
61
+ // makes its first web_fetch/search call, Hound is already warm. A dead
62
+ // subprocess (crash, OS kill) triggers a transparent re-init on the next
63
+ // call - callers never see a raw spawn error, just a slightly slower call.
64
+ //
65
+ // The MCP initialize handshake has a hard timeout. Hound occasionally spawns
66
+ // dead: its synchronous import playwright (via _get_scrapling) runs on the
67
+ // single-threaded asyncio loop and freezes it so server.run can never write
68
+ // the reply. The process is alive but mute - no stderr, no exit - so a longer
69
+ // timeout cannot help. A fresh re-spawn reliably responds in ~2-3s, so we
70
+ // kill the dead one and retry up to INIT_ATTEMPTS times.
71
+
72
+ interface Pending {
73
+ resolve: (v: any) => void;
74
+ reject: (e: Error) => void;
75
+ timer: NodeJS.Timeout;
76
+ }
77
+
78
+ class HoundClient {
79
+ private proc: ChildProcess | null = null;
80
+ private ready: Promise<boolean> | null = null;
81
+ private initInFlight: Promise<boolean> | null = null;
82
+ private nextId = 0;
83
+ private pending = new Map<number, Pending>();
84
+ private stdoutBuf = "";
85
+ private lastStderr = "";
86
+
87
+ async ensureReady(): Promise<boolean> {
88
+ if (this.ready) return this.ready;
89
+ if (!HOUND_EXE) return false;
90
+ if (this.initInFlight) return this.initInFlight;
91
+ const p = this._initWithRetry();
92
+ this.initInFlight = p;
93
+ p.then((ok) => {
94
+ this.initInFlight = null;
95
+ if (ok) this.ready = p;
96
+ else this.ready = null;
97
+ });
98
+ return p;
99
+ }
100
+
101
+ private async _initWithRetry(): Promise<boolean> {
102
+ for (let attempt = 1; attempt <= INIT_ATTEMPTS; attempt++) {
103
+ if (await this._initOnce()) return true;
104
+ if (attempt < INIT_ATTEMPTS) await new Promise((r) => setTimeout(r, INIT_BACKOFF_MS));
105
+ }
106
+ return false;
107
+ }
108
+
109
+ private _initOnce(): Promise<boolean> {
110
+ return new Promise<boolean>((resolve) => {
111
+ let settled = false;
112
+ let initTimer: NodeJS.Timeout | undefined;
113
+ let stderrBuf = "";
114
+ const done = (ok: boolean) => {
115
+ if (settled) return;
116
+ settled = true;
117
+ if (initTimer) clearTimeout(initTimer);
118
+ if (!ok) this.kill();
119
+ resolve(ok);
120
+ };
121
+ try {
122
+ this.proc = spawn(HOUND_EXE!, [], {
123
+ stdio: ["pipe", "pipe", "pipe"],
124
+ windowsHide: true,
125
+ env: { ...process.env },
126
+ });
127
+ } catch (e: any) {
128
+ this.lastStderr = `spawn threw: ${e?.message ?? e}`;
129
+ done(false);
130
+ return;
131
+ }
132
+ this.proc.on("error", (e: any) => {
133
+ this.lastStderr = `spawn error: ${e?.message ?? e}`;
134
+ done(false);
135
+ });
136
+ this.proc.on("close", (code, signal) => {
137
+ for (const [, { reject, timer }] of this.pending) {
138
+ clearTimeout(timer);
139
+ reject(new Error("Hound closed"));
140
+ }
141
+ this.pending.clear();
142
+ if (!settled) this.lastStderr = `exited before init (code=${code} signal=${signal})`;
143
+ this.ready = null;
144
+ done(false);
145
+ });
146
+ this.proc.stderr?.on("data", (chunk: Buffer) => {
147
+ stderrBuf += chunk.toString("utf-8");
148
+ if (stderrBuf.length > 4000) stderrBuf = stderrBuf.slice(-4000);
149
+ });
150
+ this.proc.stdout?.on("data", (chunk: Buffer) => {
151
+ this.stdoutBuf += chunk.toString("utf-8");
152
+ this._drain();
153
+ });
154
+ initTimer = setTimeout(() => {
155
+ this.lastStderr = `initialize timed out after ${INIT_TIMEOUT_MS}ms (dead spawn)`;
156
+ done(false);
157
+ }, INIT_TIMEOUT_MS);
158
+ const id = ++this.nextId;
159
+ this.pending.set(id, {
160
+ resolve: () => {
161
+ this._notify("notifications/initialized", {});
162
+ this.lastStderr = "";
163
+ done(true);
164
+ },
165
+ reject: (e: Error) => {
166
+ this.lastStderr = `initialize rejected: ${e.message}`;
167
+ done(false);
168
+ },
169
+ timer: initTimer,
170
+ });
171
+ try {
172
+ this.proc.stdin!.write(
173
+ JSON.stringify({
174
+ jsonrpc: "2.0",
175
+ method: "initialize",
176
+ params: {
177
+ protocolVersion: "2025-03-26",
178
+ capabilities: {},
179
+ clientInfo: { name: "pi-hound", version: "10.3.0" },
180
+ },
181
+ id,
182
+ }) + "\n",
183
+ );
184
+ } catch (e: any) {
185
+ this.pending.delete(id);
186
+ this.lastStderr = `stdin write failed: ${e?.message ?? e}`;
187
+ done(false);
188
+ }
189
+ });
190
+ }
191
+
192
+ private _drain() {
193
+ let idx: number;
194
+ while ((idx = this.stdoutBuf.indexOf("\n")) !== -1) {
195
+ const line = this.stdoutBuf.slice(0, idx).trim();
196
+ this.stdoutBuf = this.stdoutBuf.slice(idx + 1);
197
+ if (!line) continue;
198
+ try {
199
+ const msg = JSON.parse(line);
200
+ if (msg.id != null && this.pending.has(msg.id)) {
201
+ const { resolve, reject, timer } = this.pending.get(msg.id)!;
202
+ this.pending.delete(msg.id);
203
+ clearTimeout(timer);
204
+ if (msg.error) reject(new Error(msg.error?.message ?? JSON.stringify(msg.error)));
205
+ else resolve(msg.result);
206
+ }
207
+ } catch {}
208
+ }
209
+ }
210
+
211
+ private _notify(method: string, params: any) {
212
+ if (!this.proc || this.proc.killed) return;
213
+ try {
214
+ this.proc.stdin!.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n");
215
+ } catch {}
216
+ }
217
+
218
+ async call(name: string, args: Record<string, any>, timeoutMs = CALL_TIMEOUT_MS): Promise<any> {
219
+ const ready = await this.ensureReady();
220
+ if (!ready) {
221
+ const hint = HOUND_EXE
222
+ ? `Hound failed to start after ${INIT_ATTEMPTS} attempts. ${this.lastStderr || "Silent dead hang."} Run: hound --doctor`
223
+ : "Hound not found. Install: pip install hound-mcp[all]";
224
+ throw new Error(hint);
225
+ }
226
+ return new Promise((resolve, reject) => {
227
+ if (!this.proc || this.proc.killed) {
228
+ reject(new Error("Hound not running"));
229
+ return;
230
+ }
231
+ const id = ++this.nextId;
232
+ const timer = setTimeout(() => {
233
+ this.pending.delete(id);
234
+ reject(new Error(`Hound timeout: ${name}`));
235
+ }, timeoutMs);
236
+ this.pending.set(id, { resolve, reject, timer });
237
+ try {
238
+ this.proc.stdin!.write(
239
+ JSON.stringify({ jsonrpc: "2.0", method: "tools/call", params: { name, arguments: args }, id }) + "\n",
240
+ );
241
+ } catch (e) {
242
+ clearTimeout(timer);
243
+ this.pending.delete(id);
244
+ reject(e as Error);
245
+ }
246
+ });
247
+ }
248
+
249
+ kill() {
250
+ if (this.proc && !this.proc.killed) {
251
+ try { this.proc.stdin?.end(); } catch {}
252
+ try { this.proc.kill(); } catch {}
253
+ }
254
+ this.proc = null;
255
+ this.ready = null;
256
+ this.initInFlight = null;
257
+ for (const [, { timer }] of this.pending) clearTimeout(timer);
258
+ this.pending.clear();
259
+ }
260
+ }
261
+
262
+ const hound = new HoundClient();
263
+
264
+ // -- Helpers --
265
+
266
+ function getText(result: any): string {
267
+ const content = result?.content ?? [];
268
+ return content.filter((c: any) => c?.type === "text").map((c: any) => c.text).join("\n") || "(no output)";
269
+ }
270
+
271
+ function getImages(result: any): any[] {
272
+ const content = result?.content ?? [];
273
+ return content.filter((c: any) => c?.type === "image");
274
+ }
275
+
276
+ function tryJson(text: string): any {
277
+ try { return JSON.parse(text); } catch { return {}; }
278
+ }
279
+
280
+ function pick(params: Record<string, any>, keys: string[]): Record<string, any> {
281
+ const out: Record<string, any> = {};
282
+ for (const k of keys) {
283
+ if (params[k] !== undefined) out[k] = params[k];
284
+ }
285
+ return out;
286
+ }
287
+
288
+ // -- Extension --
289
+
290
+ export default function (pi: ExtensionAPI) {
291
+ pi.on("session_start", () => { hound.ensureReady().catch(() => {}); });
292
+ pi.on("session_shutdown", () => { hound.kill(); });
293
+
294
+ // -- web_fetch --
295
+ pi.registerTool({
296
+ name: "web_fetch",
297
+ label: "Web Fetch",
298
+ 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.",
299
+ 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.",
300
+ parameters: Type.Object({
301
+ url: Type.Optional(Type.String({ description: "URL to fetch" })),
302
+ urls: Type.Optional(Type.Array(Type.String(), { description: "Multiple URLs (parallel; returns per-URL results)" })),
303
+ extraction_type: Type.Optional(Type.String({ description: "Content format (default markdown). html = raw HTML." })),
304
+ css_selector: Type.Optional(Type.String({ description: "CSS selector to narrow extracted content (e.g. 'article', '.main'). Token saver." })),
305
+ 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." })),
306
+ timeout: Type.Optional(Type.Integer({ description: "Max request time in ms (default 30000)." })),
307
+ cache_ttl: Type.Optional(Type.Integer({ description: "Cache seconds (default 3600). 0 = force fresh." })),
308
+ 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." })),
309
+ offset: Type.Optional(Type.Integer({ description: "Char offset into extracted text to resume a truncated page. Use next_offset from previous response." })),
310
+ 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." })),
311
+ password: Type.Optional(Type.String({ description: "PDF only: password for an encrypted PDF." })),
312
+ 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." })),
313
+ 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'}." })),
314
+ 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)." })),
315
+ }),
316
+ async execute(_id, params, _signal, _onUpdate, _ctx) {
317
+ try {
318
+ const args = pick(params, ["url", "urls", "extraction_type", "css_selector", "max_content_chars", "timeout", "cache_ttl", "force_fetcher", "offset", "pages", "password", "focus", "actions", "options"]);
319
+ const result = await hound.call("mcp_smart_fetch", args, CALL_TIMEOUT_MS);
320
+ const text = getText(result);
321
+ const parsed = tryJson(text);
322
+ if (Array.isArray(parsed.results)) {
323
+ const ok = parsed.successful ?? 0;
324
+ const total = parsed.total ?? parsed.results.length;
325
+ const joined = parsed.results.map((r: any) => `# ${r.url}\n${(r.content ?? []).join("\n")}`).join("\n\n---\n\n");
326
+ return { content: [{ type: "text", text: joined + `\n\n[${ok}/${total} OK]` }], details: { bulk: true, ok, total } };
327
+ }
328
+ const content = Array.isArray(parsed.content) ? parsed.content.join("\n") : text;
329
+ const foot: string[] = [];
330
+ if (parsed.summary) foot.push(parsed.summary);
331
+ if (parsed.next_action) foot.push(`Next: ${parsed.next_action}`);
332
+ if (parsed.content_ok === false) foot.push("WARNING: content_ok=false - content may be a JS shell/login wall/error page");
333
+ const trunc = parsed.is_truncated ? ` | next_offset=${parsed.next_offset}` : "";
334
+ const fetcher = parsed.fetcher_used ?? "";
335
+ const src = parsed.source === "archive.org" ? ` | ARCHIVE ${parsed.archived_at ?? ""}` : "";
336
+ return {
337
+ content: [{ type: "text", text: content + (foot.length ? `\n\n${foot.join("\n")}` : "") + `\n[${fetcher}${trunc}${src}]` }],
338
+ details: { url: args.url, chars: content.length, content_ok: parsed.content_ok, truncated: !!parsed.is_truncated, source: parsed.source ?? "live" },
339
+ };
340
+ } catch (e: any) {
341
+ return { content: [{ type: "text", text: `web_fetch error: ${e.message}` }], details: { error: e.message } };
342
+ }
343
+ },
344
+ renderCall(args, theme) {
345
+ const urlStr = (args.url ?? args.urls?.[0] ?? "").toString();
346
+ const trunc = urlStr.length > 60 ? urlStr.slice(0, 57) + "..." : urlStr;
347
+ return new Text(theme.fg("toolTitle", theme.bold("Web Fetch: ")) + theme.fg("accent", trunc), 0, 0);
348
+ },
349
+ renderResult(result, { isPartial }, theme) {
350
+ if (isPartial) return new Text(theme.fg("dim", "fetching..."), 0, 0);
351
+ const d = result.details as any;
352
+ if (d?.error) return new Text(theme.fg("error", `error: ${d.error}`), 0, 0);
353
+ if (d?.bulk) return new Text(theme.fg("accent", `${d.ok}/${d.total} URLs`), 0, 0);
354
+ const kb = ((d?.chars ?? 0) / 1024).toFixed(1);
355
+ const src = d?.source === "archive.org" ? " (archive)" : "";
356
+ const warn = d?.content_ok === false ? " !" : "";
357
+ return new Text(theme.fg("accent", `${kb}KB${src}${warn}`), 0, 0);
358
+ },
359
+ });
360
+
361
+ // -- web_search --
362
+ pi.registerTool({
363
+ name: "web_search",
364
+ label: "Web Search",
365
+ 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.",
366
+ promptSnippet: "web_search(query) - keyless web search across 10 backends; returns ranked URLs (not content), related_queries, consensus. web_fetch the results that match.",
367
+ parameters: Type.Object({
368
+ query: Type.String({ description: "Search query" }),
369
+ 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)" })),
370
+ }),
371
+ async execute(_id, params, _signal, _onUpdate, _ctx) {
372
+ try {
373
+ const args: Record<string, any> = { query: params.query };
374
+ if (params.options && Object.keys(params.options).length) args.options = params.options;
375
+ const result = await hound.call("mcp_smart_search", args, 60_000);
376
+ const text = getText(result);
377
+ const parsed = tryJson(text);
378
+ const results: any[] = parsed.results ?? [];
379
+ const body = results.length === 0
380
+ ? `No results for "${params.query}".`
381
+ : results.map((r: any, i: number) => {
382
+ const cons = r.engines_consensus ? ` [consensus ${r.engines_consensus}]` : "";
383
+ const tier = r.fetch_relevance ? ` (${r.fetch_relevance})` : "";
384
+ return `[${i + 1}]${tier} ${r.title || "(untitled)"}${cons}\n ${r.url || ""}\n ${r.snippet || ""}`;
385
+ }).join("\n\n");
386
+ const foot: string[] = [];
387
+ if (Array.isArray(parsed.related_queries) && parsed.related_queries.length)
388
+ foot.push(`Related: ${parsed.related_queries.slice(0, 6).map((q: string) => `"${q}"`).join(", ")}`);
389
+ if (Array.isArray(parsed.engine_blocked) && parsed.engine_blocked.length)
390
+ foot.push(`Blocked: ${parsed.engine_blocked.join(", ")}`);
391
+ if (parsed.next_action) foot.push(`Next: ${parsed.next_action}`);
392
+ return {
393
+ content: [{ type: "text", text: body + (foot.length ? `\n\n${foot.join("\n")}` : "") }],
394
+ details: { query: params.query, count: results.length, related: parsed.related_queries ?? [], blocked: parsed.engine_blocked ?? [] },
395
+ };
396
+ } catch (e: any) {
397
+ return { content: [{ type: "text", text: `web_search error: ${e.message}` }], details: { error: e.message } };
398
+ }
399
+ },
400
+ renderCall(args, theme) {
401
+ const q = (args.query ?? "").toString();
402
+ const trunc = q.length > 50 ? q.slice(0, 47) + "..." : q;
403
+ return new Text(theme.fg("toolTitle", theme.bold("Web Search: ")) + theme.fg("accent", `"${trunc}"`), 0, 0);
404
+ },
405
+ renderResult(result, { isPartial }, theme) {
406
+ if (isPartial) return new Text(theme.fg("dim", "searching..."), 0, 0);
407
+ const d = result.details as any;
408
+ if (d?.error) return new Text(theme.fg("error", `error: ${d.error}`), 0, 0);
409
+ if (!d?.count) return new Text(theme.fg("dim", "no results"), 0, 0);
410
+ const rel = d.related?.length ? ` + ${d.related.length} related` : "";
411
+ return new Text(theme.fg("accent", `${d.count} results${rel}`), 0, 0);
412
+ },
413
+ });
414
+
415
+ // -- web_crawl --
416
+ pi.registerTool({
417
+ name: "web_crawl",
418
+ label: "Web Crawl",
419
+ 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.",
420
+ 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.",
421
+ parameters: Type.Object({
422
+ url: Type.String({ description: "Start URL (crawl stays on this domain)" }),
423
+ focus: Type.Optional(Type.String({ description: "Query: prioritize crawling links relevant to this + focus-filter each page. Token saver on doc sites." })),
424
+ discover_only: Type.Optional(Type.Boolean({ description: "true = return URL map only, no page content. For big sites prefer options sitemap=true." })),
425
+ 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." })),
426
+ 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)." })),
427
+ }),
428
+ async execute(_id, params, _signal, _onUpdate, _ctx) {
429
+ try {
430
+ const args = pick(params, ["url", "focus", "discover_only", "crawl_urls", "options"]);
431
+ const result = await hound.call("mcp_smart_crawl", args, CRAWL_TIMEOUT_MS);
432
+ const text = getText(result);
433
+ const parsed = tryJson(text);
434
+ const pages: any[] = Array.isArray(parsed.pages) ? parsed.pages : [];
435
+ let body: string;
436
+ if (parsed.sitemap_used || (args.discover_only && pages.length)) {
437
+ body = pages.map((p: any, i: number) => `[${i + 1}] ${p.url}${p.lastmod ? ` (${p.lastmod})` : ""}`).join("\n");
438
+ } else {
439
+ body = pages.map((p: any) => {
440
+ const tag = p.page_type ? `[${p.page_type}]` : "";
441
+ const ok = p.content_ok ? "" : " WARNING";
442
+ const c = Array.isArray(p.content) ? p.content.join("\n") : "";
443
+ return `## ${p.url} ${tag}${ok}\n${c}`;
444
+ }).join("\n\n---\n\n");
445
+ }
446
+ const foot: string[] = [];
447
+ if (parsed.summary) foot.push(parsed.summary);
448
+ if (parsed.next_action) foot.push(`Next: ${parsed.next_action}`);
449
+ return {
450
+ content: [{ type: "text", text: body + (foot.length ? `\n\n${foot.join("\n")}` : "") }],
451
+ details: { url: params.url, pages: pages.length, sitemap: !!parsed.sitemap_used },
452
+ };
453
+ } catch (e: any) {
454
+ return { content: [{ type: "text", text: `web_crawl error: ${e.message}` }], details: { error: e.message } };
455
+ }
456
+ },
457
+ renderCall(args, theme) {
458
+ const urlStr = (args.url ?? "").toString();
459
+ const trunc = urlStr.length > 55 ? urlStr.slice(0, 52) + "..." : urlStr;
460
+ const sm = args.options?.sitemap ? " (sitemap)" : "";
461
+ return new Text(theme.fg("toolTitle", theme.bold("Web Crawl: ")) + theme.fg("accent", trunc + sm), 0, 0);
462
+ },
463
+ renderResult(result, { isPartial }, theme) {
464
+ if (isPartial) return new Text(theme.fg("dim", "crawling..."), 0, 0);
465
+ const d = result.details as any;
466
+ if (d?.error) return new Text(theme.fg("error", `error: ${d.error}`), 0, 0);
467
+ const sm = d.sitemap ? " (sitemap)" : "";
468
+ return new Text(theme.fg("accent", `${d.pages} pages${sm}`), 0, 0);
469
+ },
470
+ });
471
+
472
+ // -- web_screenshot --
473
+ pi.registerTool({
474
+ name: "web_screenshot",
475
+ label: "Web Screenshot",
476
+ 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.",
477
+ promptSnippet: "web_screenshot(url, full_page, image_type) - anti-bot one-shot screenshot of a URL (stealthy browser). Multimodal agents only.",
478
+ parameters: Type.Object({
479
+ url: Type.String({ description: "URL to screenshot" }),
480
+ 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)." })),
481
+ }),
482
+ async execute(_id, params, _signal, _onUpdate, _ctx) {
483
+ try {
484
+ const args: Record<string, any> = { url: params.url };
485
+ if (params.options && Object.keys(params.options).length) args.options = params.options;
486
+ const result = await hound.call("mcp_screenshot", args, CALL_TIMEOUT_MS);
487
+ const images = getImages(result);
488
+ if (images.length) {
489
+ const out: any[] = [];
490
+ for (const img of images) out.push({ type: "image", data: img.data, mimeType: img.mimeType || "image/png" });
491
+ out.push({ type: "text", text: `Screenshot captured for ${params.url}` });
492
+ return { content: out, details: { url: params.url, images: images.length } };
493
+ }
494
+ const parsed = tryJson(getText(result));
495
+ return {
496
+ content: [{ type: "text", text: parsed.error ? `Capture failed: ${parsed.error}` : `Screenshot captured for ${params.url} (no image payload)` }],
497
+ details: { url: params.url, images: 0, error: parsed.error ?? "" },
498
+ };
499
+ } catch (e: any) {
500
+ return { content: [{ type: "text", text: `web_screenshot error: ${e.message}` }], details: { error: e.message } };
501
+ }
502
+ },
503
+ renderCall(args, theme) {
504
+ const urlStr = (args.url ?? "").toString();
505
+ const trunc = urlStr.length > 55 ? urlStr.slice(0, 52) + "..." : urlStr;
506
+ return new Text(theme.fg("toolTitle", theme.bold("Web Screenshot: ")) + theme.fg("accent", trunc), 0, 0);
507
+ },
508
+ renderResult(result, { isPartial }, theme) {
509
+ if (isPartial) return new Text(theme.fg("dim", "capturing..."), 0, 0);
510
+ const d = result.details as any;
511
+ if (d?.error) return new Text(theme.fg("error", `error: ${d.error}`), 0, 0);
512
+ if (!d?.images) return new Text(theme.fg("dim", "no image"), 0, 0);
513
+ return new Text(theme.fg("accent", `${d.images} image`), 0, 0);
514
+ },
515
+ });
516
+
517
+ // -- cache_clear --
518
+ pi.registerTool({
519
+ name: "cache_clear",
520
+ label: "Clear Cache",
521
+ 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.",
522
+ 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.",
523
+ parameters: Type.Object({
524
+ all: Type.Optional(Type.Boolean({ description: "Wipe all (default: expired only)" })),
525
+ }),
526
+ async execute(_id, params, _signal, _onUpdate, _ctx) {
527
+ try {
528
+ const args = { all: params.all ?? false };
529
+ const result = await hound.call("cache_clear", args, 10_000);
530
+ return { content: [{ type: "text", text: getText(result) }], details: { all: args.all } };
531
+ } catch (e: any) {
532
+ return { content: [{ type: "text", text: `cache_clear error: ${e.message}` }], details: { error: e.message } };
533
+ }
534
+ },
535
+ renderCall(_args, theme) {
536
+ return new Text(theme.fg("toolTitle", theme.bold("Clear Cache")), 0, 0);
537
+ },
538
+ renderResult(result, _meta, theme) {
539
+ const d = result.details as any;
540
+ if (d?.error) return new Text(theme.fg("error", `error: ${d.error}`), 0, 0);
541
+ return new Text(theme.fg("accent", "cleared"), 0, 0);
542
+ },
543
+ });
544
+
545
+ // -- hound_version --
546
+ pi.registerTool({
547
+ name: "hound_version",
548
+ label: "Hound Version",
549
+ description: "Hound version + update status.",
550
+ promptSnippet: "hound_version() - hound version + update status.",
551
+ parameters: Type.Object({}),
552
+ async execute(_id, _params, _signal, _onUpdate, _ctx) {
553
+ try {
554
+ const result = await hound.call("version", {}, 10_000);
555
+ return { content: [{ type: "text", text: getText(result) }], details: {} };
556
+ } catch (e: any) {
557
+ return { content: [{ type: "text", text: `hound_version error: ${e.message}` }], details: { error: e.message } };
558
+ }
559
+ },
560
+ renderCall(_args, theme) {
561
+ return new Text(theme.fg("toolTitle", theme.bold("Hound Version")), 0, 0);
562
+ },
563
+ renderResult(result, _meta, theme) {
564
+ const d = result.details as any;
565
+ if (d?.error) return new Text(theme.fg("error", `error: ${d.error}`), 0, 0);
566
+ return new Text(theme.fg("accent", "ok"), 0, 0);
567
+ },
568
+ });
569
+ }
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@houndmcp/hound-mcp-pi",
3
+ "version": "10.3.0",
4
+ "description": "Hound web research for Pi agent - keyless search, anti-bot fetch, deep crawl, screenshot, Internet Archive dead-link recovery. Free, MIT, no API key.",
5
+ "publishConfig": {"access": "public"},
6
+ "keywords": ["pi-package", "mcp", "web", "search", "fetch", "crawl", "ai-agent", "scraping"],
7
+ "license": "MIT",
8
+ "author": "dondai1234",
9
+ "repository": { "type": "git", "url": "https://github.com/dondai1234/master-fetch" },
10
+ "homepage": "https://github.com/dondai1234/master-fetch",
11
+ "peerDependencies": {
12
+ "@earendil-works/pi-coding-agent": "*",
13
+ "typebox": "*"
14
+ },
15
+ "pi": {
16
+ "extensions": ["./extensions/hound.ts"]
17
+ }
18
+ }