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 +144 -75
- package/THIRD_PARTY_NOTICES.md +13 -0
- package/bro.ts +339 -63
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -1,15 +1,91 @@
|
|
|
1
1
|
# pi-bro
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
|
7
|
-
|
|
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
|
-
|
|
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
|
+
[](https://raw.githubusercontent.com/tranhoangnguyen03/pi-bro/main/docs/images/bro-response-before.png)
|
|
82
|
+
|
|
83
|
+
**After `/bro`: the plain-language explanation**
|
|
84
|
+
|
|
85
|
+
[](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
|
[](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
|
[](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
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
342
|
+
Paste text directly after the command:
|
|
272
343
|
|
|
273
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
|
451
|
+
## Privacy and safety
|
|
403
452
|
|
|
404
|
-
- **External requests**: Bro sends the latest completed assistant response
|
|
405
|
-
extracted document text to Agy and its
|
|
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
|
-
##
|
|
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
|
-
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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,
|
|
449
|
-
handling, settings, custom prompt handling, and
|
|
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
|
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -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 = {
|
|
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
|
|
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
|
-
##
|
|
869
|
+
## Explain
|
|
592
870
|
|
|
593
|
-
- \`/bro\`
|
|
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
|
|
596
|
-
- \`/bro
|
|
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
|
-
|
|
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
|
-
|
|
879
|
+
## Check and configure
|
|
605
880
|
|
|
606
|
-
|
|
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
|
|
886
|
+
## Current settings
|
|
609
887
|
|
|
610
888
|
${settingsSummary}
|
|
611
889
|
|
|
612
|
-
|
|
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
|
|
621
|
-
- **↑ / ↓** — scroll
|
|
894
|
+
- **Mouse wheel / trackpad** — scroll
|
|
895
|
+
- **↑ / ↓** — scroll
|
|
622
896
|
- **C** — copy the full explanation
|
|
623
|
-
- **R** — repeat the current
|
|
624
|
-
- **Esc** — close
|
|
897
|
+
- **R** — repeat the current action
|
|
898
|
+
- **Esc** — close, or cancel while Bro is working
|
|
625
899
|
|
|
626
|
-
|
|
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
|
-
|
|
902
|
+
## Important limits
|
|
629
903
|
|
|
630
|
-
|
|
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
|
-
|
|
908
|
+
## Privacy and safety
|
|
633
909
|
|
|
634
|
-
Bro
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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${
|
|
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: "
|
|
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(
|
|
1234
|
+
ctx.ui.notify(`Use /bro ${action} <${action === "file" ? "path" : "url"}>.`, "warning");
|
|
961
1235
|
return;
|
|
962
1236
|
}
|
|
963
|
-
const
|
|
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 ??
|
|
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:
|
|
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\
|
|
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 (
|
|
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.
|
|
4
|
-
"description": "An Earendil Pi extension that explains assistant responses
|
|
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
|
}
|