@underactive/pi-topping-web-tools 0.2.8 → 0.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/CHANGELOG.md +30 -0
- package/README.md +55 -3
- package/package.json +6 -5
- package/src/abort-utils.ts +3 -17
- package/src/env.ts +6 -10
- package/src/fetch-markdown.ts +3 -1
- package/src/pdf-extract.ts +3 -1
- package/src/permission-store.ts +3 -7
- package/src/tool-exposure.ts +131 -0
- package/src/web-browser/feeds.ts +2 -8
- package/src/web-browser/index.ts +9 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.3.0] - 2026-10-21
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Tested against pi 1.4.0. No break: the extension-facing declaration surface (`ExtensionAPI`, `ToolDefinition`, `ExtensionContext`, `ExtensionUIContext`) is unchanged, pi-ai and pi-tui changes are additive or internal, and the provider-header, summary and classifier changes do not reach these tools. Concurrent extension dialogs no longer replace each other on 1.4.0, so parallel host-permission prompts from several tool calls all settle.
|
|
10
|
+
- Bumped the pinned `@earendil-works/pi-ai`, `@earendil-works/pi-coding-agent`, and `@earendil-works/pi-tui` devDependencies from 1.3.0 to 1.4.0.
|
|
11
|
+
- Depend on `@underactive/pi-topping-primitives` `^0.10.0` (was `^0.9.0`). Nothing it imports changed in between: 0.10.0 removed only the `pi-topping-compact` entry names from `./contracts`, which it never referenced. Every topping now asks for the same range, so an install of several shares one copy of the library. No visible change.
|
|
12
|
+
- Tool-exposure settings are read through the shared `@underactive/pi-topping-primitives` 0.2.0 settings store, and the permission grant store writes through its `atomicWriteFile` (unique temp names, cleaned up on failure). An empty `PI_WEB_TOOLS_SETTINGS_FILE` is now treated as unset instead of as a path. The grant store keeps its own lock file and format.
|
|
13
|
+
- `web_browser`: source the session-feed entry type and payload from `@underactive/pi-topping-primitives` 0.1.0; the feed's wire format is unchanged.
|
|
14
|
+
- Depend on `@underactive/pi-topping-primitives` `^0.9.0`. The environment-flag helpers (`isTruthyEnvFlag`, `suppressNotifications`) and `combineSignals` now come from its `./env` and `./abort` subpaths. Behaviour is unchanged except that a timeout above 2^31 - 1 ms is clamped to that instead of aborting the request after about a millisecond, and a timeout that `AbortSignal.timeout` rejects no longer leaves a stale entry in the active-fetch set.
|
|
15
|
+
- `web_browser`: keep the event loop open while `close()` waits on a stalled cleanup, preventing the process from hanging on shutdown.
|
|
16
|
+
|
|
17
|
+
## [0.2.9] - 2026-10-16
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- Optional `toolExposure` setting (`direct` default, or `codemode` / `deferred`) in `~/.pi/agent/pi-topping-web-tools/settings.json`. All three tools share the `pi-topping-web-tools` namespace. At each session start, a non-`direct` mode activates the loader it needs with `pi.setActiveTools()`: `codemode` for `codemode`, and `tool_search` (or `codemode` when `tool_search` is not registered) for `deferred`. `defaultTools` needs no `+codemode` / `+tool_search` entry. When `--tools` or `--exclude-tools` leaves the loader out, the extension notifies once instead.
|
|
22
|
+
- README settings profiles for default direct tools, codemode-minimal (`inlineBudget: 0`), and deferred + `tool_search`. Clarified that a large `inlineBudget` moves declarations into the `codemode` description instead of shrinking them.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- Tested against pi 1.2.0. No break: the 1.2.0 breaking changes (`before_agent_start` for `triggerTurn` runs, busy session during run start, theme schema validation, `pi mcp list --json` shape) touch APIs and resources this package does not use.
|
|
27
|
+
- Bumped the pinned `@earendil-works/pi-ai`, `@earendil-works/pi-coding-agent`, and `@earendil-works/pi-tui` devDependencies from 1.1.0 to 1.2.0.
|
|
28
|
+
- Tested against pi 1.3.0. No break: the extension-facing declaration surface (`ExtensionAPI`, `ToolDefinition`, `ExtensionContext`, `ExtensionUIContext`) is unchanged, pi-ai only adds optional `ToolCall.argumentsError`, and pi-tui is unchanged. Provider, `/share`, and model-switching changes do not reach these tools.
|
|
29
|
+
- Bumped the pinned `@earendil-works/pi-ai`, `@earendil-works/pi-coding-agent`, and `@earendil-works/pi-tui` devDependencies from 1.2.0 to 1.3.0.
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- `web_browser`: the `mode`, `state`, `cookies[].sameSite`, and `dialogAction` parameter schemas now pass `as const` arrays to `StringEnum`, so their inferred types are the literal unions instead of `string`. This fixes four `tsc` errors; runtime validation is unchanged.
|
|
34
|
+
|
|
5
35
|
## [0.2.8] - 2026-10-12
|
|
6
36
|
|
|
7
37
|
### Added
|
package/README.md
CHANGED
|
@@ -54,16 +54,68 @@ Start with `fetch_markdown` for efficient static content; use `web_browser` when
|
|
|
54
54
|
| `pdf_extract` | Extract the text layer from a remote or local PDF, with page markers |
|
|
55
55
|
| `web_browser` | Headless Chromium automation — navigate, click, type, screenshot, evaluate, logs, cookies |
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
By default all three tools use `direct` exposure. Pi activates those on registration (`defaultActive` defaults to true), so a default install needs no extra `defaultTools` entry.
|
|
58
|
+
|
|
59
|
+
### Tool exposure (optional)
|
|
60
|
+
|
|
61
|
+
A single setting controls how the model reaches all three tools. It does not change host allowlists, permission prompts, or `--exclude-tools` / `defaultTools` filtering. Set it in `~/.pi/agent/pi-topping-web-tools/settings.json` (no TUI control):
|
|
62
|
+
|
|
63
|
+
```json
|
|
64
|
+
{
|
|
65
|
+
"toolExposure": "direct"
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
| `toolExposure` | Tool `exposure` | Loader the extension activates |
|
|
70
|
+
|----------------|-----------------|--------------------------------|
|
|
71
|
+
| `direct` (default) | `direct`, active on register | None |
|
|
72
|
+
| `codemode` | `codemode` | `codemode` |
|
|
73
|
+
| `deferred` | `deferred` | `tool_search`, or `codemode` when `tool_search` is not available |
|
|
74
|
+
|
|
75
|
+
All three tools share the `pi-topping-web-tools` namespace so `searchTools()` and `describeNamespace("pi-topping-web-tools")` can find them when they are not listed inline.
|
|
76
|
+
|
|
77
|
+
Needs pi ≥0.99.0 for `codemode` / `tool_search` / `exposure`. Screenshot inspection from a script needs pi ≥1.0.4 (`toFile=true`, then `tools.read` and `image()`).
|
|
78
|
+
|
|
79
|
+
At every session start (startup, reload, new, resume, and fork), the extension activates the loader with `pi.setActiveTools()`, as Pi's MCP support does for MCP servers. You do not need `+codemode` or `+tool_search` in `defaultTools`. The loader is a session-wide Pi tool, so the model can also use it for the other tools in the session, not only for these three. `--tools` and `--exclude-tools` unregister a loader they leave out. The extension does not activate that loader. It notifies once at session start instead, because the model cannot reach the tools.
|
|
80
|
+
|
|
81
|
+
`codemode.mode: "only"` is a session-wide Pi setting. While it is on, the model must call bash/read/edit/write through `codemode` as well, not only these web tools. `--tools` still applies, for example `pi --tools +codemode,-web_browser`.
|
|
58
82
|
|
|
59
83
|
### Enabling and disabling tools
|
|
60
84
|
|
|
61
85
|
- To hide a tool for a session, name it in `--exclude-tools` (or start with `--no-tools` and re-add what you want with `--tools`).
|
|
62
86
|
- To change the startup set in your `settings.json`, use `defaultTools`. Bare names **replace** pi's defaults, so write `"+fetch_markdown"` to add a tool without dropping `read`, `bash`, `edit`, and `write`. On pi 0.99.2+, `/reload` activates tools **newly added** to `defaultTools` without a restart. The reverse does not follow: a tool **removed** from `defaultTools` stays active until you restart, and a tool you turned off during the session stays off. `--tools`, `--no-tools`, and `--no-builtin-tools` still override the setting, on reload as well.
|
|
63
87
|
|
|
88
|
+
### Settings profiles for smaller prompts
|
|
89
|
+
|
|
90
|
+
These snippets go in `~/.pi/agent/settings.json`. They are optional. Default installs can omit them.
|
|
91
|
+
|
|
92
|
+
**Default (unchanged).** Direct tools. No codemode. Full tool schemas stay in the prompt.
|
|
93
|
+
|
|
94
|
+
**Codemode-minimal.** Hide direct declarations and keep the `codemode` description small. `inlineBudget: 0` lists namespaces only. Scripts find tools with `searchTools()` / `describeTool()` / `describeNamespace()`. `inlineBudget: 4096` (or more) instead copies the full seven-tool catalog into the `codemode` description. That **moves** the schemas; it does not shrink them. Use `0` (or a small budget such as `512` for `fetch_markdown` / `pdf_extract` only) when the goal is fewer prompt tokens.
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
{
|
|
98
|
+
"defaultTools": ["+codemode"],
|
|
99
|
+
"codemode.mode": "only",
|
|
100
|
+
"codemode.inlineBudget": 0
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
With `toolExposure` still `direct`, `mode: "only"` hides the active declarations while they stay callable from scripts. Set `"toolExposure": "codemode"` in the extension settings file if you also want these three tools off the active set unless a loader activates them.
|
|
105
|
+
|
|
106
|
+
**Deferred + tool_search.** Register the tools as `deferred` and let `tool_search` load a schema on the next turn. The extension activates `tool_search`, so `~/.pi/agent/settings.json` needs no change.
|
|
107
|
+
|
|
108
|
+
`~/.pi/agent/pi-topping-web-tools/settings.json`:
|
|
109
|
+
|
|
110
|
+
```json
|
|
111
|
+
{
|
|
112
|
+
"toolExposure": "deferred"
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
64
116
|
### Codemode tool listing
|
|
65
117
|
|
|
66
|
-
With `codemode.mode: "only"`, pi hides the direct declarations of active tools, so the model calls them from `codemode` scripts, and the `codemode` description lists each callable tool instead, with its prompt guidelines on pi 1.0.4+. Listed tools share `codemode.inlineBudget`, which defaults to 3000 estimated tokens (characters / 4); tools that do not fit are left out and are reached with `searchTools()` or `describeTool()`. On pi 1.0.4+, the seven-tool catalog `fetch_markdown`, `pdf_extract`, `web_browser`, plus built-in `read`, `bash`, `edit`, and `write` needs about 3,700 tokens as of pi 1.1.0 — `web_browser` alone is about 1,600, the largest section — so the default budget drops `web_browser` from that list. Set the budget to 4096 or more if you use `only`
|
|
118
|
+
With `codemode.mode: "only"`, pi hides the direct declarations of active tools, so the model calls them from `codemode` scripts, and the `codemode` description lists each callable tool instead, with its prompt guidelines on pi 1.0.4+. Listed tools share `codemode.inlineBudget`, which defaults to 3000 estimated tokens (characters / 4); tools that do not fit are left out and are reached with `searchTools()` or `describeTool()`. `0` lists only namespaces. On pi 1.0.4+, the seven-tool catalog `fetch_markdown`, `pdf_extract`, `web_browser`, plus built-in `read`, `bash`, `edit`, and `write` needs about 3,700 tokens as of pi 1.1.0 — `web_browser` alone is about 1,600, the largest section — so the default budget drops `web_browser` from that list. Set the budget to 4096 or more if you use `only` and want every tool inline; real sessions that load more extensions need more. Use `0` when you want the smallest `codemode` description.
|
|
67
119
|
|
|
68
120
|
```json
|
|
69
121
|
{
|
|
@@ -257,7 +309,7 @@ Commands:
|
|
|
257
309
|
## Security model (shared)
|
|
258
310
|
|
|
259
311
|
- **Preapproved hosts**: common documentation and dev sites are allowed without prompting. The allowlist lives in `src/permissions.ts` and is shared by all three tools.
|
|
260
|
-
- **User confirmation**: other hosts prompt Allow once / Allow for this session / Allow for 1 day / Allow for 1 week / Allow for 30 days / Deny. For `web_browser` navigation, Allow once becomes a browser-session grant because request-level enforcement must authorize the loaded document's later requests.
|
|
312
|
+
- **User confirmation**: other hosts prompt Allow once / Allow for this session / Allow for 1 day / Allow for 1 week / Allow for 30 days / Deny. For `web_browser` navigation, Allow once becomes a browser-session grant because request-level enforcement must authorize the loaded document's later requests. Parallel tool calls that each need a prompt rely on pi 1.4.0, which stacks concurrent extension dialogs so each one settles; on older pi a second dialog replaced the first and the first prompt never settled.
|
|
261
313
|
- **Tool annotations**: on pi 0.99+, `fetch_markdown` and `pdf_extract` declare `readOnlyHint` and `openWorldHint`, and `web_browser` declares `openWorldHint` with `readOnlyHint: false`, for permission extensions that confirm calls by hint. The host confirmation above applies regardless of how another extension treats the hints.
|
|
262
314
|
- **URL validation**: all tools reject embedded credentials and overlong URLs. `fetch_markdown` and `pdf_extract` additionally reject loopback, private, and link-local hosts and any non-HTTP(S) scheme on a `url`. `web_browser` accepts `localhost`, private IPs, and `file://` paths so it can drive local dev servers and local HTML — each still prompts for confirmation unless session-approved or covered by a saved durable grant.
|
|
263
315
|
- **Local file resolution**: `pdf_extract` resolves a `path` through `realpath` before any check, so a symlink pointing outside the working directory is treated as outside and prompts. Directories, FIFOs, and devices are rejected, and the size limit is enforced from `stat` before any bytes are read. `web_browser` `upload_file` follows the same rule: files outside the working directory prompt for confirmation (session-scoped) before they are read.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@underactive/pi-topping-web-tools",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Pi extensions for the web: fetch_markdown (URL to markdown, cached, keyless), pdf_extract (PDF text extraction) and web_browser (headless Playwright automation), sharing a preapproved-host allowlist.",
|
|
5
5
|
"keywords": ["pi-package", "pi-extension"],
|
|
6
6
|
"license": "MIT",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
],
|
|
29
29
|
"scripts": {
|
|
30
30
|
"typecheck": "tsc --noEmit",
|
|
31
|
-
"test": "node --test test/*.test.ts"
|
|
31
|
+
"test": "node -C compiled --test test/*.test.ts"
|
|
32
32
|
},
|
|
33
33
|
"pi": {
|
|
34
34
|
"extensions": [
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
42
|
"@mozilla/readability": "^0.6.0",
|
|
43
|
+
"@underactive/pi-topping-primitives": "^0.10.0",
|
|
43
44
|
"linkedom": "^0.18.13",
|
|
44
45
|
"lru-cache": "^11.0.0",
|
|
45
46
|
"playwright": "^1.52.0",
|
|
@@ -53,9 +54,9 @@
|
|
|
53
54
|
"typebox": "*"
|
|
54
55
|
},
|
|
55
56
|
"devDependencies": {
|
|
56
|
-
"@earendil-works/pi-ai": "1.
|
|
57
|
-
"@earendil-works/pi-coding-agent": "1.
|
|
58
|
-
"@earendil-works/pi-tui": "1.
|
|
57
|
+
"@earendil-works/pi-ai": "1.4.0",
|
|
58
|
+
"@earendil-works/pi-coding-agent": "1.4.0",
|
|
59
|
+
"@earendil-works/pi-tui": "1.4.0",
|
|
59
60
|
"@types/node": "^24.0.0",
|
|
60
61
|
"@types/turndown": "^5.0.5",
|
|
61
62
|
"typebox": "^1.3.3",
|
package/src/abort-utils.ts
CHANGED
|
@@ -1,17 +1,3 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
activeFetches: Set<AbortController>,
|
|
5
|
-
): { signal: AbortSignal; cleanup: () => void } {
|
|
6
|
-
const controller = new AbortController();
|
|
7
|
-
// Register so the caller's cleanup can abort this request during shutdown/reload.
|
|
8
|
-
activeFetches.add(controller);
|
|
9
|
-
const signals: AbortSignal[] = [controller.signal, AbortSignal.timeout(timeoutMs)];
|
|
10
|
-
if (userSignal) signals.push(userSignal);
|
|
11
|
-
return {
|
|
12
|
-
signal: AbortSignal.any(signals),
|
|
13
|
-
cleanup: () => {
|
|
14
|
-
activeFetches.delete(controller);
|
|
15
|
-
},
|
|
16
|
-
};
|
|
17
|
-
}
|
|
1
|
+
// A request signal that aborts on the caller, a timeout, or shutdown, shared. The callers here
|
|
2
|
+
// keep importing from this file.
|
|
3
|
+
export { combineSignals } from "@underactive/pi-topping-primitives/abort";
|
package/src/env.ts
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
/** Set when pi suppresses non-essential notifications. */
|
|
8
|
-
export function suppressNotifications(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
9
|
-
return isTruthyEnvFlag(env.PI_SUPPRESS_NOTIFICATIONS);
|
|
10
|
-
}
|
|
1
|
+
// Pi's environment-flag rule and the notification switch, shared. The callers here keep
|
|
2
|
+
// importing from this file.
|
|
3
|
+
export {
|
|
4
|
+
isTruthyEnvFlag,
|
|
5
|
+
suppressNotifications,
|
|
6
|
+
} from "@underactive/pi-topping-primitives/env";
|
package/src/fetch-markdown.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { getBrowserHeaders } from "./browser-headers.ts";
|
|
|
13
13
|
import { isPreapprovedHost, permissionKey } from "./permissions.ts";
|
|
14
14
|
import { requestHostPermission } from "./permission-prompt.ts";
|
|
15
15
|
import { BoundedPreview, clipField, resultText } from "./render-preview.ts";
|
|
16
|
+
import { attachExposureLoader, registerWebTool } from "./tool-exposure.ts";
|
|
16
17
|
import { MAX_URL_LENGTH, isLocalOrPrivateHost, upgradeHttpToHttps } from "./web-browser/permissions.ts";
|
|
17
18
|
|
|
18
19
|
const MAX_HTTP_CONTENT_LENGTH = 25 * 1024 * 1024;
|
|
@@ -582,7 +583,8 @@ To complete your request, I need to fetch content from the redirected URL. Pleas
|
|
|
582
583
|
export default function (pi: ExtensionAPI) {
|
|
583
584
|
const sessionPermissions = new Map<string, "allow" | "deny">();
|
|
584
585
|
|
|
585
|
-
pi
|
|
586
|
+
registerWebTool(pi, fetchMarkdownTool);
|
|
587
|
+
attachExposureLoader(pi);
|
|
586
588
|
|
|
587
589
|
pi.on("tool_call", async (event, ctx) => {
|
|
588
590
|
if (event.toolName !== "fetch_markdown") return undefined;
|
package/src/pdf-extract.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { combineSignals } from "./abort-utils.ts";
|
|
|
12
12
|
import { isInsideCwd, isPreapprovedHost, permissionKey } from "./permissions.ts";
|
|
13
13
|
import { requestHostPermission } from "./permission-prompt.ts";
|
|
14
14
|
import { BoundedPreview, clipField, resultText } from "./render-preview.ts";
|
|
15
|
+
import { attachExposureLoader, registerWebTool } from "./tool-exposure.ts";
|
|
15
16
|
import { getWithPermittedRedirects, isPermittedRedirect, sliceContent, validateURL } from "./fetch-markdown.ts";
|
|
16
17
|
import { upgradeHttpToHttps } from "./web-browser/permissions.ts";
|
|
17
18
|
|
|
@@ -428,7 +429,8 @@ Usage notes:
|
|
|
428
429
|
export default function (pi: ExtensionAPI) {
|
|
429
430
|
const sessionPermissions = new Map<string, "allow" | "deny">();
|
|
430
431
|
|
|
431
|
-
pi
|
|
432
|
+
registerWebTool(pi, pdfExtractTool);
|
|
433
|
+
attachExposureLoader(pi);
|
|
432
434
|
|
|
433
435
|
pi.on("tool_call", async (event, ctx) => {
|
|
434
436
|
if (event.toolName !== "pdf_extract") return undefined;
|
package/src/permission-store.ts
CHANGED
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
|
|
9
9
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
10
10
|
import { readFileSync } from "node:fs";
|
|
11
|
-
import {
|
|
11
|
+
import { stat, unlink, writeFile } from "node:fs/promises";
|
|
12
12
|
import { join } from "node:path";
|
|
13
|
+
import { atomicWriteFile } from "@underactive/pi-topping-primitives/settings";
|
|
13
14
|
import { permissionKey } from "./permissions.ts";
|
|
14
15
|
|
|
15
16
|
export type PermissionScope = "fetch_markdown" | "pdf_extract" | "web_browser";
|
|
@@ -169,13 +170,8 @@ async function releaseLock(): Promise<void> {
|
|
|
169
170
|
}
|
|
170
171
|
|
|
171
172
|
async function writeStore(grants: Grant[]): Promise<void> {
|
|
172
|
-
const dir = join(storePath(), "..");
|
|
173
|
-
await mkdir(dir, { recursive: true });
|
|
174
|
-
|
|
175
173
|
const file: StoreFile = { version: 1, grants };
|
|
176
|
-
|
|
177
|
-
await writeFile(tmp, JSON.stringify(file, null, "\t"), { mode: 0o600 });
|
|
178
|
-
await rename(tmp, storePath());
|
|
174
|
+
atomicWriteFile(storePath(), JSON.stringify(file, null, "\t"));
|
|
179
175
|
}
|
|
180
176
|
|
|
181
177
|
/** Add a durable grant. Returns false if the lock could not be acquired. */
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared ToolExposure + namespace for fetch_markdown, pdf_extract, and web_browser.
|
|
3
|
+
* Default remains Pi's direct registration. Other modes are opt-in via settings.json.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ExtensionAPI, ToolDefinition, ToolExposure, ToolNamespace } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { createSettingsStore, sanitize, toppingSettingsPath } from "@underactive/pi-topping-primitives/settings";
|
|
8
|
+
|
|
9
|
+
export type WebToolsExposureMode = "direct" | "codemode" | "deferred";
|
|
10
|
+
|
|
11
|
+
export const WEB_TOOLS_NAMESPACE: ToolNamespace = {
|
|
12
|
+
name: "pi-topping-web-tools",
|
|
13
|
+
description: "Keyless fetch_markdown, pdf_extract, and headless web_browser.",
|
|
14
|
+
instructions: [
|
|
15
|
+
"Use fetch_markdown for public HTML documentation, READMEs, and articles when you want turndown markdown and a 15-minute cache.",
|
|
16
|
+
"Use pdf_extract for a remote or local PDF. fetch_markdown and web_fetch cannot read PDF text.",
|
|
17
|
+
"Use web_browser for JavaScript-rendered pages, local servers, screenshots, and interaction.",
|
|
18
|
+
"Host allowlist and permission prompts still apply. Exposure only changes how the model sees the tools, not what they may execute.",
|
|
19
|
+
"See the package README sections for fetch_markdown, pdf_extract, and web_browser.",
|
|
20
|
+
].join("\n"),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const MAX_SETTINGS_BYTES = 64 * 1024;
|
|
24
|
+
|
|
25
|
+
const defaultSettingsPath = toppingSettingsPath("pi-topping-web-tools");
|
|
26
|
+
const SETTINGS_PATH_ENV = "PI_WEB_TOOLS_SETTINGS_FILE";
|
|
27
|
+
|
|
28
|
+
/** `PI_WEB_TOOLS_SETTINGS_FILE` wins when non-empty; otherwise the agent-dir default. */
|
|
29
|
+
export function settingsPath(): string {
|
|
30
|
+
const override = process.env[SETTINGS_PATH_ENV];
|
|
31
|
+
return override ? override : defaultSettingsPath();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function parseToolExposureMode(value: unknown): WebToolsExposureMode {
|
|
35
|
+
if (value === "codemode" || value === "deferred" || value === "direct") {
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
return "direct";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function exposureStore(path?: string) {
|
|
42
|
+
return createSettingsStore<WebToolsExposureMode>({
|
|
43
|
+
path: path === undefined ? defaultSettingsPath : () => path,
|
|
44
|
+
...(path === undefined ? { pathEnv: SETTINGS_PATH_ENV } : {}),
|
|
45
|
+
maxBytes: MAX_SETTINGS_BYTES,
|
|
46
|
+
codec: {
|
|
47
|
+
sanitize: (raw) => (sanitize.isRecord(raw) ? parseToolExposureMode(raw.toolExposure) : "direct"),
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A missing, oversized, corrupt or unrecognised file means Pi's direct registration. */
|
|
53
|
+
export function loadToolExposureMode(path?: string): WebToolsExposureMode {
|
|
54
|
+
return exposureStore(path).read();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function webToolPresentation(mode: WebToolsExposureMode = loadToolExposureMode()): {
|
|
58
|
+
exposure: ToolExposure;
|
|
59
|
+
namespace: ToolNamespace;
|
|
60
|
+
} {
|
|
61
|
+
return {
|
|
62
|
+
exposure: mode,
|
|
63
|
+
namespace: WEB_TOOLS_NAMESPACE,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function registerWebTool(pi: ExtensionAPI, tool: ToolDefinition): void {
|
|
68
|
+
pi.registerTool({ ...tool, ...webToolPresentation() });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let didNotifyPrerequisites = false;
|
|
72
|
+
|
|
73
|
+
export function _resetExposureNoticeForTests(): void {
|
|
74
|
+
didNotifyPrerequisites = false;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Loader tools that reach tools with this exposure, in the order to activate them. */
|
|
78
|
+
function exposureLoaders(mode: WebToolsExposureMode): readonly string[] {
|
|
79
|
+
if (mode === "codemode") return ["codemode"];
|
|
80
|
+
if (mode === "deferred") return ["tool_search", "codemode"];
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The registered loader tool to activate so the model can reach the web tools, or undefined when a
|
|
86
|
+
* loader is already active or none is registered. `--tools` and `--exclude-tools` unregister tools,
|
|
87
|
+
* so an excluded loader is never activated.
|
|
88
|
+
*/
|
|
89
|
+
export function exposureLoaderToActivate(
|
|
90
|
+
mode: WebToolsExposureMode,
|
|
91
|
+
activeTools: readonly string[],
|
|
92
|
+
registeredTools: readonly string[],
|
|
93
|
+
): string | undefined {
|
|
94
|
+
const loaders = exposureLoaders(mode);
|
|
95
|
+
if (loaders.some((name) => activeTools.includes(name))) return undefined;
|
|
96
|
+
return loaders.find((name) => registeredTools.includes(name));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function exposurePrerequisiteNotice(
|
|
100
|
+
mode: WebToolsExposureMode,
|
|
101
|
+
activeTools: readonly string[],
|
|
102
|
+
): string | undefined {
|
|
103
|
+
if (mode === "direct") return undefined;
|
|
104
|
+
if (exposureLoaders(mode).some((name) => activeTools.includes(name))) return undefined;
|
|
105
|
+
if (mode === "codemode") {
|
|
106
|
+
return 'pi-topping-web-tools toolExposure is "codemode", but the codemode tool is not available, so the model cannot reach the tools. Check --tools and --exclude-tools, or set toolExposure to "direct".';
|
|
107
|
+
}
|
|
108
|
+
return 'pi-topping-web-tools toolExposure is "deferred", but neither tool_search nor codemode is available, so the model cannot reach the tools. Check --tools and --exclude-tools, or set toolExposure to "direct".';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* On every session start, activate the loader tool the configured exposure needs, as Pi's MCP support
|
|
113
|
+
* does for MCP servers. Notify once when no loader can be activated.
|
|
114
|
+
*/
|
|
115
|
+
export function attachExposureLoader(pi: ExtensionAPI): void {
|
|
116
|
+
pi.on("session_start", (_event, ctx) => {
|
|
117
|
+
const mode = loadToolExposureMode();
|
|
118
|
+
if (mode === "direct") return;
|
|
119
|
+
if (typeof pi.getActiveTools !== "function") return;
|
|
120
|
+
const active = pi.getActiveTools();
|
|
121
|
+
const loader = exposureLoaderToActivate(mode, active, pi.getAllTools().map((tool) => tool.name));
|
|
122
|
+
if (loader) pi.setActiveTools([...active, loader]);
|
|
123
|
+
if (didNotifyPrerequisites) return;
|
|
124
|
+
const message = exposurePrerequisiteNotice(mode, pi.getActiveTools());
|
|
125
|
+
if (!message) return;
|
|
126
|
+
didNotifyPrerequisites = true;
|
|
127
|
+
if (ctx.hasUI) {
|
|
128
|
+
ctx.ui.notify(message, "warning");
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
}
|
package/src/web-browser/feeds.ts
CHANGED
|
@@ -1,11 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
|
|
3
|
-
export const BROWSER_FEED_TYPE = "pi-topping-web-tools/browser";
|
|
4
|
-
|
|
5
|
-
export interface BrowserFeedPayload {
|
|
6
|
-
label: string;
|
|
7
|
-
open: 0 | 1;
|
|
8
|
-
}
|
|
2
|
+
import { ENTRY_TYPES, type BrowserFeedPayload } from "@underactive/pi-topping-primitives/contracts";
|
|
9
3
|
|
|
10
4
|
let extension: ExtensionAPI | undefined;
|
|
11
5
|
let latestPayload: BrowserFeedPayload | undefined;
|
|
@@ -19,7 +13,7 @@ function samePayload(left: BrowserFeedPayload | undefined, right: BrowserFeedPay
|
|
|
19
13
|
function emitBrowserState(force = false): void {
|
|
20
14
|
if (!extension || !latestPayload || (!force && samePayload(emittedPayload, latestPayload))) return;
|
|
21
15
|
try {
|
|
22
|
-
extension.appendEntry(
|
|
16
|
+
extension.appendEntry(ENTRY_TYPES.browser, latestPayload);
|
|
23
17
|
emittedPayload = latestPayload;
|
|
24
18
|
} catch {
|
|
25
19
|
// Feed publication is best effort; leave emitted state unchanged for retry.
|
package/src/web-browser/index.ts
CHANGED
|
@@ -34,6 +34,7 @@ import { listGrants } from "../permission-store.ts";
|
|
|
34
34
|
import { isInsideCwd, permissionKey } from "../permissions.ts";
|
|
35
35
|
import { suppressNotifications } from "../env.ts";
|
|
36
36
|
import { publishBrowserState, registerBrowserFeeds } from "./feeds.ts";
|
|
37
|
+
import { attachExposureLoader, webToolPresentation } from "../tool-exposure.ts";
|
|
37
38
|
|
|
38
39
|
const ACTIONS = [
|
|
39
40
|
"navigate",
|
|
@@ -228,6 +229,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
228
229
|
return manager;
|
|
229
230
|
};
|
|
230
231
|
registerBrowserFeeds(pi);
|
|
232
|
+
attachExposureLoader(pi);
|
|
231
233
|
|
|
232
234
|
pi.on("session_start", async (_event, ctx) => {
|
|
233
235
|
const info = await getConfiguredBrowserManager().getPageInfoAsync();
|
|
@@ -322,9 +324,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
322
324
|
},
|
|
323
325
|
});
|
|
324
326
|
|
|
327
|
+
const presentation = webToolPresentation();
|
|
325
328
|
pi.registerTool({
|
|
326
329
|
name: "web_browser",
|
|
327
330
|
label: "Web Browser",
|
|
331
|
+
exposure: presentation.exposure,
|
|
332
|
+
namespace: presentation.namespace,
|
|
328
333
|
promptSnippet:
|
|
329
334
|
"Automate a headless browser to navigate sites, click/type, capture screenshots, evaluate JS, and read console logs.",
|
|
330
335
|
promptGuidelines: [
|
|
@@ -389,7 +394,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
389
394
|
Type.Number({ description: "Tab index to switch to, from list_tabs (action: switch_tab)" }),
|
|
390
395
|
),
|
|
391
396
|
dialogAction: Type.Optional(
|
|
392
|
-
StringEnum(["accept", "dismiss"], {
|
|
397
|
+
StringEnum(["accept", "dismiss"] as const, {
|
|
393
398
|
description:
|
|
394
399
|
"How to resolve future JS dialogs (alert/confirm/prompt) for the rest of the session (action: set_dialog_behavior, default dismiss)",
|
|
395
400
|
}),
|
|
@@ -418,13 +423,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
418
423
|
Type.Number({ description: "Viewport height in pixels (action: set_viewport)" }),
|
|
419
424
|
),
|
|
420
425
|
mode: Type.Optional(
|
|
421
|
-
StringEnum(["fill", "press"], {
|
|
426
|
+
StringEnum(["fill", "press"] as const, {
|
|
422
427
|
description:
|
|
423
428
|
"Typing mode (action: type, default fill). fill clears the field first; press types key-by-key and appends to existing input.",
|
|
424
429
|
}),
|
|
425
430
|
),
|
|
426
431
|
state: Type.Optional(
|
|
427
|
-
StringEnum(["visible", "hidden", "attached", "detached"], {
|
|
432
|
+
StringEnum(["visible", "hidden", "attached", "detached"] as const, {
|
|
428
433
|
description: "Wait state (action: wait_for, default visible)",
|
|
429
434
|
}),
|
|
430
435
|
),
|
|
@@ -453,7 +458,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
453
458
|
path: Type.Optional(Type.String()),
|
|
454
459
|
httpOnly: Type.Optional(Type.Boolean()),
|
|
455
460
|
secure: Type.Optional(Type.Boolean()),
|
|
456
|
-
sameSite: Type.Optional(StringEnum(["Strict", "Lax", "None"])),
|
|
461
|
+
sameSite: Type.Optional(StringEnum(["Strict", "Lax", "None"] as const)),
|
|
457
462
|
}),
|
|
458
463
|
{ description: "Cookies to set (action: set_cookies); each needs url or domain+path" },
|
|
459
464
|
),
|