pi-web-toolkit 0.1.2 → 0.2.1

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
@@ -13,11 +13,33 @@ Web research toolkit for [pi](https://pi.dev) agents. Search via SearXNG, fetch
13
13
 
14
14
  | Tool | Backend | Purpose | Current Limit |
15
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) |
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 | 20 results (max 60, auto-pages up to 3 pages) |
17
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) |
18
+ | **`web_batch_fetch`** | [scrapling](https://github.com/D4Vinci/Scrapling) | Fetch 2–15 pages in parallel for research synthesis | 3 concurrent (max 5) |
19
19
  | **`web_browse`** | [agent-browser](https://github.com/vercel-labs/agent-browser) | Interact with a page (click, scroll, fill) then extract content | 25 actions |
20
20
 
21
+ ## Tools Preview
22
+
23
+ A quick look at how pi renders toolkit calls while an agent searches, fetches, batches, and browses the web.
24
+
25
+ <table>
26
+ <tr>
27
+ <td width="50%"><strong>Multi-tool research flow</strong><br><img src="docs/assets/screenshots/tools-workflow-preview.png" alt="pi-web-toolkit multi-tool research preview"></td>
28
+ <td width="50%"><strong><code>web_search</code> expanded results</strong><br><img src="docs/assets/screenshots/web-search-results-expanded.png" alt="web_search expanded results"></td>
29
+ </tr>
30
+ <tr>
31
+ <td width="50%"><strong><code>web_batch_fetch</code> progress</strong><br><img src="docs/assets/screenshots/web-batch-fetch-progress.png" alt="web_batch_fetch progress"></td>
32
+ <td width="50%"><strong><code>web_batch_fetch</code> results</strong><br><img src="docs/assets/screenshots/web-batch-fetch-results.png" alt="web_batch_fetch results"></td>
33
+ </tr>
34
+ <tr>
35
+ <td width="50%"><strong><code>web_fetch</code> result preview</strong><br><img src="docs/assets/screenshots/web-fetch-summary.png" alt="web_fetch result preview"></td>
36
+ <td width="50%"><strong><code>web_browse</code> headless browser flow</strong><br><img src="docs/assets/screenshots/web-browse-headless.png" alt="web_browse headless browser flow"></td>
37
+ </tr>
38
+ <tr>
39
+ <td colspan="2"><strong>End-to-end research summary</strong><br><img src="docs/assets/screenshots/web-research-workflow.png" alt="end-to-end web research workflow"></td>
40
+ </tr>
41
+ </table>
42
+
21
43
  ## Quick Start
22
44
 
23
45
  ### 1. Install external dependencies
@@ -78,12 +100,19 @@ pi-web-toolkit/
78
100
  ├── extensions/
79
101
  │ ├── index.ts # Unified entry point — registers all 4 tools
80
102
  │ ├── utils/
103
+ │ │ ├── cli-runner.ts # Unified CLI process spawning with timeout/AbortSignal
104
+ │ │ ├── content-preview.ts # Intelligent content extraction from scraped pages
105
+ │ │ ├── output-sink.ts # Truncation + temp-file fallback
106
+ │ │ ├── render-helpers.ts # URL abbreviations, text normalization, error formatting for TUI
81
107
  │ │ ├── scrapling.ts # Reusable scrapling CLI wrapper (shared by fetch + batch)
108
+ │ │ ├── tool-factory.ts # Common tool registration patterns
82
109
  │ │ └── agent-browser.ts # agent-browser CLI wrapper (shared by web_browse)
83
110
  │ ├── web_search.ts # SearXNG search tool
84
111
  │ ├── web_fetch.ts # Single-page scrapling fetcher
85
112
  │ ├── web_batch_fetch.ts # Parallel scrapling fetcher
86
113
  │ └── web_browse.ts # Interactive browser automation (agent-browser)
114
+ ├── test/
115
+ │ └── content-preview/ # Automated test suite with fixtures & snapshots
87
116
  ├── docs/
88
117
  │ ├── tools.md # Full parameter specs
89
118
  │ └── guide.md # Decision tree & tool comparison
@@ -95,7 +124,7 @@ pi-web-toolkit/
95
124
 
96
125
  **Design principles:**
97
126
  - **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.
127
+ - **Shared utilities** — `utils/` modules encapsulate CLI spawning, content extraction, output truncation, TUI formatting, and common registration patterns; tool files import only from `utils/`, never from each other.
99
128
  - **Per-tool isolation** — each tool owns its own schema, execute logic, and TUI renderer; no cross-imports except via `utils/`.
100
129
  - **Runtime config** — environment variables are read at execute time, not build time.
101
130
 
@@ -112,7 +141,10 @@ pi-web-toolkit/
112
141
  pi install ./
113
142
 
114
143
  # Type-check (no build step; pi loads TypeScript directly)
115
- npx tsc --noEmit
144
+ npm run typecheck
145
+
146
+ # Run tests
147
+ npm run test
116
148
 
117
149
  # Verify external CLI dependencies
118
150
  scrapling --help
package/docs/guide.md CHANGED
@@ -32,7 +32,7 @@ User asks about something external / current
32
32
 
33
33
  | | `web_fetch` | `web_browse` | `web_batch_fetch` |
34
34
  |--|-------------|--------------|-------------------|
35
- | **Pages** | 1 | 1 | 2–10 |
35
+ | **Pages** | 1 | 1 | 2–15 |
36
36
  | **Browser** | Yes (scrapling) | Yes (agent-browser) | Yes (scrapling) |
37
37
  | **Interaction** | ❌ No | ✅ Click, fill, scroll, wait | ❌ No |
38
38
  | **Selector** | ✅ Per-URL | ✅ Final state | ✅ Applied to all |
package/docs/tools.md CHANGED
@@ -2,18 +2,22 @@
2
2
 
3
3
  ## `web_search`
4
4
 
5
- Search the web via SearXNG. Returns ranked results with title, URL, and snippet.
5
+ Search the web via SearXNG. Returns ranked results with title, URL, and snippet. Automatically aggregates up to 3 pages of SearXNG results when more than ~20 are needed.
6
6
 
7
7
  ```typescript
8
8
  {
9
9
  query: string, // Search query
10
10
  language?: string, // Language code (en, de, fr...). Default: "auto"
11
- results?: number, // Max results (1–50). Default: 10
11
+ results?: number, // Max results (1–60). Default: 20. Automatically pages through SearXNG (up to 3 pages) if needed.
12
12
  }
13
13
  ```
14
14
 
15
15
  **When to use:** The user asks about current events, facts, or anything requiring up-to-date information. This is always the **first step** of web research.
16
16
 
17
+ **Empty results behavior:** When no results are found, `web_search` returns a list of **suggestions** — alternative queries that SearXNG believes may yield better results. The agent can use these suggestions to automatically refine and retry the search.
18
+
19
+ **Pagination:** `web_search` automatically fetches up to 3 pages from SearXNG and deduplicates by URL. You do not need to call it multiple times for deeper results.
20
+
17
21
  ---
18
22
 
19
23
  ## `web_fetch`
@@ -5,7 +5,7 @@
5
5
  * command building, process spawning, JSON parsing, and session cleanup.
6
6
  */
7
7
 
8
- import { spawn } from "node:child_process";
8
+ import { runCLI } from "./cli-runner";
9
9
 
10
10
  export interface BrowseAction {
11
11
  type: "click" | "fill" | "type" | "press" | "wait" | "wait_selector" | "scroll";
@@ -25,8 +25,50 @@ export interface AgentBrowserBatchItem {
25
25
  error?: string | null;
26
26
  }
27
27
 
28
+ function isRecord(value: unknown): value is Record<string, unknown> {
29
+ return typeof value === "object" && value !== null && !Array.isArray(value);
30
+ }
31
+
32
+ function isBatchItem(value: unknown): value is AgentBrowserBatchItem {
33
+ return isRecord(value)
34
+ && typeof value.success === "boolean"
35
+ && Array.isArray(value.command)
36
+ && value.command.every((part) => typeof part === "string");
37
+ }
38
+
39
+ function describeBatchOutput(value: unknown): string {
40
+ if (Array.isArray(value)) return `array with ${value.length} item(s)`;
41
+ if (isRecord(value)) return `object with keys: ${Object.keys(value).join(", ") || "(none)"}`;
42
+ return typeof value;
43
+ }
44
+
45
+ export function parseAgentBrowserBatchOutput(stdout: string): AgentBrowserBatchItem[] {
46
+ const parsed = JSON.parse(stdout) as unknown;
47
+
48
+ if (Array.isArray(parsed)) {
49
+ if (parsed.every(isBatchItem)) return parsed;
50
+ throw new Error(`Expected every batch result item to contain { success, command }; got ${describeBatchOutput(parsed)}`);
51
+ }
52
+
53
+ if (isBatchItem(parsed)) {
54
+ return [parsed];
55
+ }
56
+
57
+ if (isRecord(parsed)) {
58
+ for (const key of ["results", "items", "data", "commands"]) {
59
+ const candidate = parsed[key];
60
+ if (Array.isArray(candidate)) {
61
+ if (candidate.every(isBatchItem)) return candidate;
62
+ throw new Error(`Expected ${key} to contain batch result items; got ${describeBatchOutput(candidate)}`);
63
+ }
64
+ }
65
+ }
66
+
67
+ throw new Error(`Expected JSON array of batch results; got ${describeBatchOutput(parsed)}`);
68
+ }
69
+
28
70
  function requireString(action: BrowseAction, field: "selector" | "value" | "key"): string {
29
- const value = action[field];
71
+ const value = action[field] as string | undefined;
30
72
  if (typeof value !== "string" || value.length === 0) {
31
73
  throw new Error(`Action "${action.type}" requires non-empty ${field}`);
32
74
  }
@@ -34,11 +76,11 @@ function requireString(action: BrowseAction, field: "selector" | "value" | "key"
34
76
  }
35
77
 
36
78
  function requireInteger(action: BrowseAction, field: "ms" | "amount"): number {
37
- const value = action[field];
38
- if (!Number.isInteger(value) || value < 0) {
79
+ const value = action[field] as number | undefined;
80
+ if (!Number.isInteger(value) || (value as number) < 0) {
39
81
  throw new Error(`Action "${action.type}" requires non-negative integer ${field}`);
40
82
  }
41
- return value;
83
+ return value as number;
42
84
  }
43
85
 
44
86
  function waitForSelectorScript(selector: string, state: "attached" | "visible" | "hidden"): string {
@@ -128,7 +170,7 @@ export function buildBatchCommands(
128
170
  return commands;
129
171
  }
130
172
 
131
- export function runAgentBrowserBatch(
173
+ export async function runAgentBrowserBatch(
132
174
  commands: string[][],
133
175
  options: { session: string; headless: boolean; signal?: AbortSignal; timeout?: number },
134
176
  ): Promise<AgentBrowserBatchItem[]> {
@@ -136,99 +178,44 @@ export function runAgentBrowserBatch(
136
178
  if (!options.headless) args.push("--headed");
137
179
  args.push("batch", "--bail", "--json");
138
180
 
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();
181
+ try {
182
+ const result = await runCLI({
183
+ command: "agent-browser",
184
+ args,
185
+ stdin: JSON.stringify(commands),
186
+ timeout: options.timeout,
187
+ signal: options.signal,
166
188
  });
167
189
 
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);
190
+ if (result.exitCode !== 0 && !result.stdout.trim()) {
191
+ throw new Error(`agent-browser failed (exit ${result.exitCode}):\n${result.stderr || "unknown error"}`);
177
192
  }
178
193
 
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 });
194
+ try {
195
+ return parseAgentBrowserBatchOutput(result.stdout);
196
+ } catch (err: any) {
197
+ throw new Error(
198
+ `Failed to parse agent-browser output: ${err.message}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`
199
+ );
212
200
  }
213
-
214
- proc.stdin.write(JSON.stringify(commands));
215
- proc.stdin.end();
216
- });
201
+ } catch (err: any) {
202
+ if (err.message === "agent-browser is not installed") {
203
+ throw new Error(
204
+ "agent-browser is not installed.\n\nInstall it with:\n npm i -g agent-browser && agent-browser install\n\nThen run: agent-browser doctor"
205
+ );
206
+ }
207
+ throw err;
208
+ }
217
209
  }
218
210
 
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"],
211
+ export async function closeAgentBrowserSession(session: string, signal?: AbortSignal): Promise<void> {
212
+ try {
213
+ await runCLI({
214
+ command: "agent-browser",
215
+ args: ["--session", session, "close"],
216
+ signal,
224
217
  });
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
- });
218
+ } catch {
219
+ // Best-effort cleanup — ignore errors
220
+ }
234
221
  }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * CLI runner — abstracted process spawning
3
+ *
4
+ * Provides a single interface for running external CLI commands
5
+ * with consistent signal handling, timeout support, and stdout/stderr
6
+ * collection. Enables testability by allowing the runner to be swapped.
7
+ */
8
+
9
+ import { spawn, type ChildProcess } from "node:child_process";
10
+
11
+ export interface CLIRunOptions {
12
+ command: string;
13
+ args: string[];
14
+ /** Data to write to stdin. If omitted, stdin is ignored. */
15
+ stdin?: string;
16
+ /** Timeout in milliseconds. If exceeded, the process is killed. */
17
+ timeout?: number;
18
+ /** AbortSignal for cancellation. */
19
+ signal?: AbortSignal;
20
+ }
21
+
22
+ export interface CLIRunResult {
23
+ stdout: string;
24
+ stderr: string;
25
+ exitCode: number;
26
+ }
27
+
28
+ /**
29
+ * Run an external CLI command and capture its output.
30
+ *
31
+ * Handles:
32
+ * - stdout/stderr collection
33
+ * - optional stdin feeding
34
+ * - optional timeout (SIGTERM)
35
+ * - AbortSignal cancellation (SIGTERM)
36
+ * - process spawn errors (e.g. ENOENT)
37
+ */
38
+ export function runCLI(options: CLIRunOptions): Promise<CLIRunResult> {
39
+ return new Promise((resolve, reject) => {
40
+ const stdio = options.stdin
41
+ ? ["pipe", "pipe", "pipe"]
42
+ : ["ignore", "pipe", "pipe"];
43
+
44
+ const proc = spawn(options.command, options.args, {
45
+ shell: false,
46
+ stdio: stdio as any,
47
+ }) as ChildProcess;
48
+
49
+ let stdout = "";
50
+ let stderr = "";
51
+ let timeoutId: NodeJS.Timeout | undefined;
52
+ let settled = false;
53
+
54
+ const cleanup = () => {
55
+ if (timeoutId) clearTimeout(timeoutId);
56
+ if (options.signal) options.signal.removeEventListener("abort", kill);
57
+ };
58
+
59
+ const settleReject = (err: Error) => {
60
+ if (settled) return;
61
+ settled = true;
62
+ cleanup();
63
+ reject(err);
64
+ };
65
+
66
+ const kill = () => proc.kill("SIGTERM");
67
+
68
+ proc.stdout?.on("data", (data: Buffer) => {
69
+ stdout += data.toString();
70
+ });
71
+
72
+ proc.stderr?.on("data", (data: Buffer) => {
73
+ stderr += data.toString();
74
+ });
75
+
76
+ if (options.timeout) {
77
+ timeoutId = setTimeout(() => {
78
+ proc.kill("SIGTERM");
79
+ settleReject(new Error(`${options.command} timed out after ${options.timeout}ms`));
80
+ }, options.timeout);
81
+ }
82
+
83
+ proc.on("close", (code) => {
84
+ if (settled) return;
85
+ settled = true;
86
+ cleanup();
87
+ resolve({ stdout, stderr, exitCode: code ?? 1 });
88
+ });
89
+
90
+ proc.on("error", (err: any) => {
91
+ if (err.code === "ENOENT") {
92
+ settleReject(new Error(`${options.command} is not installed`));
93
+ } else {
94
+ settleReject(err);
95
+ }
96
+ });
97
+
98
+ if (options.signal) {
99
+ if (options.signal.aborted) kill();
100
+ else options.signal.addEventListener("abort", kill, { once: true });
101
+ }
102
+
103
+ if (options.stdin && proc.stdin) {
104
+ proc.stdin.write(options.stdin);
105
+ proc.stdin.end();
106
+ }
107
+ });
108
+ }