@underactive/pi-topping-web-tools 0.2.4 → 0.2.6
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 +24 -0
- package/README.md +9 -0
- package/package.json +4 -4
- package/src/fetch-markdown.ts +5 -13
- package/src/pdf-extract.ts +3 -12
- package/src/render-preview.ts +107 -0
- package/src/web-browser/index.ts +18 -17
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.2.6] - 2026-10-02
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Tested against pi 1.0.0. No break: the extension-facing declaration surface (`ToolDefinition`, `ExtensionAPI`, `ExtensionContext`, `ExtensionUIContext`, `defineTool`) is unchanged, `pi-ai`'s declaration surface is unchanged, and pi-tui's three changed declaration files are additive. The codemode tool description for each declared tool is now one line instead of a rendered TypeScript declaration, which shortens the prompt without changing what `promptGuidelines` contribute. `web_browser`'s `browser` status line is unaffected by the `tuiMode` default moving to `fullscreen`: the footer that renders it is mounted unconditionally and its renderer is unchanged.
|
|
10
|
+
- Bumped the pinned `@earendil-works/pi-ai`, `@earendil-works/pi-coding-agent`, and `@earendil-works/pi-tui` devDependencies from 0.99.2 to 1.0.0.
|
|
11
|
+
- `web_browser`: added a prompt guideline that image content is not visible to a codemode script, so `screenshot` should be called directly, or with `toFile=true` to get a path the script can read.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- `fetch_markdown`, `pdf_extract`, `web_browser`: expanded result previews are bounded in wrapped rows at the render width instead of in characters. The 0.2.5 bound of 10 logical lines of 200 characters still rendered about 43 rows at 80 columns and 76 at 40; the body is now capped at 20 rows at any terminal width, using pi's `truncateToVisualLines`.
|
|
16
|
+
|
|
17
|
+
## [0.2.5] - 2026-09-30
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- Tested against pi 0.99.2. No break: the 0.99.1 to 0.99.2 extension API is additive only (`ToolNamespace.instructions`), and `pi-tui` and `pi-agent-core` declarations are unchanged.
|
|
22
|
+
- Bumped the pinned `@earendil-works/pi-ai`, `@earendil-works/pi-coding-agent`, and `@earendil-works/pi-tui` devDependencies from 0.99.1 to 0.99.2.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- Bounded tool result and status previews. A result that is one long logical line — minified JSON, raw HTML from `get_content` — rendered as a single unbounded logical line and word-wrapped into ~1250 screen lines. An expanded preview now keeps 10 logical lines of 200 characters, and a collapsed result whose `details` cannot be rendered is clipped to 200 characters.
|
|
27
|
+
- `fetch_markdown`, `pdf_extract`, `web_browser`: attacker-controlled header fields are clipped to 200 characters before display. A hostile server could previously send a multi-megabyte HTTP reason phrase, and a page could carry a huge `<title>`; either one filled the screen from the collapsed one-line summary.
|
|
28
|
+
|
|
5
29
|
## [0.2.4] - 2026-09-29
|
|
6
30
|
|
|
7
31
|
### Added
|
package/README.md
CHANGED
|
@@ -54,6 +54,13 @@ 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
|
+
All three tools are `direct`-exposure and pi activates those on registration (`defaultActive` defaults to true), so nothing needs to be enabled.
|
|
58
|
+
|
|
59
|
+
### Enabling and disabling tools
|
|
60
|
+
|
|
61
|
+
- 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
|
+
- 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
|
+
|
|
57
64
|
## `fetch_markdown`
|
|
58
65
|
|
|
59
66
|
Complements pi's built-in search-backed `web_fetch` with a lightweight, keyless fetch path for public documentation, READMEs, and articles:
|
|
@@ -135,6 +142,8 @@ When `screenshot` returns an inline result (`toFile` omitted or `false`), `web_b
|
|
|
135
142
|
|
|
136
143
|
`toFile=true` and `/browser-screenshot` save the original PNG to disk and put only the path and dimensions in the tool result. The file is resized only if a later `read` or `@file` attachment sends it to the model.
|
|
137
144
|
|
|
145
|
+
In codemode, a tool result exposes only its text blocks, so an inline `screenshot` reaches the script as its caption with no image bytes. Call `screenshot` directly from the session, or pass `toFile=true` and read the saved path from the script.
|
|
146
|
+
|
|
138
147
|
The resize fields are:
|
|
139
148
|
|
|
140
149
|
- `maxWidth` / `maxHeight`: pixel bounds (pi defaults to 2000×2000)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@underactive/pi-topping-web-tools",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
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",
|
|
@@ -53,9 +53,9 @@
|
|
|
53
53
|
"typebox": "*"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
|
-
"@earendil-works/pi-ai": "0.
|
|
57
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
58
|
-
"@earendil-works/pi-tui": "0.
|
|
56
|
+
"@earendil-works/pi-ai": "1.0.0",
|
|
57
|
+
"@earendil-works/pi-coding-agent": "1.0.0",
|
|
58
|
+
"@earendil-works/pi-tui": "1.0.0",
|
|
59
59
|
"@types/node": "^24.0.0",
|
|
60
60
|
"@types/turndown": "^5.0.5",
|
|
61
61
|
"typebox": "^1.3.3",
|
package/src/fetch-markdown.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { combineSignals } from "./abort-utils.ts";
|
|
|
12
12
|
import { getBrowserHeaders } from "./browser-headers.ts";
|
|
13
13
|
import { isPreapprovedHost, permissionKey } from "./permissions.ts";
|
|
14
14
|
import { requestHostPermission } from "./permission-prompt.ts";
|
|
15
|
+
import { BoundedPreview, clipField, resultText } from "./render-preview.ts";
|
|
15
16
|
import { MAX_URL_LENGTH, isLocalOrPrivateHost, upgradeHttpToHttps } from "./web-browser/permissions.ts";
|
|
16
17
|
|
|
17
18
|
const MAX_HTTP_CONTENT_LENGTH = 25 * 1024 * 1024;
|
|
@@ -557,12 +558,12 @@ To complete your request, I need to fetch content from the redirected URL. Pleas
|
|
|
557
558
|
}
|
|
558
559
|
|
|
559
560
|
if (!details || typeof details.bytes !== "number") {
|
|
560
|
-
|
|
561
|
-
return new Text(content?.type === "text" ? content.text : "", 0, 0);
|
|
561
|
+
return new Text(clipField(resultText(result.content)), 0, 0);
|
|
562
562
|
}
|
|
563
563
|
|
|
564
564
|
let text = theme.fg("success", `Received ${formatSize(details.bytes)}`);
|
|
565
|
-
|
|
565
|
+
// The HTTP reason phrase is attacker-controlled, so bound it like the body.
|
|
566
|
+
text += theme.fg("muted", ` (${details.code} ${clipField(details.codeText)})`);
|
|
566
567
|
if (details.cached) {
|
|
567
568
|
text += theme.fg("dim", " cached");
|
|
568
569
|
}
|
|
@@ -571,16 +572,7 @@ To complete your request, I need to fetch content from the redirected URL. Pleas
|
|
|
571
572
|
}
|
|
572
573
|
|
|
573
574
|
if (expanded) {
|
|
574
|
-
|
|
575
|
-
if (content?.type === "text") {
|
|
576
|
-
const lines = content.text.split("\n", 16);
|
|
577
|
-
for (const line of lines.slice(0, 15)) {
|
|
578
|
-
text += `\n${theme.fg("dim", line)}`;
|
|
579
|
-
}
|
|
580
|
-
if (lines.length > 15) {
|
|
581
|
-
text += `\n${theme.fg("muted", "… (truncated)")}`;
|
|
582
|
-
}
|
|
583
|
-
}
|
|
575
|
+
return new BoundedPreview({ header: text, body: resultText(result.content), marker: "… (truncated)", theme });
|
|
584
576
|
}
|
|
585
577
|
|
|
586
578
|
return new Text(text, 0, 0);
|
package/src/pdf-extract.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { extractText, getDocumentProxy } from "unpdf";
|
|
|
11
11
|
import { combineSignals } from "./abort-utils.ts";
|
|
12
12
|
import { isPreapprovedHost, permissionKey } from "./permissions.ts";
|
|
13
13
|
import { requestHostPermission } from "./permission-prompt.ts";
|
|
14
|
+
import { BoundedPreview, clipField, resultText } from "./render-preview.ts";
|
|
14
15
|
import { getWithPermittedRedirects, isPermittedRedirect, sliceContent, validateURL } from "./fetch-markdown.ts";
|
|
15
16
|
import { upgradeHttpToHttps } from "./web-browser/permissions.ts";
|
|
16
17
|
|
|
@@ -409,8 +410,7 @@ Usage notes:
|
|
|
409
410
|
}
|
|
410
411
|
|
|
411
412
|
if (!details || typeof details.pageCount !== "number") {
|
|
412
|
-
|
|
413
|
-
return new Text(content?.type === "text" ? content.text : "", 0, 0);
|
|
413
|
+
return new Text(clipField(resultText(result.content)), 0, 0);
|
|
414
414
|
}
|
|
415
415
|
|
|
416
416
|
let text = theme.fg("success", `Extracted ${details.pageCount} of ${details.totalPages} page(s)`);
|
|
@@ -423,16 +423,7 @@ Usage notes:
|
|
|
423
423
|
}
|
|
424
424
|
|
|
425
425
|
if (expanded) {
|
|
426
|
-
|
|
427
|
-
if (content?.type === "text") {
|
|
428
|
-
const lines = content.text.split("\n", 16);
|
|
429
|
-
for (const line of lines.slice(0, 15)) {
|
|
430
|
-
text += `\n${theme.fg("dim", line)}`;
|
|
431
|
-
}
|
|
432
|
-
if (lines.length > 15) {
|
|
433
|
-
text += `\n${theme.fg("muted", "… (truncated)")}`;
|
|
434
|
-
}
|
|
435
|
-
}
|
|
426
|
+
return new BoundedPreview({ header: text, body: resultText(result.content), marker: "… (truncated)", theme });
|
|
436
427
|
}
|
|
437
428
|
|
|
438
429
|
return new Text(text, 0, 0);
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded previews for tool results in the TUI.
|
|
3
|
+
*
|
|
4
|
+
* pi 0.99.2 fixed long single-line results filling the screen for bash, codemode,
|
|
5
|
+
* and MCP tool output, but that fix lives in the host's own renderers. Extension
|
|
6
|
+
* tools that supply a custom `renderResult` render whatever they return, so the
|
|
7
|
+
* same bound has to be applied here. Header fields need the same bound: an HTTP
|
|
8
|
+
* reason phrase and a page `<title>` are attacker-controlled and can be megabytes.
|
|
9
|
+
*
|
|
10
|
+
* `renderResult` is not handed a width, so header fields and collapsed bodies are
|
|
11
|
+
* bounded in characters. The width does reach the component it returns, through
|
|
12
|
+
* `render(width)`, so an expanded body is bounded in rows there: `BoundedPreview`
|
|
13
|
+
* wraps it with the host's `truncateToVisualLines`, which keeps its height the same
|
|
14
|
+
* at every terminal width.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { truncateToVisualLines, type Theme } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
|
|
19
|
+
import { Text, type Component } from "@earendil-works/pi-tui";
|
|
20
|
+
|
|
21
|
+
/** Body rows in an expanded preview, counted after wrapping at the render width. */
|
|
22
|
+
export const MAX_VISUAL_PREVIEW_LINES = 20;
|
|
23
|
+
|
|
24
|
+
/** Body characters handed to the wrapper: fills the row cap up to 400 columns without wrapping a 100K result on every redraw. */
|
|
25
|
+
export const MAX_PREVIEW_CHARS = 8_000;
|
|
26
|
+
|
|
27
|
+
/** Characters kept from a single unbounded field: a header value, status field, or collapsed body. */
|
|
28
|
+
export const MAX_FIELD_CHARS = 200;
|
|
29
|
+
|
|
30
|
+
/** Concatenate a tool result's text blocks; empty when the result carries only images. */
|
|
31
|
+
export function resultText(content: readonly (TextContent | ImageContent)[]): string {
|
|
32
|
+
return content
|
|
33
|
+
.filter((block): block is TextContent => block.type === "text")
|
|
34
|
+
.map((block) => block.text)
|
|
35
|
+
.join("\n");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Clip one attacker-controlled field to {@link MAX_FIELD_CHARS}, marking the cut with an ellipsis. */
|
|
39
|
+
export function clipField(text: string): string {
|
|
40
|
+
return text.length > MAX_FIELD_CHARS ? `${text.slice(0, MAX_FIELD_CHARS)}…` : text;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface BoundedPreviewOptions {
|
|
44
|
+
/** Styled summary; its attacker-controlled fields are already clipped. */
|
|
45
|
+
header: string;
|
|
46
|
+
/** Raw result text, styled dim per logical line. */
|
|
47
|
+
body: string;
|
|
48
|
+
/** Raw marker shown muted when the body was cut. */
|
|
49
|
+
marker: string;
|
|
50
|
+
/** Raw line shown dim after the body whether or not it was cut. */
|
|
51
|
+
trailer?: string;
|
|
52
|
+
theme: Theme;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Expanded tool result: header, at most {@link MAX_VISUAL_PREVIEW_LINES} body rows, then the
|
|
57
|
+
* marker and trailer. Rows depend on the width, which only `render` sees; lines are cached per
|
|
58
|
+
* width because the transcript renders every result on every frame.
|
|
59
|
+
*/
|
|
60
|
+
export class BoundedPreview implements Component {
|
|
61
|
+
private readonly header: string;
|
|
62
|
+
private readonly body: string;
|
|
63
|
+
private readonly marker: string;
|
|
64
|
+
private readonly trailer: string | undefined;
|
|
65
|
+
private readonly cut: boolean;
|
|
66
|
+
private cachedWidth: number | undefined;
|
|
67
|
+
private cachedLines: string[] | undefined;
|
|
68
|
+
|
|
69
|
+
constructor(options: BoundedPreviewOptions) {
|
|
70
|
+
const { theme } = options;
|
|
71
|
+
this.header = options.header;
|
|
72
|
+
this.cut = options.body.length > MAX_PREVIEW_CHARS;
|
|
73
|
+
// Slice the raw text before styling so the cap never cuts an escape sequence. Every logical
|
|
74
|
+
// line takes at least one row, so one line past the cap is enough for the skip to register.
|
|
75
|
+
this.body = options.body
|
|
76
|
+
.slice(0, MAX_PREVIEW_CHARS)
|
|
77
|
+
.split("\n", MAX_VISUAL_PREVIEW_LINES + 1)
|
|
78
|
+
.map((line) => theme.fg("dim", line))
|
|
79
|
+
.join("\n");
|
|
80
|
+
this.marker = theme.fg("muted", options.marker);
|
|
81
|
+
this.trailer = options.trailer === undefined ? undefined : theme.fg("dim", options.trailer);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
render(width: number): string[] {
|
|
85
|
+
if (this.cachedLines === undefined || this.cachedWidth !== width) {
|
|
86
|
+
// paddingX 0 keeps the geometry of the `new Text(text, 0, 0)` this replaces.
|
|
87
|
+
const { visualLines, skippedCount } = truncateToVisualLines(
|
|
88
|
+
this.body,
|
|
89
|
+
MAX_VISUAL_PREVIEW_LINES,
|
|
90
|
+
width,
|
|
91
|
+
0,
|
|
92
|
+
"start",
|
|
93
|
+
);
|
|
94
|
+
const lines = [...new Text(this.header, 0, 0).render(width), ...visualLines];
|
|
95
|
+
if (this.cut || skippedCount > 0) lines.push(...new Text(this.marker, 0, 0).render(width));
|
|
96
|
+
if (this.trailer !== undefined) lines.push(...new Text(this.trailer, 0, 0).render(width));
|
|
97
|
+
this.cachedLines = lines;
|
|
98
|
+
this.cachedWidth = width;
|
|
99
|
+
}
|
|
100
|
+
return this.cachedLines;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
invalidate(): void {
|
|
104
|
+
this.cachedWidth = undefined;
|
|
105
|
+
this.cachedLines = undefined;
|
|
106
|
+
}
|
|
107
|
+
}
|
package/src/web-browser/index.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
import { buildOriginChecker } from "./egress-policy.ts";
|
|
32
32
|
import { checkUrlPermission, upgradeHttpToHttps, validateURL } from "./permissions.ts";
|
|
33
33
|
import { requestHostPermission } from "../permission-prompt.ts";
|
|
34
|
+
import { BoundedPreview, clipField, resultText } from "../render-preview.ts";
|
|
34
35
|
import { listGrants } from "../permission-store.ts";
|
|
35
36
|
import { permissionKey } from "../permissions.ts";
|
|
36
37
|
import { isInsideCwd } from "../pdf-extract.ts";
|
|
@@ -291,8 +292,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
291
292
|
const lines = [
|
|
292
293
|
"Web Browser:",
|
|
293
294
|
` Status: ${info.isOpen ? "open" : "closed"}`,
|
|
294
|
-
` URL: ${info.url
|
|
295
|
-
` Title: ${info.title
|
|
295
|
+
` URL: ${info.url ? clipField(info.url) : "(none)"}`,
|
|
296
|
+
` Title: ${info.title ? clipField(info.title.replace(/[\u0000-\u001f\u007f]/g, " ")) : "(none)"}`,
|
|
296
297
|
` Tabs open: ${info.tabCount}`,
|
|
297
298
|
` Console messages: ${info.consoleCount}`,
|
|
298
299
|
` Network issues: ${info.networkCount}`,
|
|
@@ -342,6 +343,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
342
343
|
"Use get_content only when DOM structure or attributes are needed; it returns raw HTML.",
|
|
343
344
|
"On SPA sites, use wait_for after navigate (selector/state or networkidle) before click/type.",
|
|
344
345
|
"Use screenshot for visual verification (requires a vision-capable model); use toFile=true to save PNG to disk instead of base64.",
|
|
346
|
+
"Image content is not visible to a codemode script: a script receives only a tool result's text blocks, so a screenshot taken from one returns its caption without the pixels. Call screenshot directly, or with toFile=true to get a file path the script can read.",
|
|
345
347
|
"Use get_console_logs when JavaScript errors or console output may explain page behavior.",
|
|
346
348
|
"Use get_network_logs when failed, egress-blocked, or 4xx/5xx requests may explain page behavior. Navigate directly to a blocked origin to request approval.",
|
|
347
349
|
"Use set_cookies to inject cookies for authenticated flows after manual login.",
|
|
@@ -1042,7 +1044,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1042
1044
|
}
|
|
1043
1045
|
|
|
1044
1046
|
if (details.error) {
|
|
1045
|
-
return new Text(theme.fg("error", details.error
|
|
1047
|
+
return new Text(theme.fg("error", clipField(details.error)), 0, 0);
|
|
1046
1048
|
}
|
|
1047
1049
|
|
|
1048
1050
|
const imageBlock = result.content.find((c) => c.type === "image");
|
|
@@ -1062,9 +1064,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
1062
1064
|
let text = "";
|
|
1063
1065
|
switch (details.action) {
|
|
1064
1066
|
case "navigate":
|
|
1067
|
+
// The page title is attacker-controlled, so bound it like the body.
|
|
1065
1068
|
text = theme.fg(
|
|
1066
1069
|
"success",
|
|
1067
|
-
`Navigated — ${
|
|
1070
|
+
`Navigated — ${
|
|
1071
|
+
details.title
|
|
1072
|
+
? clipField(details.title.replace(/[\u0000-\u001f\u007f]/g, " "))
|
|
1073
|
+
: clipField(details.url ?? "ok")
|
|
1074
|
+
}`,
|
|
1068
1075
|
);
|
|
1069
1076
|
break;
|
|
1070
1077
|
case "get_content":
|
|
@@ -1095,19 +1102,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
1095
1102
|
}
|
|
1096
1103
|
|
|
1097
1104
|
if (expanded) {
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
text += `\n${theme.fg("muted", "…")}`;
|
|
1106
|
-
}
|
|
1107
|
-
}
|
|
1108
|
-
if (details.fullOutputPath) {
|
|
1109
|
-
text += `\n${theme.fg("dim", `Full: ${details.fullOutputPath}`)}`;
|
|
1110
|
-
}
|
|
1105
|
+
return new BoundedPreview({
|
|
1106
|
+
header: text,
|
|
1107
|
+
body: resultText(result.content),
|
|
1108
|
+
marker: "…",
|
|
1109
|
+
trailer: details.fullOutputPath ? `Full: ${details.fullOutputPath}` : undefined,
|
|
1110
|
+
theme,
|
|
1111
|
+
});
|
|
1111
1112
|
}
|
|
1112
1113
|
|
|
1113
1114
|
return new Text(text, 0, 0);
|