pi-bro 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,15 +1,91 @@
1
1
  # pi-bro
2
2
 
3
- Simplify Pi's latest assistant response or a local document in a separate
4
- pop-up window without adding extra messages to your conversation context.
3
+ Turn a dense AI reply, pasted text, local document, or public webpage into a
4
+ plain-language explanation without adding anything to your main agent's context.
5
5
 
6
- `pi-bro` is a small extension for
7
- [Earendil Pi](https://github.com/earendil-works/pi). It uses the
6
+ `pi-bro` is an extension for [Earendil Pi](https://github.com/earendil-works/pi).
7
+ It opens explanations in a separate modal and uses the
8
8
  [Google Antigravity CLI](https://antigravity.google/docs/cli-install) (`agy`)
9
- and your selected Agy model to stream plain-language explanations.
9
+ with your selected model.
10
+
11
+ ## Quick start
12
+
13
+ You need Earendil Pi `>=0.78.1 <1`, Node.js `>=22.19.0`, and `agy >=1.1.11`
14
+ installed and available on your `PATH`. Run `agy` once in your terminal to sign
15
+ in, then install Bro:
16
+
17
+ ```sh
18
+ pi install npm:pi-bro
19
+ ```
20
+
21
+ Restart Pi or run `/reload`, then try:
22
+
23
+ ```text
24
+ /bro
25
+ /bro simplify Paste text here
26
+ /bro file docs/report.pdf
27
+ /bro url https://example.com/article
28
+ ```
29
+
30
+ Run `/bro doctor` after installation or whenever Bro is not working.
31
+
32
+ To install from GitHub instead, use
33
+ `pi install git:github.com/tranhoangnguyen03/pi-bro`. To try Bro without
34
+ installing it, use `pi -e npm:pi-bro`.
35
+
36
+ ## What Bro can explain
37
+
38
+ | Source | Command | What Bro does |
39
+ | --- | --- | --- |
40
+ | Latest assistant reply | `/bro` | Explains the latest completed reply without adding the result to the conversation. |
41
+ | Pasted text | `/bro simplify <text>` | Explains text supplied directly in the command. |
42
+ | Local document | `/bro file <path>` | Extracts text from a workspace-local Markdown, text, PDF, or DOCX file. |
43
+ | Public webpage | `/bro url <url>` | Fetches one public HTML page and extracts its main readable content. |
44
+
45
+ Pressing **R** simplifies the captured source again. These commands capture a
46
+ new source: `/bro simplify`, `/bro file`, and `/bro url`.
47
+
48
+ ## Commands
49
+
50
+ | Command | Description |
51
+ | --- | --- |
52
+ | `/bro` or `/bro simplify` | Explain the latest completed assistant response. |
53
+ | `/bro simplify <text>` | Explain pasted text. |
54
+ | `/bro file <path>` | Explain a workspace-local `.md`, `.markdown`, `.txt`, `.pdf`, or `.docx` file. |
55
+ | `/bro url <url>` | Explain one public, text-based webpage. |
56
+ | `/bro open` | Reopen the latest explanation without calling the simplifier again. |
57
+ | `/bro doctor` | Check Bro's settings, Agy installation, account, model, and effort. |
58
+ | `/bro usage [--provider agy]` | Show current Agy resource limits. |
59
+ | `/bro model [id]` | View or choose the Agy model. |
60
+ | `/bro effort [low\|medium\|high]` | View or choose the supported reasoning effort. |
61
+ | `/bro help` | Open the built-in quick reference. |
62
+
63
+ ### Modal controls
64
+
65
+ - **Mouse wheel / trackpad**: Scroll in regular or fullscreen mode
66
+ - **↑ / ↓**: Scroll in any mode
67
+ - **C**: Copy the complete explanation
68
+ - **R**: Simplify the captured source or run the current Doctor check again
69
+ - **Esc**: Close the modal, or cancel while Bro is working
70
+
71
+ Bro temporarily captures mouse input while its modal is open. Native mouse
72
+ selection may be unavailable or visually extend outside the modal depending on
73
+ your terminal mode; press **C** to copy the complete explanation reliably.
10
74
 
11
75
  ## Bro in action
12
76
 
77
+ ### Assistant response
78
+
79
+ **Before `/bro`: the original agent response**
80
+
81
+ [![A dense assistant response before Bro](https://raw.githubusercontent.com/tranhoangnguyen03/pi-bro/main/docs/images/bro-response-before.png)](https://raw.githubusercontent.com/tranhoangnguyen03/pi-bro/main/docs/images/bro-response-before.png)
82
+
83
+ **After `/bro`: the plain-language explanation**
84
+
85
+ [![The assistant response explained in the Bro modal](https://raw.githubusercontent.com/tranhoangnguyen03/pi-bro/main/docs/images/bro-response-after.png)](https://raw.githubusercontent.com/tranhoangnguyen03/pi-bro/main/docs/images/bro-response-after.png)
86
+
87
+ ### Local document
88
+
13
89
  **Before: a complex PDF**
14
90
 
15
91
  [![A complex PDF before Bro](https://raw.githubusercontent.com/tranhoangnguyen03/pi-bro/main/docs/images/bro-file-before.png)](https://raw.githubusercontent.com/tranhoangnguyen03/pi-bro/main/docs/images/bro-file-before.png)
@@ -18,9 +94,9 @@ and your selected Agy model to stream plain-language explanations.
18
94
 
19
95
  [![The PDF explained in the Bro modal](https://raw.githubusercontent.com/tranhoangnguyen03/pi-bro/main/docs/images/bro-file-after.png)](https://raw.githubusercontent.com/tranhoangnguyen03/pi-bro/main/docs/images/bro-file-after.png)
20
96
 
21
- Bro optimizes for understanding, not simply for fewer words. The document and
22
- coding-agent examples below are synthetic and were run through Bro's default
23
- prompt. Click a screenshot to see it at full size.
97
+ Bro optimizes for understanding, not simply for fewer words. The written
98
+ examples below are synthetic and were run through Bro's default prompt. Click
99
+ a screenshot to see it at full size.
24
100
 
25
101
  <details>
26
102
  <summary><strong>Short:</strong> TypeScript says a value is <code>never</code></summary>
@@ -261,65 +337,17 @@ cached files, not your source code or dependencies.
261
337
 
262
338
  </details>
263
339
 
264
- ## Requirements
265
-
266
- - Earendil Pi `>=0.78.1 <1` (tested on `0.84.2`)
267
- - Node.js `>=22.19.0`
268
- - `agy >=1.1.11` installed, authenticated, and on your `PATH` (tested on `1.1.13`)
269
- - Pi's interactive terminal UI
340
+ ## Explain pasted text
270
341
 
271
- Run `agy` once in your terminal to complete sign-in before using Bro.
342
+ Paste text directly after the command:
272
343
 
273
- ## Install
274
-
275
- From npm:
276
-
277
- ```sh
278
- pi install npm:pi-bro
279
- ```
280
-
281
- From GitHub:
282
-
283
- ```sh
284
- pi install git:github.com/tranhoangnguyen03/pi-bro
285
- ```
286
-
287
- Restart Pi or run `/reload`. Run `/bro` after an assistant response, or use
288
- `/bro file <path>` for a document in the current workspace.
289
-
290
- To test Bro without installing it:
291
-
292
- ```sh
293
- pi -e npm:pi-bro
344
+ ```text
345
+ /bro simplify OAuth refresh tokens are rotated after every successful use.
294
346
  ```
295
347
 
296
- ## Commands
297
-
298
- | Command | Description |
299
- | --- | --- |
300
- | `/bro` | Create a new plain-language explanation of the latest completed assistant response. |
301
- | `/bro simplify` | Same as `/bro`. |
302
- | `/bro file <path>` | Explain a workspace-local `.md`, `.markdown`, `.txt`, `.pdf`, or `.docx` file. |
303
- | `/bro open` | Reopen the latest explanation without calling the simplifier again. |
304
- | `/bro doctor` | Check whether Bro, Agy, and the selected settings are ready. |
305
- | `/bro usage` | Show current Agy resource limits. |
306
- | `/bro usage --provider agy` | Same as `/bro usage`, with the provider stated explicitly. |
307
- | `/bro model` | Choose from the models currently available through Agy. |
308
- | `/bro model <id>` | Set an available Agy model directly. |
309
- | `/bro effort` | Choose an effort supported by the current model. |
310
- | `/bro effort <low\|medium\|high>` | Set a supported reasoning effort directly. |
311
- | `/bro help` | Open the built-in guide. |
312
-
313
- ### Modal controls
314
-
315
- - **Mouse wheel / trackpad**: Scroll in Pi's fullscreen mode
316
- - **↑ / ↓**: Scroll up or down
317
- - **C**: Copy the full explanation to your clipboard
318
- - **R**: Run the current simplification or Doctor check again
319
- - **Esc**: Close the window, or cancel while Bro is running
320
-
321
- In Pi's regular terminal mode, the Bro title warns that mouse-wheel scrolling
322
- needs fullscreen mode. Arrow-key scrolling still works.
348
+ Bro explains the pasted text instead of the latest assistant reply. With no text
349
+ after `/bro simplify`, it falls back to the latest completed reply. Press **R**
350
+ to simplify the same captured text again.
323
351
 
324
352
  ## Explain a document
325
353
 
@@ -337,6 +365,27 @@ new `/bro file <path>` command reads the file again.
337
365
  Files are limited to 10 MiB and 100,000 extracted characters. Scanned PDFs are
338
366
  not supported because Bro does not perform OCR.
339
367
 
368
+ ## Explain a webpage
369
+
370
+ Pass one public HTTP or HTTPS page:
371
+
372
+ ```text
373
+ /bro url https://example.com/complicated-article
374
+ ```
375
+
376
+ Bro fetches the page, extracts its main readable text locally, and sends only
377
+ that text through the existing explanation flow. The completed modal shows the
378
+ final website and page title. Pressing **R** retries the captured page without
379
+ fetching again; running a new `/bro url <url>` command fetches a fresh copy.
380
+
381
+ The first version is intentionally limited to one public, text-based page. It
382
+ does not use browser cookies, sign in, run page JavaScript, bypass paywalls or
383
+ bot protection, load complete discussion threads, follow pagination, or
384
+ understand images and video. Pages that depend on those features may fail.
385
+
386
+ If Bro cannot read a page, copy its content into a `.txt` or `.md` file, or save
387
+ it as a PDF, then use `/bro file <path>`.
388
+
340
389
  ## Check your setup
341
390
 
342
391
  Run `/bro doctor` when Bro is newly installed or something is not working. It
@@ -399,10 +448,11 @@ Text to explain:
399
448
  Bro re-reads this file every time you simplify, so your edits take effect
400
449
  immediately without reloading Pi. Bro never creates or modifies this file.
401
450
 
402
- ## Privacy and files
451
+ ## Privacy and safety
403
452
 
404
- - **External requests**: Bro sends the latest completed assistant response or
405
- extracted document text to Agy and its configured model provider.
453
+ - **External requests**: Bro sends the latest completed assistant response,
454
+ pasted text, extracted document text, or extracted webpage text to Agy and its
455
+ configured model provider.
406
456
  - **Usage checks**: `/bro usage` checks your authenticated Agy limits without
407
457
  sending an assistant response or running a model turn.
408
458
  - **Setup checks**: `/bro doctor` checks Agy account and model availability
@@ -417,24 +467,42 @@ immediately without reloading Pi. Bro never creates or modifies this file.
417
467
  not modify them. It runs Agy in sandbox mode inside a temporary empty folder.
418
468
  This reduces project access, but it is not a security boundary. Bro only
419
469
  writes its own user settings file described above.
470
+ - **Web requests**: `/bro url` connects directly to the target website. The site
471
+ sees your IP address and Bro's user agent. Bro sends no browser cookies,
472
+ authorization, or referrer information, and it refuses local, private, and
473
+ reserved network destinations, including redirects. Avoid private or signed
474
+ URLs whose query string contains secrets.
475
+ - **Web extraction**: Bro parses downloaded HTML locally without executing page
476
+ scripts or loading page subresources. It sends the extracted readable text,
477
+ including links preserved in that text, to Agy; it does not separately send
478
+ the requested URL or raw page HTML. The URL, captured text, and explanation
479
+ remain in process memory only and clear with the existing `/bro open` cache.
420
480
  - **Provider data**: Agy and your model provider may retain logs and request data
421
481
  according to their own settings and privacy policies.
422
482
  - **Clipboard**: Pressing **C** copies the text to your system clipboard, where
423
483
  your operating system or clipboard manager may retain it.
424
484
 
425
- ## Current limits
485
+ ## Troubleshooting and current limits
486
+
487
+ If an explanation fails, run `/bro doctor` first. If a webpage cannot be
488
+ extracted, copy its content into a supported text file or save it as a PDF and
489
+ use `/bro file`. If a PDF contains only scanned images, run OCR with another
490
+ tool before giving it to Bro.
426
491
 
427
492
  - Uses Agy as its only provider.
428
493
  - Document input supports `.md`, `.markdown`, `.txt`, `.pdf`, and `.docx` only;
429
494
  it does not perform OCR.
495
+ - Webpage input supports one public HTML page, up to 5 MiB downloaded and
496
+ 100,000 extracted characters. JavaScript-only, authenticated, paywalled,
497
+ blocked, paginated, and media-first pages are not supported.
498
+ - Direct webpage fetching does not currently use `HTTP_PROXY`, `HTTPS_PROXY`,
499
+ or other proxy environment variables.
430
500
  - Keeps only the latest explanation in memory.
431
501
  - Does not store history or export directly to files.
432
- - Mouse-wheel and trackpad scrolling work in Pi's fullscreen mode
433
- (`pi --tui-mode fullscreen`). In regular mode, Bro shows a warning in its
434
- title; use the arrow keys so Bro does not interfere with your terminal's
435
- native text selection.
436
- - In fullscreen mode, mouse text selection may visually extend outside the Bro
437
- window. Press **C** to copy the full explanation instead.
502
+ - Bro temporarily captures mouse input while its modal is open so mouse-wheel
503
+ and trackpad scrolling work in regular and fullscreen modes. Native mouse
504
+ selection may be unavailable or visually extend outside the Bro window;
505
+ press **C** to copy the full explanation instead.
438
506
 
439
507
  ## Development
440
508
 
@@ -445,8 +513,9 @@ pi --tui-mode fullscreen -e ./bro.ts
445
513
  ```
446
514
 
447
515
  The smoke test uses a fake `agy`, so it does not call an external model. It
448
- verifies command routing, document boundaries, healthy and broken setup
449
- handling, settings, custom prompt handling, and context isolation.
516
+ verifies command routing, document and URL safety boundaries, HTML extraction,
517
+ healthy and broken setup handling, settings, custom prompt handling, and
518
+ context isolation.
450
519
 
451
520
  ## License
452
521
 
@@ -25,3 +25,16 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
25
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
26
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
27
  SOFTWARE.
28
+
29
+ ## Defuddle
30
+
31
+ Webpage content extraction uses
32
+ [Defuddle](https://github.com/kepano/defuddle), copyright (c) 2025 Steph Ango
33
+ (@kepano), licensed under the MIT License. Its complete license is distributed
34
+ with the Defuddle npm package.
35
+
36
+ ## LinkeDOM
37
+
38
+ HTML parsing uses [LinkeDOM](https://github.com/WebReflection/linkedom),
39
+ copyright (c) 2021 Andrea Giammarchi (@WebReflection), licensed under the ISC
40
+ License. Its complete license is distributed with the LinkeDOM npm package.
package/bro.ts CHANGED
@@ -1,11 +1,18 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { lookup } from "node:dns/promises";
2
3
  import { mkdir, mkdtemp, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
4
+ import { request as httpRequest, type IncomingMessage } from "node:http";
5
+ import { request as httpsRequest } from "node:https";
6
+ import { BlockList, isIP } from "node:net";
3
7
  import { homedir, tmpdir } from "node:os";
4
8
  import { extname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
9
  import { createInterface } from "node:readline";
10
+ import { stripVTControlCharacters } from "node:util";
6
11
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
7
12
  import { copyToClipboard, getMarkdownTheme } from "@earendil-works/pi-coding-agent";
8
13
  import { Markdown, matchesKey, truncateToWidth, visibleWidth, type Focusable } from "@earendil-works/pi-tui";
14
+ import { Defuddle } from "defuddle/node";
15
+ import { parseHTML } from "linkedom";
9
16
  import mammoth from "mammoth";
10
17
  import { extractText } from "unpdf";
11
18
 
@@ -16,8 +23,13 @@ const PROMPT_FILE = join(AGENT_DIR, "bro-prompt.md");
16
23
  const SETTINGS_FILE = join(AGENT_DIR, "bro-settings.json");
17
24
  const LOADING_TEXT = "Simplifying for my bro…";
18
25
  const MAX_FILE_BYTES = 10 * 1024 * 1024;
26
+ const MAX_WEB_BYTES = 5 * 1024 * 1024;
27
+ const MAX_WEB_ELEMENTS = 100_000;
28
+ const MAX_WEB_REDIRECTS = 5;
29
+ const WEB_TIMEOUT_MS = 25_000;
19
30
  const MAX_TEXT_LENGTH = 100_000;
20
31
  const TEXT_EXTENSIONS = new Set([".md", ".markdown", ".txt"]);
32
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
21
33
  const DEFAULT_TEMPLATE = `Rewrite the quoted text for a non-expert.
22
34
  Use plain English and short sentences. Explain jargon briefly.
23
35
  Use at most 400 words. Focus on the main point, what it means, and what the reader should know or do next.
@@ -29,9 +41,13 @@ Quoted text as a JSON string:
29
41
  {{response}}`;
30
42
 
31
43
  type Theme = ExtensionCommandContext["ui"]["theme"];
32
- type TuiLike = { readonly mode: "regular" | "fullscreen"; requestRender(): void };
44
+ type TuiLike = {
45
+ readonly mode: "regular" | "fullscreen";
46
+ readonly terminal?: { write?: (data: string) => void };
47
+ requestRender(): void;
48
+ };
33
49
  type ModalKind = "loading" | "streaming" | "result" | "help" | "empty" | "error";
34
- type BroSource = { text: string };
50
+ type BroSource = { text: string; label?: string };
35
51
  type BroResult = { source: BroSource; text: string };
36
52
  type ModalResult = { source?: BroSource; text: string };
37
53
  const EFFORTS = ["default", "low", "medium", "high"] as const;
@@ -58,9 +74,14 @@ export function wheelDelta(data: string): number {
58
74
  return (button & 3) === 0 ? -3 : (button & 3) === 1 ? 3 : 0;
59
75
  }
60
76
 
77
+ export function setRegularMouseReporting(tui: Pick<TuiLike, "mode" | "terminal">, enabled: boolean): void {
78
+ if (tui.mode === "regular") tui.terminal?.write?.(`\x1b[?1000${enabled ? "h" : "l"}\x1b[?1006${enabled ? "h" : "l"}`);
79
+ }
80
+
61
81
  const COMMANDS = [
62
- { value: "simplify", label: "simplify", description: "Simplify the latest assistant response" },
82
+ { value: "simplify", label: "simplify", description: "Simplify pasted text or the latest assistant response" },
63
83
  { value: "file", label: "file", description: "Explain a local document" },
84
+ { value: "url", label: "url", description: "Explain a public webpage" },
64
85
  { value: "open", label: "open", description: "Reopen the last explanation" },
65
86
  { value: "doctor", label: "doctor", description: "Check whether Bro is ready" },
66
87
  { value: "usage", label: "usage", description: "Show current Agy usage" },
@@ -156,6 +177,263 @@ export async function extractDocumentText(input: string, cwd: string, signal?: A
156
177
  return text;
157
178
  }
158
179
 
180
+ const NON_PUBLIC_ADDRESSES = new BlockList();
181
+ for (const [network, prefix] of [
182
+ ["0.0.0.0", 8],
183
+ ["10.0.0.0", 8],
184
+ ["100.64.0.0", 10],
185
+ ["127.0.0.0", 8],
186
+ ["169.254.0.0", 16],
187
+ ["172.16.0.0", 12],
188
+ ["192.0.0.0", 24],
189
+ ["192.0.2.0", 24],
190
+ ["192.31.196.0", 24],
191
+ ["192.52.193.0", 24],
192
+ ["192.88.99.0", 24],
193
+ ["192.168.0.0", 16],
194
+ ["192.175.48.0", 24],
195
+ ["198.18.0.0", 15],
196
+ ["198.51.100.0", 24],
197
+ ["203.0.113.0", 24],
198
+ ["224.0.0.0", 4],
199
+ ["240.0.0.0", 4],
200
+ ] as const) {
201
+ NON_PUBLIC_ADDRESSES.addSubnet(network, prefix, "ipv4");
202
+ }
203
+ for (const [network, prefix] of [
204
+ ["::", 128],
205
+ ["::1", 128],
206
+ ["64:ff9b::", 96],
207
+ ["64:ff9b:1::", 48],
208
+ ["100::", 64],
209
+ ["2001::", 23],
210
+ ["2001:db8::", 32],
211
+ ["2002::", 16],
212
+ ["3fff::", 20],
213
+ ["5f00::", 16],
214
+ ["fc00::", 7],
215
+ ["fe80::", 10],
216
+ ["ff00::", 8],
217
+ ] as const) {
218
+ NON_PUBLIC_ADDRESSES.addSubnet(network, prefix, "ipv6");
219
+ }
220
+
221
+ export function isPublicWebAddress(address: string): boolean {
222
+ const family = isIP(address);
223
+ return family === 4
224
+ ? !NON_PUBLIC_ADDRESSES.check(address, "ipv4")
225
+ : family === 6
226
+ ? !NON_PUBLIC_ADDRESSES.check(address, "ipv6")
227
+ : false;
228
+ }
229
+
230
+ export function parseWebUrl(input: string): URL {
231
+ const requested = unquote(input.trim());
232
+ if (!requested) throw new Error("Use /bro url <url>.");
233
+
234
+ let url: URL;
235
+ try {
236
+ url = new URL(requested);
237
+ } catch {
238
+ throw new Error("That is not a valid URL. Use /bro url https://example.com/article.");
239
+ }
240
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
241
+ throw new Error("Bro can read only public HTTP or HTTPS webpages.");
242
+ }
243
+ if (url.username || url.password) {
244
+ throw new Error("Bro does not accept URLs containing usernames or passwords.");
245
+ }
246
+ url.hash = "";
247
+ return url;
248
+ }
249
+
250
+ export function parseWebRedirect(current: URL, location: string): URL {
251
+ const next = parseWebUrl(new URL(location, current).href);
252
+ if (current.protocol === "https:" && next.protocol !== "https:") {
253
+ throw new Error("Bro refused an insecure HTTPS-to-HTTP redirect.");
254
+ }
255
+ return next;
256
+ }
257
+
258
+ function headerValue(value: string | string[] | undefined): string {
259
+ return Array.isArray(value) ? value[0] ?? "" : value ?? "";
260
+ }
261
+
262
+ async function resolvePublicAddress(hostname: string): Promise<{ address: string; family: 4 | 6 }> {
263
+ const host = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
264
+ let addresses: Array<{ address: string; family: number }>;
265
+ try {
266
+ addresses = await lookup(host, { all: true, verbatim: true });
267
+ } catch (error) {
268
+ throw new Error(`Could not resolve webpage host: ${errorMessage(error)}`);
269
+ }
270
+ if (!addresses.length) throw new Error("The webpage host has no network address.");
271
+ if (addresses.some((item) => !isPublicWebAddress(item.address))) {
272
+ throw new Error("Bro cannot connect to local, private, or reserved network addresses.");
273
+ }
274
+ return { address: addresses[0].address, family: addresses[0].family === 6 ? 6 : 4 };
275
+ }
276
+
277
+ function requestWebPage(url: URL, address: { address: string; family: 4 | 6 }, signal: AbortSignal): Promise<IncomingMessage> {
278
+ return new Promise((resolveResponse, rejectResponse) => {
279
+ const request = (url.protocol === "https:" ? httpsRequest : httpRequest)(
280
+ url,
281
+ {
282
+ method: "GET",
283
+ signal,
284
+ headers: {
285
+ Accept: "text/html,application/xhtml+xml",
286
+ "Accept-Encoding": "identity",
287
+ "User-Agent": "pi-bro URL reader (+https://github.com/tranhoangnguyen03/pi-bro)",
288
+ },
289
+ lookup: (_hostname, options, callback) => {
290
+ if (options.all) callback(null, [address]);
291
+ else callback(null, address.address, address.family);
292
+ },
293
+ },
294
+ resolveResponse,
295
+ );
296
+ request.once("error", rejectResponse);
297
+ request.end();
298
+ });
299
+ }
300
+
301
+ async function readWebBody(response: IncomingMessage): Promise<Buffer> {
302
+ const contentEncoding = headerValue(response.headers["content-encoding"]).trim().toLowerCase();
303
+ if (contentEncoding && contentEncoding !== "identity") {
304
+ response.destroy();
305
+ throw new Error(`Bro cannot read this page's ${contentEncoding} response encoding.`);
306
+ }
307
+
308
+ const contentLength = Number.parseInt(headerValue(response.headers["content-length"]), 10);
309
+ if (Number.isFinite(contentLength) && contentLength > MAX_WEB_BYTES) {
310
+ response.destroy();
311
+ throw new Error("Webpage is larger than Bro's 5 MiB download limit.");
312
+ }
313
+
314
+ const chunks: Buffer[] = [];
315
+ let size = 0;
316
+ try {
317
+ for await (const chunk of response) {
318
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
319
+ size += buffer.byteLength;
320
+ if (size > MAX_WEB_BYTES) throw new Error("Webpage is larger than Bro's 5 MiB download limit.");
321
+ chunks.push(buffer);
322
+ }
323
+ } catch (error) {
324
+ response.destroy();
325
+ throw error;
326
+ }
327
+ return Buffer.concat(chunks, size);
328
+ }
329
+
330
+ function decodeWebHtml(buffer: Buffer, contentType: string): string {
331
+ const headerCharset = /charset\s*=\s*["']?([^\s;"']+)/i.exec(contentType)?.[1];
332
+ const head = new TextDecoder("latin1").decode(buffer.subarray(0, 2048));
333
+ const metaCharset = /<meta[^>]+charset\s*=\s*["']?([^\s;"'>]+)/i.exec(head)?.[1]
334
+ ?? /<meta[^>]+content\s*=\s*["'][^"']*charset=([^\s;"']+)/i.exec(head)?.[1];
335
+ const charset = headerCharset ?? metaCharset ?? "utf-8";
336
+ try {
337
+ return new TextDecoder(charset).decode(buffer);
338
+ } catch {
339
+ throw new Error(`Bro does not support this page's ${charset} character encoding.`);
340
+ }
341
+ }
342
+
343
+ function assertWebElementLimit(html: string): void {
344
+ let count = 0;
345
+ for (let index = 0; index < html.length - 1; index++) {
346
+ if (html.charCodeAt(index) !== 60) continue;
347
+ const next = html.charCodeAt(index + 1) | 32;
348
+ if (next >= 97 && next <= 122 && ++count > MAX_WEB_ELEMENTS) {
349
+ throw new Error("Webpage is too complex for Bro to read safely.");
350
+ }
351
+ }
352
+ }
353
+
354
+ async function fetchPublicHtml(startUrl: URL, signal: AbortSignal): Promise<{ html: string; url: URL }> {
355
+ let url = startUrl;
356
+ const visited = new Set<string>();
357
+
358
+ for (let redirects = 0; ; redirects++) {
359
+ if (visited.has(url.href)) throw new Error("Webpage redirect loop detected.");
360
+ visited.add(url.href);
361
+ const address = await resolvePublicAddress(url.hostname);
362
+ let response: IncomingMessage;
363
+ try {
364
+ response = await requestWebPage(url, address, signal);
365
+ } catch (error) {
366
+ throw new Error(`Could not fetch webpage: ${errorMessage(error)}`);
367
+ }
368
+ const status = response.statusCode ?? 0;
369
+
370
+ if (REDIRECT_STATUSES.has(status)) {
371
+ response.destroy();
372
+ if (redirects >= MAX_WEB_REDIRECTS) throw new Error("Webpage redirected too many times.");
373
+ const location = headerValue(response.headers.location);
374
+ if (!location) throw new Error(`Webpage returned HTTP ${status} without a redirect location.`);
375
+ url = parseWebRedirect(url, location);
376
+ continue;
377
+ }
378
+
379
+ if (status < 200 || status >= 300) {
380
+ response.destroy();
381
+ if (status === 401 || status === 403) {
382
+ throw new Error(`Webpage returned HTTP ${status}. It may require a login or block automated readers.`);
383
+ }
384
+ if (status === 429) throw new Error("Webpage returned HTTP 429 and is limiting automated requests.");
385
+ throw new Error(`Webpage returned HTTP ${status}.`);
386
+ }
387
+
388
+ const contentType = headerValue(response.headers["content-type"]);
389
+ const mime = contentType.split(";", 1)[0].trim().toLowerCase();
390
+ if (mime !== "text/html" && mime !== "application/xhtml+xml") {
391
+ response.destroy();
392
+ throw new Error(`Unsupported webpage content type: ${mime || "missing"}.`);
393
+ }
394
+
395
+ const html = decodeWebHtml(await readWebBody(response), contentType);
396
+ assertWebElementLimit(html);
397
+ return { html, url };
398
+ }
399
+ }
400
+
401
+ export async function extractWebHtml(html: string, url: string): Promise<BroSource> {
402
+ assertWebElementLimit(html);
403
+ const parsedUrl = parseWebUrl(url);
404
+ const { document } = parseHTML(html);
405
+ const result = await Defuddle(document, parsedUrl.href, {
406
+ markdown: true,
407
+ removeImages: true,
408
+ includeReplies: false,
409
+ useAsync: false,
410
+ });
411
+ const text = (result.contentMarkdown || result.content || "").trim();
412
+ if (!text) {
413
+ throw new Error("Bro found no readable page content. The page may require JavaScript, a login, or block automated readers.");
414
+ }
415
+ if (text.length > MAX_TEXT_LENGTH) {
416
+ throw new Error("Extracted webpage text is longer than Bro's 100,000-character limit.");
417
+ }
418
+ const title = result.title
419
+ ? stripVTControlCharacters(result.title).replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").replace(/\s+/g, " ").trim().slice(0, 200)
420
+ : undefined;
421
+ return { text, label: [parsedUrl.hostname, title].filter(Boolean).join(" · ") };
422
+ }
423
+
424
+ export async function extractWebPage(input: string, signal?: AbortSignal): Promise<BroSource> {
425
+ const timeout = AbortSignal.timeout(WEB_TIMEOUT_MS);
426
+ const combinedSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
427
+ try {
428
+ const fetched = await fetchPublicHtml(parseWebUrl(input), combinedSignal);
429
+ return await extractWebHtml(fetched.html, fetched.url.href);
430
+ } catch (error) {
431
+ if (signal?.aborted) throw new Error("Canceled.");
432
+ if (timeout.aborted) throw new Error("Webpage took longer than 25 seconds to respond.");
433
+ throw error;
434
+ }
435
+ }
436
+
159
437
  export function agyFailureMessage(
160
438
  action: string,
161
439
  result: { code: number; killed: boolean; stderr: string },
@@ -586,72 +864,60 @@ function helpText(settings?: BroSettings, settingsError?: string): string {
586
864
  : `Bro could not read its settings: ${settingsError}\n\nRun \`/bro doctor\` for setup help.`;
587
865
  return `# Bro
588
866
 
589
- Bro turns the latest completed assistant response or a local document into a clear, plain-language explanation.
867
+ Bro explains a dense assistant reply, pasted text, local document, or public webpage in plain language without adding the explanation to Pi's conversation.
590
868
 
591
- ## Commands
869
+ ## Explain
592
870
 
593
- - \`/bro\` or \`/bro simplify\` create a new explanation
871
+ - \`/bro\` explain the latest completed assistant reply
872
+ - \`/bro simplify [text]\` — explain pasted text, or the latest reply when text is omitted
594
873
  - \`/bro file <path>\` — explain a Markdown, text, PDF, or DOCX file
595
- - \`/bro open\`reopen the last explanation
596
- - \`/bro doctor\` — check whether Bro is ready
597
- - \`/bro usage\` or \`/bro usage --provider agy\` — show current Agy usage
598
- - \`/bro model\` — choose the Agy model
599
- - \`/bro effort\` — choose the Agy reasoning effort
600
- - \`/bro help\` — show this guide
874
+ - \`/bro url <url>\` explain one public webpage
875
+ - \`/bro open\` — reopen the latest explanation
601
876
 
602
- ## Documents
877
+ Press **R** to simplify the captured source again. Run a new \`/bro simplify\`, \`/bro file\`, or \`/bro url\` command to capture a new source.
603
878
 
604
- \`/bro file\` accepts \`.md\`, \`.markdown\`, \`.txt\`, \`.pdf\`, and \`.docx\` files. Use a relative path or an absolute path inside the current workspace. Paths may contain spaces; matching single or double quotes are optional.
879
+ ## Check and configure
605
880
 
606
- Files are limited to 10 MiB and 100,000 extracted characters. Scanned PDFs need OCR, which Bro does not support. Pressing **R** retries the extracted snapshot; a new \`/bro file <path>\` command reads the file again.
881
+ - \`/bro doctor\` check settings, Agy, account, model, and effort
882
+ - \`/bro usage [--provider agy]\` — show current Agy limits
883
+ - \`/bro model [id]\` — view or choose the Agy model
884
+ - \`/bro effort [low|medium|high]\` — view or choose reasoning effort
607
885
 
608
- ## Current simplifier settings
886
+ ## Current settings
609
887
 
610
888
  ${settingsSummary}
611
889
 
612
- These choices are saved in:
613
-
614
- \`${SETTINGS_FILE}\`
615
-
616
- Use the slash commands or edit that file directly. Changes apply to future explanations and remain active across Pi restarts until you change them. Use a model ID shown by \`/bro model\`. Use an effort shown by \`/bro effort\`; fixed-effort models use \`default\`.
890
+ Saved in \`${SETTINGS_FILE}\`. Use the commands above or edit the file directly. Changes apply to future explanations.
617
891
 
618
892
  ## Controls
619
893
 
620
- - **Mouse wheel / trackpad** — scroll in Pi's fullscreen mode
621
- - **↑ / ↓** — scroll in any mode
894
+ - **Mouse wheel / trackpad** — scroll
895
+ - **↑ / ↓** — scroll
622
896
  - **C** — copy the full explanation
623
- - **R** — repeat the current simplification or Doctor check
624
- - **Esc** — close the window, or cancel while Bro is working
897
+ - **R** — repeat the current action
898
+ - **Esc** — close, or cancel while Bro is working
625
899
 
626
- In Pi's regular terminal mode, the Bro title warns that mouse-wheel scrolling needs fullscreen mode. Arrow-key scrolling still works.
900
+ Bro temporarily captures mouse input while the modal is open. Native mouse selection may be unavailable or extend outside the modal; press **C** to copy everything reliably.
627
901
 
628
- Mouse text selection may extend outside the Bro window. Press **C** to copy the complete explanation instead.
902
+ ## Important limits
629
903
 
630
- ## Privacy and file safety
904
+ - Documents must be inside the current workspace, are limited to 10 MiB and 100,000 extracted characters, and must be \`.md\`, \`.markdown\`, \`.txt\`, \`.pdf\`, or \`.docx\`. Scanned PDFs need OCR first.
905
+ - Web input is limited to one public HTML page. Bro cannot sign in, run page JavaScript, bypass paywalls or blocks, follow pagination, or understand images and video.
906
+ - If a webpage fails, copy it into a text file or save it as a PDF, then use \`/bro file\`.
631
907
 
632
- Bro does not modify your project files. It creates and updates only its user settings file shown above. It runs the simplifier in sandbox mode inside a temporary empty folder. This reduces project access, but it is not a security boundary.
908
+ ## Privacy and safety
633
909
 
634
- Bro does not add explanations to Pi's conversation history, session files, or main-agent context. The latest explanation is kept in process memory only so \`/bro open\` can reopen it. It is cleared when you change sessions, reload extensions, or exit Pi.
910
+ Bro sends the selected assistant reply, pasted text, or locally extracted document or webpage text to Agy and your model provider. They may retain request data under their own policies.
635
911
 
636
- Bro sends the assistant response to an external simplifier (currently Agy with your selected model). Agy and the model provider may retain request data or logs under their own policies.
912
+ Bro never adds the explanation to Pi's conversation, session file, or main-agent context. The captured source and latest explanation stay in process memory until you change sessions, reload extensions, or exit Pi.
637
913
 
638
- \`/bro file\` sends the selected document's extracted text to the same external simplifier. Bro reads only regular files whose resolved path remains inside the current workspace, including after resolving symlinks. It never modifies them and does not expose the workspace to Agy.
914
+ Bro does not modify project files. For webpages, it connects directly to the site without browser cookies; the site sees your IP address and Bro's user agent. Do not use private or signed URLs.
639
915
 
640
- \`/bro usage\` checks your authenticated Agy limits without sending an assistant response or running a model turn.
641
-
642
- \`/bro doctor\` checks your settings, prompt, Agy installation, account, model, and reasoning effort. It contacts Agy but does not send an assistant response or run a model turn.
643
-
644
- Pressing **C** copies the explanation to your system clipboard, where your operating system or clipboard manager may retain it.
916
+ Usage and Doctor checks contact Agy but do not send source text or run a model turn. Pressing **C** sends the explanation to your system clipboard.
645
917
 
646
918
  ## Custom prompt
647
919
 
648
- You can create or edit:
649
-
650
- \`${PROMPT_FILE}\`
651
-
652
- Bro reads this file when running but never creates or edits it. Include \`{{response}}\` exactly once in your template. Changes take effect on the next simplification.
653
-
654
- When the settings file does not exist yet, \`PI_BRO_MODEL\` can choose its initial model.`;
920
+ Create or edit \`${PROMPT_FILE}\` and include \`{{response}}\` exactly once. Bro reads it on the next explanation and never modifies it.`;
655
921
  }
656
922
 
657
923
  // The overlay framing pattern is adapted from pi-btw (MIT); see THIRD_PARTY_NOTICES.md.
@@ -660,6 +926,7 @@ class BroModal implements Focusable {
660
926
  private readonly markdown = new Markdown("", 0, 0, getMarkdownTheme());
661
927
  private kind: ModalKind = "loading";
662
928
  private rawText = "";
929
+ private sourceLabel = "";
663
930
  private notice = "";
664
931
  private offset = 0;
665
932
  private maxOffset = 0;
@@ -675,7 +942,9 @@ class BroModal implements Focusable {
675
942
  private readonly onRetry: () => void,
676
943
  private readonly onDispose: () => void,
677
944
  private readonly retryLabel: string,
678
- ) {}
945
+ ) {
946
+ setRegularMouseReporting(this.tui, true);
947
+ }
679
948
 
680
949
  setLoading(text = LOADING_TEXT): void {
681
950
  this.setContent("loading", `**${text}**`, "", false, false);
@@ -685,8 +954,8 @@ class BroModal implements Focusable {
685
954
  this.setContent("streaming", text, "", false, false);
686
955
  }
687
956
 
688
- setResult(text: string, retryable: boolean, notice = ""): void {
689
- this.setContent("result", text, text, true, retryable, notice);
957
+ setResult(text: string, retryable: boolean, notice = "", sourceLabel = ""): void {
958
+ this.setContent("result", text, text, true, retryable, notice, sourceLabel);
690
959
  }
691
960
 
692
961
  setStatic(kind: "help" | "empty", text: string, copyable: boolean): void {
@@ -704,12 +973,17 @@ class BroModal implements Focusable {
704
973
  copyable: boolean,
705
974
  retryable: boolean,
706
975
  notice = "",
976
+ sourceLabel = "",
707
977
  ): void {
708
978
  this.kind = kind;
709
979
  this.rawText = rawText;
710
980
  this.copyable = copyable;
711
981
  this.retryable = retryable;
712
982
  this.notice = notice;
983
+ this.sourceLabel = stripVTControlCharacters(sourceLabel)
984
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
985
+ .replace(/\s+/g, " ")
986
+ .trim();
713
987
  if (kind !== "streaming") this.offset = 0;
714
988
  this.markdown.setText(text);
715
989
  this.tui.requestRender();
@@ -754,13 +1028,12 @@ class BroModal implements Focusable {
754
1028
  this.offset = Math.max(0, Math.min(this.offset, this.maxOffset));
755
1029
  const visible = rendered.slice(this.offset, this.offset + this.bodyHeight);
756
1030
  const hiddenBelow = Math.max(0, this.maxOffset - this.offset);
757
- const modeHint = this.tui.mode === "regular" ? " · mouse wheel needs fullscreen" : "";
758
1031
  const scroll = this.maxOffset > 0 ? ` · ↑${this.offset} ↓${hiddenBelow}` : "";
759
1032
  const controls = this.notice ? `${this.notice} · ${this.controls()}` : this.controls();
760
1033
 
761
1034
  const lines = [
762
1035
  this.borderLine(innerWidth, "top"),
763
- this.frameLine(this.theme.fg("accent", this.theme.bold(`Bro${modeHint}${scroll}`)), innerWidth),
1036
+ this.frameLine(this.theme.fg("accent", this.theme.bold(`Bro${this.sourceLabel ? ` · ${this.sourceLabel}` : ""}${scroll}`)), innerWidth),
764
1037
  this.ruleLine(innerWidth),
765
1038
  ];
766
1039
 
@@ -820,6 +1093,7 @@ class BroModal implements Focusable {
820
1093
  dispose(): void {
821
1094
  if (this.disposed) return;
822
1095
  this.disposed = true;
1096
+ setRegularMouseReporting(this.tui, false);
823
1097
  this.onDispose();
824
1098
  }
825
1099
  }
@@ -891,14 +1165,14 @@ async function showBroModal(ctx: ExtensionCommandContext, options: BroModalOptio
891
1165
  if (closed || nextController.signal.aborted) return;
892
1166
  current = result;
893
1167
  options.onResult?.(result);
894
- modal.setResult(result.text, options.retryable ?? true);
1168
+ modal.setResult(result.text, options.retryable ?? true, "", result.source?.label);
895
1169
  })
896
1170
  .catch((error) => {
897
1171
  if (closed || nextController.signal.aborted) return;
898
1172
  const message = error instanceof Error ? error.message : String(error);
899
1173
  if (previous) {
900
1174
  current = previous;
901
- modal.setResult(previous.text, options.retryable ?? true, `Retry failed: ${message}`);
1175
+ modal.setResult(previous.text, options.retryable ?? true, `Retry failed: ${message}`, previous.source?.label);
902
1176
  } else {
903
1177
  modal.setError(message);
904
1178
  }
@@ -911,7 +1185,7 @@ async function showBroModal(ctx: ExtensionCommandContext, options: BroModalOptio
911
1185
  if (options.text !== undefined) {
912
1186
  modal.setStatic(options.kind ?? "help", options.text, options.copyable ?? false);
913
1187
  } else if (current) {
914
- modal.setResult(current.text, options.retryable ?? Boolean(options.run));
1188
+ modal.setResult(current.text, options.retryable ?? Boolean(options.run), "", current.source?.label);
915
1189
  } else {
916
1190
  execute();
917
1191
  }
@@ -942,7 +1216,7 @@ export default async function bro(pi: ExtensionAPI) {
942
1216
  });
943
1217
 
944
1218
  pi.registerCommand("bro", {
945
- description: "Simplify responses and manage Bro",
1219
+ description: "Explain pasted text, replies, documents, and webpages",
946
1220
  getArgumentCompletions: (prefix) => {
947
1221
  const normalized = prefix.trim().toLowerCase();
948
1222
  const matches = COMMANDS.filter((command) => command.value.startsWith(normalized));
@@ -955,17 +1229,19 @@ export default async function bro(pi: ExtensionAPI) {
955
1229
  const action = parts[0] ?? "";
956
1230
  const value = raw.slice(raw.split(/\s+/, 1)[0]?.length ?? 0).trim();
957
1231
 
958
- if (action === "file") {
1232
+ if (action === "file" || action === "url") {
959
1233
  if (!value) {
960
- ctx.ui.notify("Use /bro file <path>.", "warning");
1234
+ ctx.ui.notify(`Use /bro ${action} <${action === "file" ? "path" : "url"}>.`, "warning");
961
1235
  return;
962
1236
  }
963
- const runFile = async (
1237
+ const runInput = async (
964
1238
  signal: AbortSignal,
965
1239
  source?: BroSource,
966
1240
  onProgress?: (text: string) => void,
967
1241
  ): Promise<BroResult> => {
968
- const target = source ?? { text: await extractDocumentText(value, ctx.cwd, signal) };
1242
+ const target = source ?? (action === "url"
1243
+ ? await extractWebPage(value, signal)
1244
+ : { text: await extractDocumentText(value, ctx.cwd, signal), label: unquote(value) });
969
1245
  try {
970
1246
  return {
971
1247
  source: target,
@@ -977,8 +1253,8 @@ export default async function bro(pi: ExtensionAPI) {
977
1253
  };
978
1254
  try {
979
1255
  await showBroModal(ctx, {
980
- loadingText: "Reading and simplifying document…",
981
- run: runFile,
1256
+ loadingText: action === "url" ? "Fetching and simplifying webpage…" : "Reading and simplifying document…",
1257
+ run: runInput,
982
1258
  onResult: remember,
983
1259
  });
984
1260
  } catch (error) {
@@ -1151,7 +1427,7 @@ export default async function bro(pi: ExtensionAPI) {
1151
1427
  source?: BroSource,
1152
1428
  onProgress?: (text: string) => void,
1153
1429
  ): Promise<BroResult> => {
1154
- let target = source;
1430
+ let target = source ?? (action === "simplify" && value ? { text: value } : undefined);
1155
1431
  if (!target) {
1156
1432
  await ctx.waitForIdle();
1157
1433
  target = latestAssistant(ctx);
@@ -1171,7 +1447,7 @@ export default async function bro(pi: ExtensionAPI) {
1171
1447
  if (normalized === "open") {
1172
1448
  if (!lastResult) {
1173
1449
  await showBroModal(ctx, {
1174
- text: "# Nothing to open yet\n\nRun `/bro` after an assistant response, or use `/bro file <path>`.",
1450
+ text: "# Nothing to open yet\n\nUse `/bro simplify <text>`, run `/bro` after an assistant response, use `/bro file <path>`, or use `/bro url <url>`.",
1175
1451
  kind: "empty",
1176
1452
  });
1177
1453
  return;
@@ -1185,8 +1461,8 @@ export default async function bro(pi: ExtensionAPI) {
1185
1461
  return;
1186
1462
  }
1187
1463
 
1188
- if (normalized && normalized !== "simplify") {
1189
- ctx.ui.notify(`Unknown action "${normalized}". Use simplify, file, open, doctor, usage, model, effort, or help.`, "warning");
1464
+ if (action && action !== "simplify") {
1465
+ ctx.ui.notify(`Unknown action "${normalized}". Use simplify, file, url, open, doctor, usage, model, effort, or help.`, "warning");
1190
1466
  return;
1191
1467
  }
1192
1468
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-bro",
3
- "version": "0.7.0",
4
- "description": "An Earendil Pi extension that explains assistant responses and local documents in a context-isolated window.",
3
+ "version": "0.8.0",
4
+ "description": "An Earendil Pi extension that explains pasted text, assistant responses, local documents, and public webpages in a context-isolated window.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Tran Hoang Nguyen",
@@ -18,6 +18,8 @@
18
18
  "pi-extension",
19
19
  "pi-coding-agent",
20
20
  "document",
21
+ "webpage",
22
+ "article",
21
23
  "pdf",
22
24
  "docx",
23
25
  "simplify",
@@ -58,6 +60,8 @@
58
60
  "typescript": "6.0.2"
59
61
  },
60
62
  "dependencies": {
63
+ "defuddle": "0.19.2",
64
+ "linkedom": "0.18.13",
61
65
  "mammoth": "^1.12.1",
62
66
  "unpdf": "^1.8.1"
63
67
  }