pi-web-toolkit 0.1.1 → 0.1.2

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 CHANGED
@@ -1,79 +1,125 @@
1
1
  # pi-web-toolkit
2
2
 
3
- Web research toolkit for [pi](https://pi.dev) agents. Search, fetch, browse, and batch-read the web.
3
+ [![npm version](https://badge.fury.io/js/pi-web-toolkit.svg)](https://www.npmjs.com/package/pi-web-toolkit)
4
+ [![CI](https://github.com/Wade11s/pi-web-toolkit/actions/workflows/ci.yml/badge.svg)](https://github.com/Wade11s/pi-web-toolkit/actions)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
6
+ ![Node.js](https://img.shields.io/badge/node-%3E%3D22-339933)
7
+
8
+ **100% open-source. Zero API keys. Zero fees.**
9
+
10
+ Web research toolkit for [pi](https://pi.dev) agents. Search via SearXNG, fetch static pages with scrapling, browse interactively via agent-browser, and batch-read sources in parallel. All self-hosted, all local, all free — with built-in truncation safety and LLM-optimized prompt guidelines.
4
11
 
5
12
  ## Features
6
13
 
7
- | Tool | Purpose |
8
- |------|---------|
9
- | **`web_search`** | Search the web via SearXNG |
10
- | **`web_fetch`** | Fetch a single static page as clean markdown |
11
- | **`web_browse`** | Interact with a page (click, scroll, fill) then extract content |
12
- | **`web_batch_fetch`** | Fetch 2–10 pages in parallel for research synthesis |
14
+ | Tool | Backend | Purpose | Current Limit |
15
+ |------|---------|---------|---------------|
16
+ | **`web_search`** | [SearXNG](https://github.com/searxng/searxng) | Search the web with scored, ranked results from multiple engines — always the first step in web research | 10 results (max 50) |
17
+ | **`web_fetch`** | [scrapling](https://github.com/D4Vinci/Scrapling) | Fetch a single static page as clean markdown | — |
18
+ | **`web_batch_fetch`** | [scrapling](https://github.com/D4Vinci/Scrapling) | Fetch 2–10 pages in parallel for research synthesis | 3 concurrent (max 5) |
19
+ | **`web_browse`** | [agent-browser](https://github.com/vercel-labs/agent-browser) | Interact with a page (click, scroll, fill) then extract content | 25 actions |
13
20
 
14
- ## Installation
21
+ ## Quick Start
15
22
 
16
- ### Option 1: From npm (recommended)
23
+ ### 1. Install external dependencies
17
24
 
18
25
  ```bash
19
- pi install pi-web-toolkit
26
+ # SearXNG (for search)
27
+ docker run -d --name searxng -p 8080:8080 -v searxng:/etc/searxng searxng/searxng
28
+ export SEARXNG_URL="http://localhost:8080"
29
+
30
+ # scrapling (for fetch & batch fetch)
31
+ uv tool install "scrapling[all]"
32
+ scrapling install
33
+
34
+ # agent-browser (for browse)
35
+ npm i -g agent-browser && agent-browser install
20
36
  ```
21
37
 
22
- ### Option 2: From GitHub
38
+ **Verify dependencies:**
39
+ ```bash
40
+ # SearXNG
41
+ curl -s "$SEARXNG_URL" | head
42
+
43
+ # scrapling
44
+ scrapling --help
23
45
 
46
+ # agent-browser
47
+ agent-browser doctor
48
+ ```
49
+
50
+ ### 2. Install the extension
51
+ #### From npm
52
+ ```bash
53
+ pi install npm:pi-web-toolkit
54
+ ```
55
+ #### From GitHub
24
56
  ```bash
25
57
  pi install git:github.com/Wade11s/pi-web-toolkit
26
58
  ```
27
59
 
28
- ## Requirements
29
-
30
- - **Node.js 20** — for running pi extensions
31
- - **SearXNG** — for `web_search`
32
- ```bash
33
- # Set your SearXNG instance URL (default: http://localhost:8080)
34
- export SEARXNG_URL="http://localhost:8080"
35
-
36
- # Self-host with Docker
37
- docker run -d -p 8080:8080 -v searxng:/etc/searxng searxng/searxng
38
- ```
39
- - **scrapling** — for `web_fetch` and `web_batch_fetch`
40
- ```bash
41
- # recommended: install scrapling via uv
42
- uv tool install "scrapling[all]"
43
- scrapling install
44
- ```
45
- - **agent-browser** — for `web_browse`
46
- ```bash
47
- npm i -g agent-browser && agent-browser install
48
- ```
49
- Verify installation:
50
- ```bash
51
- agent-browser doctor
52
- ```
60
+ ## Configuration
61
+
62
+ All tools are configured via **environment variables** at runtime no rebuild or restart required.
63
+
64
+ | Variable | Default | Used By | Description |
65
+ |----------|---------|---------|-------------|
66
+ | `SEARXNG_URL` | `http://localhost:8080` | `web_search` | Your SearXNG instance endpoint |
67
+
68
+ Set before starting pi:
69
+
70
+ ```bash
71
+ export SEARXNG_URL="https://searxng.example.com"
72
+ ```
53
73
 
54
74
  ## Project Structure
55
75
 
56
76
  ```
57
77
  pi-web-toolkit/
58
78
  ├── extensions/
79
+ │ ├── index.ts # Unified entry point — registers all 4 tools
59
80
  │ ├── utils/
60
- │ │ └── scrapling.ts # scrapling CLI wrapper
61
- ├── web_search.ts # web_search
62
- │ ├── web_fetch.ts # web_fetch
63
- │ ├── web_browse.ts # web_browse (agent-browser)
64
- └── web_batch_fetch.ts # web_batch_fetch
81
+ │ │ ├── scrapling.ts # Reusable scrapling CLI wrapper (shared by fetch + batch)
82
+ │ └── agent-browser.ts # agent-browser CLI wrapper (shared by web_browse)
83
+ │ ├── web_search.ts # SearXNG search tool
84
+ │ ├── web_fetch.ts # Single-page scrapling fetcher
85
+ ├── web_batch_fetch.ts # Parallel scrapling fetcher
86
+ │ └── web_browse.ts # Interactive browser automation (agent-browser)
65
87
  ├── docs/
66
- │ ├── tools.md
67
- │ └── guide.md
88
+ │ ├── tools.md # Full parameter specs
89
+ │ └── guide.md # Decision tree & tool comparison
90
+ ├── CHANGELOG.md
68
91
  ├── package.json
69
92
  ├── README.md
70
93
  └── LICENSE
71
94
  ```
72
95
 
96
+ **Design principles:**
97
+ - **Unified registration** — `index.ts` is the single source of truth for what pi loads.
98
+ - **Shared utilities** — `utils/scrapling.ts` and `utils/agent-browser.ts` encapsulate the CLI wrappers and fallback logic; tool files import only from `utils/`, never from each other.
99
+ - **Per-tool isolation** — each tool owns its own schema, execute logic, and TUI renderer; no cross-imports except via `utils/`.
100
+ - **Runtime config** — environment variables are read at execute time, not build time.
101
+
73
102
  ## Reference
74
103
 
75
104
  - [Tool Reference](docs/tools.md) — Full parameter specs and usage examples for each tool.
76
105
  - [Usage Guide](docs/guide.md) — Decision tree and tool comparison.
106
+ - [Changelog](CHANGELOG.md) — Release history and migration notes.
107
+
108
+ ## Contributing
109
+
110
+ ```bash
111
+ # Local development
112
+ pi install ./
113
+
114
+ # Type-check (no build step; pi loads TypeScript directly)
115
+ npx tsc --noEmit
116
+
117
+ # Verify external CLI dependencies
118
+ scrapling --help
119
+ agent-browser doctor
120
+ ```
121
+
122
+ Pull requests welcome. Please keep changes scoped to a single tool or concern and follow [Conventional Commits](https://www.conventionalcommits.org/).
77
123
 
78
124
  ## License
79
125
 
@@ -0,0 +1,51 @@
1
+ # Domain Docs
2
+
3
+ How the engineering skills should consume this repo's domain documentation when exploring the codebase.
4
+
5
+ ## Before exploring, read these
6
+
7
+ - **`CONTEXT.md`** at the repo root, or
8
+ - **`CONTEXT-MAP.md`** at the repo root if it exists — it points at one `CONTEXT.md` per context. Read each one relevant to the topic.
9
+ - **`docs/adr/`** — read ADRs that touch the area you're about to work in. In multi-context repos, also check `src/<context>/docs/adr/` for context-scoped decisions.
10
+
11
+ If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The producer skill (`/grill-with-docs`) creates them lazily when terms or decisions actually get resolved.
12
+
13
+ ## File structure
14
+
15
+ Single-context repo (most repos):
16
+
17
+ ```
18
+ /
19
+ ├── CONTEXT.md
20
+ ├── docs/adr/
21
+ │ ├── 0001-event-sourced-orders.md
22
+ │ └── 0002-postgres-for-write-model.md
23
+ └── src/
24
+ ```
25
+
26
+ Multi-context repo (presence of `CONTEXT-MAP.md` at the root):
27
+
28
+ ```
29
+ /
30
+ ├── CONTEXT-MAP.md
31
+ ├── docs/adr/ ← system-wide decisions
32
+ └── src/
33
+ ├── ordering/
34
+ │ ├── CONTEXT.md
35
+ │ └── docs/adr/ ← context-specific decisions
36
+ └── billing/
37
+ ├── CONTEXT.md
38
+ └── docs/adr/
39
+ ```
40
+
41
+ ## Use the glossary's vocabulary
42
+
43
+ When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
44
+
45
+ If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/grill-with-docs`).
46
+
47
+ ## Flag ADR conflicts
48
+
49
+ If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
50
+
51
+ > _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_
@@ -0,0 +1,22 @@
1
+ # Issue tracker: GitHub
2
+
3
+ Issues and PRDs for this repo live as GitHub issues. Use the `gh` CLI for all operations.
4
+
5
+ ## Conventions
6
+
7
+ - **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies.
8
+ - **Read an issue**: `gh issue view <number> --comments`, filtering comments by `jq` and also fetching labels.
9
+ - **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters.
10
+ - **Comment on an issue**: `gh issue comment <number> --body "..."`
11
+ - **Apply / remove labels**: `gh issue edit <number> --add-label "..."` / `--remove-label "..."`
12
+ - **Close**: `gh issue close <number> --comment "..."`
13
+
14
+ Infer the repo from `git remote -v` — `gh` does this automatically when run inside a clone.
15
+
16
+ ## When a skill says "publish to the issue tracker"
17
+
18
+ Create a GitHub issue.
19
+
20
+ ## When a skill says "fetch the relevant ticket"
21
+
22
+ Run `gh issue view <number> --comments`.
@@ -0,0 +1,15 @@
1
+ # Triage Labels
2
+
3
+ The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker.
4
+
5
+ | Label in mattpocock/skills | Label in our tracker | Meaning |
6
+ | -------------------------- | -------------------- | ---------------------------------------- |
7
+ | `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
8
+ | `needs-info` | `needs-info` | Waiting on reporter for more information |
9
+ | `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
10
+ | `ready-for-human` | `ready-for-human` | Requires human implementation |
11
+ | `wontfix` | `wontfix` | Will not be actioned |
12
+
13
+ When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table.
14
+
15
+ Edit the right-hand column to match whatever vocabulary you actually use.
@@ -0,0 +1,234 @@
1
+ /**
2
+ * agent-browser CLI wrapper
3
+ *
4
+ * Encapsulates all low-level interaction with the agent-browser command:
5
+ * command building, process spawning, JSON parsing, and session cleanup.
6
+ */
7
+
8
+ import { spawn } from "node:child_process";
9
+
10
+ export interface BrowseAction {
11
+ type: "click" | "fill" | "type" | "press" | "wait" | "wait_selector" | "scroll";
12
+ selector?: string;
13
+ value?: string;
14
+ key?: string;
15
+ ms?: number;
16
+ direction?: "down" | "up" | "bottom" | "top";
17
+ amount?: number;
18
+ state?: "attached" | "visible" | "hidden";
19
+ }
20
+
21
+ export interface AgentBrowserBatchItem {
22
+ success: boolean;
23
+ command: string[];
24
+ result?: any;
25
+ error?: string | null;
26
+ }
27
+
28
+ function requireString(action: BrowseAction, field: "selector" | "value" | "key"): string {
29
+ const value = action[field];
30
+ if (typeof value !== "string" || value.length === 0) {
31
+ throw new Error(`Action "${action.type}" requires non-empty ${field}`);
32
+ }
33
+ return value;
34
+ }
35
+
36
+ function requireInteger(action: BrowseAction, field: "ms" | "amount"): number {
37
+ const value = action[field];
38
+ if (!Number.isInteger(value) || value < 0) {
39
+ throw new Error(`Action "${action.type}" requires non-negative integer ${field}`);
40
+ }
41
+ return value;
42
+ }
43
+
44
+ function waitForSelectorScript(selector: string, state: "attached" | "visible" | "hidden"): string {
45
+ const selectorLiteral = JSON.stringify(selector);
46
+ const stateLiteral = JSON.stringify(state);
47
+ return `await new Promise((resolve, reject) => {
48
+ const selector = ${selectorLiteral};
49
+ const state = ${stateLiteral};
50
+ const deadline = Date.now() + 30000;
51
+ const isVisible = (el) => !!(el && (el.offsetWidth || el.offsetHeight || el.getClientRects().length));
52
+ const check = () => {
53
+ const el = document.querySelector(selector);
54
+ const ok = state === "attached" ? !!el : state === "hidden" ? !isVisible(el) : isVisible(el);
55
+ if (ok) return resolve(true);
56
+ if (Date.now() > deadline) return reject(new Error(\`Timed out waiting for ${state} selector: ${selector}\`));
57
+ setTimeout(check, 100);
58
+ };
59
+ check();
60
+ })`;
61
+ }
62
+
63
+ export function buildBatchCommands(
64
+ url: string,
65
+ actions: BrowseAction[],
66
+ selector?: string,
67
+ ): string[][] {
68
+ const commands: string[][] = [["open", url]];
69
+
70
+ for (const action of actions) {
71
+ switch (action.type) {
72
+ case "click":
73
+ commands.push(["click", requireString(action, "selector")]);
74
+ break;
75
+ case "fill":
76
+ commands.push(["fill", requireString(action, "selector"), requireString(action, "value")]);
77
+ break;
78
+ case "type":
79
+ commands.push(["type", requireString(action, "selector"), requireString(action, "value")]);
80
+ break;
81
+ case "press": {
82
+ if (action.selector) {
83
+ commands.push(["focus", action.selector]);
84
+ }
85
+ commands.push(["press", requireString(action, "key")]);
86
+ break;
87
+ }
88
+ case "wait":
89
+ commands.push(["wait", String(requireInteger(action, "ms"))]);
90
+ break;
91
+ case "wait_selector": {
92
+ const state = action.state ?? "visible";
93
+ const waitSelector = requireString(action, "selector");
94
+ if (state === "visible") {
95
+ commands.push(["wait", waitSelector]);
96
+ } else {
97
+ commands.push(["eval", waitForSelectorScript(waitSelector, state)]);
98
+ }
99
+ break;
100
+ }
101
+ case "scroll": {
102
+ const dir = action.direction ?? "down";
103
+ if (dir === "top") {
104
+ commands.push(["eval", "window.scrollTo(0, 0)"]);
105
+ } else if (dir === "bottom") {
106
+ commands.push(["eval", "window.scrollTo(0, document.body.scrollHeight)"]);
107
+ } else {
108
+ commands.push(["scroll", dir, String(action.amount ?? 500)]);
109
+ }
110
+ break;
111
+ }
112
+ default:
113
+ throw new Error(`Unsupported browser action: ${(action as BrowseAction).type}`);
114
+ }
115
+ }
116
+
117
+ // Extract content
118
+ if (selector) {
119
+ commands.push(["get", "text", selector, "--json"]);
120
+ } else {
121
+ commands.push(["snapshot", "-i", "--json"]);
122
+ }
123
+
124
+ // Metadata
125
+ commands.push(["get", "title", "--json"]);
126
+ commands.push(["get", "url", "--json"]);
127
+
128
+ return commands;
129
+ }
130
+
131
+ export function runAgentBrowserBatch(
132
+ commands: string[][],
133
+ options: { session: string; headless: boolean; signal?: AbortSignal; timeout?: number },
134
+ ): Promise<AgentBrowserBatchItem[]> {
135
+ const args = ["--session", options.session];
136
+ if (!options.headless) args.push("--headed");
137
+ args.push("batch", "--bail", "--json");
138
+
139
+ return new Promise((resolve, reject) => {
140
+ const proc = spawn("agent-browser", args, {
141
+ shell: false,
142
+ stdio: ["pipe", "pipe", "pipe"],
143
+ });
144
+
145
+ let stdout = "";
146
+ let stderr = "";
147
+ let timeoutId: NodeJS.Timeout | undefined;
148
+ let settled = false;
149
+
150
+ const cleanup = () => {
151
+ if (timeoutId) clearTimeout(timeoutId);
152
+ if (options.signal) options.signal.removeEventListener("abort", kill);
153
+ };
154
+
155
+ const settleReject = (err: Error) => {
156
+ if (settled) return;
157
+ settled = true;
158
+ cleanup();
159
+ reject(err);
160
+ };
161
+
162
+ const kill = () => proc.kill("SIGTERM");
163
+
164
+ proc.stdout.on("data", (data: Buffer) => {
165
+ stdout += data.toString();
166
+ });
167
+
168
+ proc.stderr.on("data", (data: Buffer) => {
169
+ stderr += data.toString();
170
+ });
171
+
172
+ if (options.timeout) {
173
+ timeoutId = setTimeout(() => {
174
+ proc.kill("SIGTERM");
175
+ settleReject(new Error(`agent-browser timed out after ${options.timeout}ms`));
176
+ }, options.timeout);
177
+ }
178
+
179
+ proc.on("close", (code) => {
180
+ if (settled) return;
181
+ settled = true;
182
+ cleanup();
183
+
184
+ if (code !== 0 && !stdout.trim()) {
185
+ reject(new Error(`agent-browser failed (exit ${code}):\n${stderr || "unknown error"}`));
186
+ return;
187
+ }
188
+
189
+ try {
190
+ const results = JSON.parse(stdout) as AgentBrowserBatchItem[];
191
+ resolve(results);
192
+ } catch (err: any) {
193
+ reject(new Error(
194
+ `Failed to parse agent-browser output: ${err.message}\nstdout: ${stdout}\nstderr: ${stderr}`
195
+ ));
196
+ }
197
+ });
198
+
199
+ proc.on("error", (err: any) => {
200
+ if (err.code === "ENOENT") {
201
+ settleReject(new Error(
202
+ "agent-browser is not installed.\n\nInstall it with:\n npm i -g agent-browser && agent-browser install\n\nThen run: agent-browser doctor"
203
+ ));
204
+ } else {
205
+ settleReject(err);
206
+ }
207
+ });
208
+
209
+ if (options.signal) {
210
+ if (options.signal.aborted) kill();
211
+ else options.signal.addEventListener("abort", kill, { once: true });
212
+ }
213
+
214
+ proc.stdin.write(JSON.stringify(commands));
215
+ proc.stdin.end();
216
+ });
217
+ }
218
+
219
+ export function closeAgentBrowserSession(session: string, signal?: AbortSignal): Promise<void> {
220
+ return new Promise((resolve) => {
221
+ const proc = spawn("agent-browser", ["--session", session, "close"], {
222
+ shell: false,
223
+ stdio: ["ignore", "ignore", "ignore"],
224
+ });
225
+ const done = () => resolve();
226
+ proc.on("close", done);
227
+ proc.on("error", done);
228
+ if (signal) {
229
+ const kill = () => proc.kill("SIGTERM");
230
+ if (signal.aborted) kill();
231
+ else signal.addEventListener("abort", kill, { once: true });
232
+ }
233
+ });
234
+ }
@@ -34,3 +34,43 @@ export function runScrapling(
34
34
  }
35
35
  });
36
36
  }
37
+
38
+ /**
39
+ * Run scrapling fetch with automatic fallback to HTTP GET on failure.
40
+ *
41
+ * @param url Target URL
42
+ * @param tmpFile Output markdown file path
43
+ * @param options { selector?: string; stealthy?: boolean; noGetFallback?: boolean }
44
+ * @param signal Optional AbortSignal
45
+ * @returns { ok: true } or { ok: false, stderr: string }
46
+ */
47
+ export async function runScraplingWithFallback(
48
+ url: string,
49
+ tmpFile: string,
50
+ options: { selector?: string; stealthy?: boolean; noGetFallback?: boolean },
51
+ signal?: AbortSignal,
52
+ ): Promise<{ ok: boolean; stderr?: string }> {
53
+ const cmd = options.stealthy ? "stealthy-fetch" : "fetch";
54
+ const args = ["extract", cmd, url, tmpFile, "--ai-targeted"];
55
+ if (options.selector) {
56
+ args.push("--css-selector", options.selector);
57
+ }
58
+
59
+ const result = await runScrapling(args, signal);
60
+ if (result.exitCode === 0) {
61
+ return { ok: true };
62
+ }
63
+
64
+ if (!options.noGetFallback) {
65
+ const fallback = await runScrapling(
66
+ ["extract", "get", url, tmpFile, "--ai-targeted"],
67
+ signal,
68
+ );
69
+ if (fallback.exitCode === 0) {
70
+ return { ok: true };
71
+ }
72
+ return { ok: false, stderr: result.stderr || fallback.stderr };
73
+ }
74
+
75
+ return { ok: false, stderr: result.stderr };
76
+ }
@@ -24,7 +24,7 @@ import { Type, type Static } from "typebox";
24
24
  import * as fs from "node:fs";
25
25
  import * as os from "node:os";
26
26
  import * as path from "node:path";
27
- import { runScrapling } from "./utils/scrapling";
27
+ import { runScraplingWithFallback } from "./utils/scrapling";
28
28
 
29
29
  interface FetchTask {
30
30
  url: string;
@@ -37,18 +37,15 @@ async function fetchOne(
37
37
  stealthy: boolean,
38
38
  signal?: AbortSignal,
39
39
  ): Promise<{ url: string; content: string; size: number; ok: boolean; error?: string }> {
40
- const cmd = stealthy ? "stealthy-fetch" : "fetch";
41
- const args = ["extract", cmd, task.url, task.tmpFile, "--ai-targeted"];
42
- if (selector) args.push("--css-selector", selector);
43
-
44
- const { stderr, exitCode } = await runScrapling(args, signal);
45
-
46
- if (exitCode !== 0) {
47
- // Fallback to GET
48
- const fallback = await runScrapling(["extract", "get", task.url, task.tmpFile, "--ai-targeted"], signal);
49
- if (fallback.exitCode !== 0) {
50
- return { url: task.url, content: "", size: 0, ok: false, error: stderr || fallback.stderr };
51
- }
40
+ const { ok: fetchOk, stderr } = await runScraplingWithFallback(
41
+ task.url,
42
+ task.tmpFile,
43
+ { selector, stealthy },
44
+ signal,
45
+ );
46
+
47
+ if (!fetchOk) {
48
+ return { url: task.url, content: "", size: 0, ok: false, error: stderr };
52
49
  }
53
50
 
54
51
  try {
@@ -22,236 +22,15 @@ import {
22
22
  import { StringEnum } from "@earendil-works/pi-ai";
23
23
  import { Text } from "@earendil-works/pi-tui";
24
24
  import { Type, type Static } from "typebox";
25
- import { spawn } from "node:child_process";
26
25
  import * as fs from "node:fs";
27
26
  import * as os from "node:os";
28
27
  import * as path from "node:path";
29
-
30
- interface BrowseAction {
31
- type: "click" | "fill" | "type" | "press" | "wait" | "wait_selector" | "scroll";
32
- selector?: string;
33
- value?: string;
34
- key?: string;
35
- ms?: number;
36
- direction?: "down" | "up" | "bottom" | "top";
37
- amount?: number;
38
- state?: "attached" | "visible" | "hidden";
39
- }
40
-
41
- interface AgentBrowserBatchItem {
42
- success: boolean;
43
- command: string[];
44
- result?: any;
45
- error?: string | null;
46
- }
47
-
48
- function requireString(action: BrowseAction, field: "selector" | "value" | "key"): string {
49
- const value = action[field];
50
- if (typeof value !== "string" || value.length === 0) {
51
- throw new Error(`Action "${action.type}" requires non-empty ${field}`);
52
- }
53
- return value;
54
- }
55
-
56
- function requireInteger(action: BrowseAction, field: "ms" | "amount"): number {
57
- const value = action[field];
58
- if (!Number.isInteger(value) || value < 0) {
59
- throw new Error(`Action "${action.type}" requires non-negative integer ${field}`);
60
- }
61
- return value;
62
- }
63
-
64
- function waitForSelectorScript(selector: string, state: "attached" | "visible" | "hidden"): string {
65
- const selectorLiteral = JSON.stringify(selector);
66
- const stateLiteral = JSON.stringify(state);
67
- return `await new Promise((resolve, reject) => {
68
- const selector = ${selectorLiteral};
69
- const state = ${stateLiteral};
70
- const deadline = Date.now() + 30000;
71
- const isVisible = (el) => !!(el && (el.offsetWidth || el.offsetHeight || el.getClientRects().length));
72
- const check = () => {
73
- const el = document.querySelector(selector);
74
- const ok = state === "attached" ? !!el : state === "hidden" ? !isVisible(el) : isVisible(el);
75
- if (ok) return resolve(true);
76
- if (Date.now() > deadline) return reject(new Error(\`Timed out waiting for ${state} selector: ${selector}\`));
77
- setTimeout(check, 100);
78
- };
79
- check();
80
- })`;
81
- }
82
-
83
- function buildBatchCommands(
84
- url: string,
85
- actions: BrowseAction[],
86
- selector?: string,
87
- ): string[][] {
88
- const commands: string[][] = [["open", url]];
89
-
90
- for (const action of actions) {
91
- switch (action.type) {
92
- case "click":
93
- commands.push(["click", requireString(action, "selector")]);
94
- break;
95
- case "fill":
96
- commands.push(["fill", requireString(action, "selector"), requireString(action, "value")]);
97
- break;
98
- case "type":
99
- commands.push(["type", requireString(action, "selector"), requireString(action, "value")]);
100
- break;
101
- case "press": {
102
- if (action.selector) {
103
- commands.push(["focus", action.selector]);
104
- }
105
- commands.push(["press", requireString(action, "key")]);
106
- break;
107
- }
108
- case "wait":
109
- commands.push(["wait", String(requireInteger(action, "ms"))]);
110
- break;
111
- case "wait_selector": {
112
- const state = action.state ?? "visible";
113
- const waitSelector = requireString(action, "selector");
114
- if (state === "visible") {
115
- commands.push(["wait", waitSelector]);
116
- } else {
117
- commands.push(["eval", waitForSelectorScript(waitSelector, state)]);
118
- }
119
- break;
120
- }
121
- case "scroll": {
122
- const dir = action.direction ?? "down";
123
- if (dir === "top") {
124
- commands.push(["eval", "window.scrollTo(0, 0)"]);
125
- } else if (dir === "bottom") {
126
- commands.push(["eval", "window.scrollTo(0, document.body.scrollHeight)"]);
127
- } else {
128
- commands.push(["scroll", dir, String(action.amount ?? 500)]);
129
- }
130
- break;
131
- }
132
- default:
133
- throw new Error(`Unsupported browser action: ${(action as BrowseAction).type}`);
134
- }
135
- }
136
-
137
- // Extract content
138
- if (selector) {
139
- commands.push(["get", "text", selector, "--json"]);
140
- } else {
141
- commands.push(["snapshot", "-i", "--json"]);
142
- }
143
-
144
- // Metadata
145
- commands.push(["get", "title", "--json"]);
146
- commands.push(["get", "url", "--json"]);
147
-
148
- return commands;
149
- }
150
-
151
- function runAgentBrowserBatch(
152
- commands: string[][],
153
- options: { session: string; headless: boolean; signal?: AbortSignal; timeout?: number },
154
- ): Promise<AgentBrowserBatchItem[]> {
155
- const args = ["--session", options.session];
156
- if (!options.headless) args.push("--headed");
157
- args.push("batch", "--bail", "--json");
158
-
159
- return new Promise((resolve, reject) => {
160
- const proc = spawn("agent-browser", args, {
161
- shell: false,
162
- stdio: ["pipe", "pipe", "pipe"],
163
- });
164
-
165
- let stdout = "";
166
- let stderr = "";
167
- let timeoutId: NodeJS.Timeout | undefined;
168
- let settled = false;
169
-
170
- const cleanup = () => {
171
- if (timeoutId) clearTimeout(timeoutId);
172
- if (options.signal) options.signal.removeEventListener("abort", kill);
173
- };
174
-
175
- const settleReject = (err: Error) => {
176
- if (settled) return;
177
- settled = true;
178
- cleanup();
179
- reject(err);
180
- };
181
-
182
- const kill = () => proc.kill("SIGTERM");
183
-
184
- proc.stdout.on("data", (data: Buffer) => {
185
- stdout += data.toString();
186
- });
187
-
188
- proc.stderr.on("data", (data: Buffer) => {
189
- stderr += data.toString();
190
- });
191
-
192
- if (options.timeout) {
193
- timeoutId = setTimeout(() => {
194
- proc.kill("SIGTERM");
195
- settleReject(new Error(`agent-browser timed out after ${options.timeout}ms`));
196
- }, options.timeout);
197
- }
198
-
199
- proc.on("close", (code) => {
200
- if (settled) return;
201
- settled = true;
202
- cleanup();
203
-
204
- if (code !== 0 && !stdout.trim()) {
205
- reject(new Error(`agent-browser failed (exit ${code}):\n${stderr || "unknown error"}`));
206
- return;
207
- }
208
-
209
- try {
210
- const results = JSON.parse(stdout) as AgentBrowserBatchItem[];
211
- resolve(results);
212
- } catch (err: any) {
213
- reject(new Error(
214
- `Failed to parse agent-browser output: ${err.message}\nstdout: ${stdout}\nstderr: ${stderr}`
215
- ));
216
- }
217
- });
218
-
219
- proc.on("error", (err: any) => {
220
- if (err.code === "ENOENT") {
221
- settleReject(new Error(
222
- "agent-browser is not installed.\n\nInstall it with:\n npm i -g agent-browser && agent-browser install\n\nThen run: agent-browser doctor"
223
- ));
224
- } else {
225
- settleReject(err);
226
- }
227
- });
228
-
229
- if (options.signal) {
230
- if (options.signal.aborted) kill();
231
- else options.signal.addEventListener("abort", kill, { once: true });
232
- }
233
-
234
- proc.stdin.write(JSON.stringify(commands));
235
- proc.stdin.end();
236
- });
237
- }
238
-
239
- function closeAgentBrowserSession(session: string, signal?: AbortSignal): Promise<void> {
240
- return new Promise((resolve) => {
241
- const proc = spawn("agent-browser", ["--session", session, "close"], {
242
- shell: false,
243
- stdio: ["ignore", "ignore", "ignore"],
244
- });
245
- const done = () => resolve();
246
- proc.on("close", done);
247
- proc.on("error", done);
248
- if (signal) {
249
- const kill = () => proc.kill("SIGTERM");
250
- if (signal.aborted) kill();
251
- else signal.addEventListener("abort", kill, { once: true });
252
- }
253
- });
254
- }
28
+ import {
29
+ type BrowseAction,
30
+ buildBatchCommands,
31
+ runAgentBrowserBatch,
32
+ closeAgentBrowserSession,
33
+ } from "./utils/agent-browser";
255
34
 
256
35
  export const WebBrowseActionSchema = Type.Object({
257
36
  type: StringEnum(["click", "fill", "type", "press", "wait", "wait_selector", "scroll"] as const),
@@ -23,7 +23,7 @@ import { Type, type Static } from "typebox";
23
23
  import * as fs from "node:fs";
24
24
  import * as os from "node:os";
25
25
  import * as path from "node:path";
26
- import { runScrapling } from "./utils/scrapling";
26
+ import { runScraplingWithFallback } from "./utils/scrapling";
27
27
 
28
28
  export const WebFetchParamsSchema = Type.Object({
29
29
  url: Type.String({ description: "Full URL to fetch (e.g. https://example.com/article)" }),
@@ -57,24 +57,15 @@ const webFetchTool = defineTool({
57
57
  let tmpFull: string | undefined;
58
58
 
59
59
  try {
60
- const cmd = params.stealthy ? "stealthy-fetch" : "fetch";
61
- const args = ["extract", cmd, params.url, tmpFile, "--ai-targeted"];
62
- if (params.selector) {
63
- args.push("--css-selector", params.selector);
64
- }
65
-
66
- const { stdout, stderr, exitCode } = await runScrapling(args, signal);
60
+ const { ok, stderr } = await runScraplingWithFallback(
61
+ params.url,
62
+ tmpFile,
63
+ { selector: params.selector, stealthy: params.stealthy, noGetFallback: params.stealthy },
64
+ signal,
65
+ );
67
66
 
68
- if (exitCode !== 0) {
69
- // Try fallback to simple HTTP GET if fetch/stealthy-fetch failed
70
- if (!params.stealthy) {
71
- const fallback = await runScrapling(["extract", "get", params.url, tmpFile, "--ai-targeted"], signal);
72
- if (fallback.exitCode !== 0) {
73
- throw new Error(`Failed to fetch ${params.url}\n\nscrapling error:\n${stderr || fallback.stderr}`);
74
- }
75
- } else {
76
- throw new Error(`Failed to fetch ${params.url}\n\nscrapling error:\n${stderr}`);
77
- }
67
+ if (!ok) {
68
+ throw new Error(`Failed to fetch ${params.url}\n\nscrapling error:\n${stderr}`);
78
69
  }
79
70
 
80
71
  const content = await fs.promises.readFile(tmpFile, "utf-8");
@@ -24,7 +24,7 @@ import { mkdtemp, writeFile } from "node:fs/promises";
24
24
  import * as os from "node:os";
25
25
  import * as path from "node:path";
26
26
 
27
- const SEARXNG_URL = (process.env.SEARXNG_URL || "http://localhost:8080").replace(/\/$/, "");
27
+
28
28
 
29
29
  interface SearxResult {
30
30
  title: string;
@@ -66,6 +66,7 @@ const webSearchTool = defineTool({
66
66
  parameters: WebSearchParamsSchema,
67
67
 
68
68
  async execute(_toolCallId, params, signal) {
69
+ const searxngUrl = (process.env.SEARXNG_URL || "http://localhost:8080").replace(/\/$/, "");
69
70
  const maxResults = Math.floor(Math.min(50, Math.max(1, params.results ?? 10)));
70
71
  const searchParams = new URLSearchParams({
71
72
  q: params.query,
@@ -73,7 +74,7 @@ const webSearchTool = defineTool({
73
74
  language: params.language ?? "auto",
74
75
  });
75
76
 
76
- const url = `${SEARXNG_URL}/search?${searchParams.toString()}`;
77
+ const url = `${searxngUrl}/search?${searchParams.toString()}`;
77
78
 
78
79
  let fullOutputPath: string | undefined;
79
80
 
@@ -140,7 +141,7 @@ const webSearchTool = defineTool({
140
141
  details: { query: data.query, totalResults: data.results.length, results: data.results.slice(0, maxResults), fullOutputPath },
141
142
  };
142
143
  } catch (err: any) {
143
- throw new Error(`Failed to query SearXNG at ${SEARXNG_URL}: ${err.message ?? err}`);
144
+ throw new Error(`Failed to query SearXNG at ${searxngUrl}: ${err.message ?? err}`);
144
145
  }
145
146
  },
146
147
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-web-toolkit",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Web research toolkit for the pi coding agent. Search via SearXNG, fetch static pages with scrapling, browse interactively via agent-browser, and batch-read sources in parallel.",
5
5
  "author": "Wade Huang <fastwade11@gmail.com>",
6
6
  "license": "MIT",