pi-quiver 3.1.1 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,14 @@ Published to npm as `pi-quiver` (`pi install npm:pi-quiver`). Pushing a
8
8
  via OIDC trusted publishing. The release helper at
9
9
  `.agents/skills/release/scripts/release.sh` cuts the tag; CI publishes.
10
10
 
11
+ ## v3.2.0 - 2026-07-12
12
+
13
+ - Add `fast-mode` extension: opt-in Anthropic fast mode for Claude Opus 4.8 (`speed: "fast"` payload + `fast-mode-2026-02-01` beta header), controlled via `fastMode` settings key, `--fast` flag, and `/fast [on|off|status]`. OFF by default. Preserves OAuth identity betas. Requires pi bundling `@earendil-works/pi-coding-agent` >= 0.80.5 (the `before_provider_headers` hook).
14
+
15
+ ## v3.1.2 - 2026-07-07
16
+
17
+ - **`fetch` routes GitHub Actions run URLs through `gh`.** `github.com/{owner}/{repo}/actions/runs/{id}` URLs are served by `gh run view {id} --repo {owner}/{repo}` and returned through the existing size gate with a `Source: gh run view ...` header, alongside the existing issue/PR/repo routing. Only the bare run URL routes; deeper paths (`.../runs/{id}/jobs/{jobId}`, `.../actions/workflows/{file}`) fall back to HTTP. Falls back silently when `gh` is absent/unauthenticated/errors; `raw=true` forces the rendered page.
18
+
11
19
  ## v3.1.1 - 2026-07-05
12
20
 
13
21
  Branding, funding, and gallery preview. No behavior change.
package/README.md CHANGED
@@ -6,146 +6,89 @@
6
6
 
7
7
  [![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20A%20Coffee-donate-yellow?logo=buymeacoffee&logoColor=black)](https://buymeacoffee.com/jjurasszek)
8
8
 
9
- A small pack of [Pi coding-agent](https://github.com/earendil-works/pi) extensions I keep across every pi profile. First-party-quality tools, published to npm like sibling packages ([`pi-cohort`](https://github.com/jjuraszek/pi-cohort), [`pi-gauntlet`](https://github.com/jjuraszek/pi-gauntlet), [`pi-condense`](https://github.com/jjuraszek/pi-condense)).
9
+ Ground-truth ingestion for the [Pi coding agent](https://github.com/earendil-works/pi): pull real web pages, docs, and local files into context without flooding it.
10
10
 
11
- ## Extensions
11
+ ## The problem
12
12
 
13
- | Extension | Tool | What it does |
14
- |---|---|---|
15
- | `fetch.ts` | `fetch` | Retrieve URLs over HTTP(S). HTML → Markdown (main-content extraction, stripped boilerplate). Binary content saved untouched to a temp file. **Context-safe:** output over 32 KB or 1000 lines is written to a temp file with a preview + file path. Prevents a single fetch from flooding the context window. |
16
- | `doc_to_md.ts` | `doc_to_md` | Convert a local PDF/DOCX/PPTX to Markdown. High-fidelity via `pymupdf4llm` (run through `uv`, fetched on first use); degraded pure-JS fallback (`unpdf`) when `uv`/Python is unavailable or conversion times out. DOCX/PPTX convert via LibreOffice (`soffice`) to PDF first. Same 32 KB / 1000-line size gate as `fetch`. |
17
- | `session-name.ts` | `/session-name` | Name work sessions. Manual `/session-name [name]` always works. **OFF by default:** when opted in via `settings.json`, after the first agent turn it asks the current model for a concise session name + short tab label and applies them, and renames the **Ghostty** tab via OSC 2 (only when the active terminal is really Ghostty), re-asserting it each turn so the tab tracks the session name. |
18
- | `sword-header.ts` | `/builtin-header` | Replace the TUI startup logo with a theme-colored ASCII greatsword (hilt = accent, blade = text). **OFF by default:** only installs the header when opted in via `settings.json`. `/builtin-header` restores the built-in header at runtime. |
19
-
20
- ## Prerequisites
21
-
22
- The npm package's bundled JS deps install automatically on `pi install` - nothing to set up there. A few **runtime system binaries** are optional; each degrades gracefully when absent:
23
-
24
- | Prerequisite | Needed by | If absent |
25
- |---|---|---|
26
- | `gh` (GitHub CLI, installed + `gh auth login`) | `fetch` GitHub issue/PR/repo routing | Falls back to an HTTP fetch of the rendered page (private repos hit a login wall). |
27
- | `uv` (+ managed Python 3.14, fetched on first use) | `doc_to_md` high-fidelity PDF conversion | Degrades to the pure-JS `unpdf` fallback (no faithful tables/headings). |
28
- | LibreOffice (`soffice` on `PATH`) | `doc_to_md` DOCX/PPTX conversion | Office inputs error (no JS fallback for office->PDF); PDFs unaffected. |
29
-
30
- None is a hard install-time dependency of the package; they are tools you provide in the environment where pi runs.
31
-
32
- ### fetch — content routing & context hygiene
33
-
34
- `fetch` is the main way an agent pulls external bytes into context. This extension routes responses by type to keep context tight:
35
-
36
- **HTML → Markdown:**
37
- - Mozilla Readability extracts main content, strips navigation/chrome/boilerplate
38
- - Turndown converts to Markdown with GFM support (pipe tables, fenced code blocks, ATX headings)
39
- - Page title becomes a top-level `#` heading
40
- - Download cap: **1 MB**
41
-
42
- **Binary (images, PDFs, archives, fonts, audio/video) → temp file:**
43
- - Streamed untouched to `${TMPDIR}/pi-fetch/<stamp>-<host>-<hash>.<ext>` without decoding
44
- - Detection: content-type check + NUL-byte sniff in first ≤64 KB (catches mislabeled payloads)
45
- - Returns: status, content-type, size, file path — **no preview**
46
- - Download cap: **50 MB**
47
-
48
- **Text / Markdown / JSON size gate:**
49
- - Inline when **≤ 32 KB AND ≤ 1000 lines** (converted output size)
50
- - Otherwise **spills to file** with:
51
- - HTTP status, content-type, charset, byte/line counts
52
- - File path (`Saved-To:`)
53
- - 60-line preview
54
- - Instruction to `grep` (Markdown is grep-able by heading: `^#`) or `read` slices
13
+ Reasoning from a model's training memory instead of the real page, the current docs, or the actual PDF is how agents confidently ship wrong answers about APIs that changed last month. Mature engineering work has to be data-driven - the agent needs to read the real source.
55
14
 
56
- **JSON:** Pretty-printed with 2-space indent before the gate.
15
+ But the moment an agent does that, one `fetch` or PDF read can dump hundreds of kilobytes of boilerplate into context, degrading every turn after it.
57
16
 
58
- **GitHub URLs -> `gh`:** `github.com` issue (`/issues/{n}`), PR (`/pull/{n}`), and repo-root (`/{owner}/{repo}`) URLs are served by running the `gh` CLI (`gh issue|pr view --comments`, `gh repo view`) and returning its output, tagged with a `Source: gh ...` header and run through the same size gate. Requires `gh` (see [Prerequisites](#prerequisites)); if `gh` is missing or the call fails, `fetch` silently falls back to the normal HTTP path. Pass `raw=true` to force the rendered HTML page. All other GitHub paths (`tree`, `blob`, `raw`, `releases`, gists, ...) use the HTTP path unchanged. Routing is also skipped (plain HTTP used) when the request is non-GET, carries a body, or sets custom headers. gh output is bounded by a 10 MB buffer and run through the same size gate (spilled to a file when large), not the 1 MB HTTP download cap.
17
+ ## Why pi-quiver exists
59
18
 
60
- **Parameters:**
61
- - `raw=true`: Skip HTML→Markdown and JSON pretty-printing; return decoded body as-is (still subject to the size gate).
62
- - `raw=true` also bypasses GitHub `gh` routing (forces the HTTP/rendered path).
19
+ `fetch` and `doc_to_md` bring real web pages, GitHub issues/PRs, and local PDF/DOCX/PPTX files into context - and every result is size-gated by construction: over 32 KB or 1000 lines spills to a temp file with a preview and a grep/read hint, so a single call can never flood the window. Ingestion is what makes data-driven work possible; the gate is what keeps it safe.
63
20
 
64
- **Truncation:** Parsable content over 1 MB is truncated with a `(truncated to 1MB)` note; binary over 50 MB notes `(truncated to 50MB)`.
21
+ `session-name` and `sword-header` are smaller, opt-in ergonomics on top - session labeling and a themed startup header.
65
22
 
66
- **Runtime dependencies:** `jsdom`, `@mozilla/readability`, `turndown`, `turndown-plugin-gfm`. Shipped in the npm package and installed automatically on `pi install` - no manual setup needed.
23
+ ## Part of the pi agent toolkit
67
24
 
68
- ### doc_to_md — local document → Markdown
25
+ Four independent extensions for the [pi coding agent](https://github.com/earendil-works/pi), each owning one concern of running agents seriously:
69
26
 
70
- `doc_to_md` takes a **local file path** (`.pdf`, `.docx`, `.pptx`) and returns Markdown. For remote documents, `fetch` the URL first (it saves binaries to a temp path), then pass that path here.
27
+ - **pi-quiver** - capabilities (fetch, doc conversion, session tools)
28
+ - [pi-cohort](https://github.com/jjuraszek/pi-cohort) - coordination (delegate to focused child agents)
29
+ - [pi-condense](https://github.com/jjuraszek/pi-condense) - context economy (prune context, keep it recoverable)
30
+ - [pi-gauntlet](https://github.com/jjuraszek/pi-gauntlet) - process (the gated brainstorm->ship workflow)
71
31
 
72
- **Two engines, auto-selected:**
32
+ No code dependency between them. pi-quiver is call-level: it gates the size of what comes *in*. [pi-condense](https://github.com/jjuraszek/pi-condense) is loop-level: it prunes what's already *in context* once a tool call is done. Different problem, same discipline.
73
33
 
74
- - **Primary — `pymupdf4llm`** (high fidelity: headings, tables, reading order). Runs as an arms-length subprocess via `uv run --with pymupdf4llm==<pin> --python 3.14`. `uv` fetches the wheel into its own cache on first use (one-time download); Python 3.14 is fixed. Warmed once per process: the first call probes/installs (generous budget), later calls reuse the warm cache with a shorter per-document budget.
75
- - **Fallback — `unpdf`** (pure JS, bundled PDF.js). Used when `uv` is not on `PATH`, the warm probe fails, or a conversion times out. Output is plain text with page breaks — **no faithful tables/headings**. Degraded results are marked in the output (`[Note: degraded extraction via unpdf ...]`) and carry a `Fallback-Reason:` line.
34
+ ## Mental model
76
35
 
77
- **Office documents (`.docx`, `.pptx`):** converted to PDF by headless LibreOffice (`soffice`, isolated per-call profile), then fed through the same PDF pipeline. `soffice` must be on `PATH` for office inputs — otherwise the tool errors (there is no JS fallback for office→PDF). Spreadsheets and other formats are out of scope (spreadsheets paginate badly via PDF).
36
+ Every extension here is context-safe by construction, not by convention: the size check runs on every call, there's no flag to forget. Two tools bring real sources in (`fetch`, `doc_to_md`); two are opt-in ergonomics (`session-name`, `sword-header`).
78
37
 
79
- **Size gate:** identical to `fetch` — Markdown ≤ 32 KB and ≤ 1000 lines is inlined; larger output spills to `${TMPDIR}/pi-doc-to-md/<stamp>-<basename>-<hash>.md` with a 60-line preview + a grep/read-slice hint.
80
-
81
- **Configuration (environment variables):**
82
-
83
- | Variable | Default | Meaning |
84
- |---|---|---|
85
- | `PI_DOC_TO_MD_PYMUPDF_VERSION` | `1.27.2.3` | `pymupdf4llm` version pin passed to `uv --with` (digits/dots only) |
86
- | `PI_DOC_TO_MD_WARM_TIMEOUT_MS` | `120000` | Warm/install call budget — covers the cold wheel (+ managed Python) download |
87
- | `PI_DOC_TO_MD_CONVERT_TIMEOUT_MS` | `60000` | Per-document conversion budget (also bounds the `unpdf` fallback) |
88
- | `PI_DOC_TO_MD_SOFFICE_TIMEOUT_MS` | `120000` | LibreOffice `.docx`/`.pptx` → PDF budget |
89
-
90
- Python is pinned to **3.14** and is not configurable.
91
-
92
- **Runtime dependencies:** `unpdf` (shipped in the npm package, installed automatically on `pi install`). `uv` and LibreOffice (`soffice`) are optional system binaries detected at runtime: without `uv`, PDFs still convert via the `unpdf` fallback; without `soffice`, office inputs error while PDFs are unaffected. See [Prerequisites](#prerequisites) for the consolidated list.
93
-
94
- **Licensing note:** `pymupdf4llm`/PyMuPDF are **AGPL-3.0**. This package ships none of their code — `uv` downloads the wheel from PyPI onto your machine at runtime, and it runs as a **separate subprocess** (never imported or linked into this TypeScript). The arms-length process boundary keeps pi-quiver' MIT license intact; the AGPL governs PyMuPDF itself, whose source is public. This holds only while the boundary stays subprocess-only (no vendoring/importing the wheel).
95
-
96
- ### session-name — manual + opt-in automatic session naming
97
-
98
- Names work sessions so the session selector (and optionally the Ghostty tab) shows what each one is about.
99
-
100
- **Behaviors:**
101
-
102
- - **Manual `/session-name [name]`** - set the session name, or, with no argument, print the current one. Always available, regardless of config. A manual name wins: it suppresses later auto-naming for the session.
103
- - **Automatic naming (opt-in).** After the first agent turn completes, if no name is set yet, the extension asks the **current model** for a 3-6 word session title plus a 1-4 word tab label and applies both. It only runs once per session and never overwrites an existing name.
104
- - **Resume reflection (opt-in).** When a session that already carries a name is loaded/resumed/reloaded, its tab label is re-applied so the Ghostty tab matches.
105
- - **Per-turn re-assert (opt-in).** The tab is re-pinned to the session name at the start of every turn. Pi owns the OS terminal title (OSC 0, `pi - <name> - <cwd>`) and overwrites it on every name change and session switch; the re-assert is the only hook that fires *after* pi's writer on a session swap, so the Ghostty tab and the session name stay in sync instead of drifting. It self-heals: if the name was changed outside this extension, the tab label is re-derived from the new name.
106
- - **Ghostty tab rename.** The short label is written via OSC 2 (`ESC ] 2 ; <label> BEL`) **only when the active terminal is really Ghostty** (`TERM_PROGRAM=ghostty`, `TERM=xterm-ghostty`, or a `GHOSTTY_*` dir env) **and** stdout is a TTY. Other terminals are never touched. Auto-naming keeps its curated short label; re-derived labels (resume/external rename) are the first words of the session name.
107
-
108
- **OFF by default.** All automatic behavior (auto-naming + resume reflection) is inert until explicitly enabled. The manual command is unaffected.
38
+ ```mermaid
39
+ flowchart LR
40
+ S[web page / PDF / doc] --> T["fetch / doc_to_md"]
41
+ T --> E[extract main content]
42
+ E --> G{"over 32KB or 1000 lines?"}
43
+ G -->|no| I[return inline to context]
44
+ G -->|yes| F[spill to temp file<br/>return preview + grep/read hint]
45
+ ```
109
46
 
110
- **Configuration** (`settings.json`, **project `.pi/settings.json` overrides the global agent-dir `settings.json`**). The global path is resolved via pi's own `getAgentDir()` (honours `PI_CODING_AGENT_DIR`, else `~/.pi/agent`), so it stays correct however this package is installed.
47
+ ## Quick example
111
48
 
112
- ```jsonc
113
- {
114
- // full form, defaults shown
115
- "sessionAutoName": { "enabled": false, "ghosttyTab": true }
116
- }
49
+ ```bash
50
+ pi install npm:pi-quiver
117
51
  ```
118
52
 
119
- | Key | Default | Meaning |
120
- |---|---|---|
121
- | `enabled` | `false` | Master switch for automatic naming + resume reflection. |
122
- | `ghosttyTab` | `true` | Whether to rename the Ghostty tab (only ever fires when the terminal is actually Ghostty). |
53
+ ```
54
+ > fetch https://example.com/some-huge-changelog
55
+ Saved-To: /tmp/pi-fetch/2026-...-example.com-....md
56
+ 60-line preview follows. grep '^#' the file for headings, or read a slice.
57
+ ```
123
58
 
124
- Boolean shorthand: `"sessionAutoName": true` enables everything (equivalent to `{ "enabled": true, "ghosttyTab": true }`); `false` disables everything.
59
+ A 300 KB changelog page never touches your context window - you get a preview and a path.
125
60
 
126
- **Cost note:** when enabled, automatic naming makes **one extra short LLM call** per session (low reasoning effort, current model, a few-thousand-char conversation digest), once, after the first turn. When OFF (the default) it makes **no** model calls and writes **nothing** to the terminal.
61
+ ## Architecture
127
62
 
128
- **Runtime dependency:** `@earendil-works/pi-ai` (the unified LLM API provided by the pi runtime; a peer dependency, no separate install).
63
+ | Extension | Tool | What it does |
64
+ | --- | --- | --- |
65
+ | `fetch.ts` | `fetch` | Retrieve URLs over HTTP(S). HTML -> Markdown (Readability extraction, Turndown conversion). Binary saved untouched to a temp file. GitHub issue/PR/repo/actions-run URLs auto-route through `gh` (falls back to HTTP). Same size gate as `doc_to_md`. |
66
+ | `doc_to_md.ts` | `doc_to_md` | Convert a local PDF/DOCX/PPTX to Markdown. High-fidelity via `pymupdf4llm` (run through `uv`); degraded pure-JS fallback (`unpdf`) when `uv`/Python is unavailable or conversion times out. DOCX/PPTX convert via LibreOffice first. |
67
+ | `session-name.ts` | `/session-name` | Manual + opt-in automatic session naming, with Ghostty tab rename. OFF by default. |
68
+ | `sword-header.ts` | `/builtin-header` | Themed ASCII startup header replacing pi's default logo. OFF by default. |
69
+ | `fast-mode.ts` | `/fast` | Inject Anthropic fast-mode (`speed: "fast"` + `anthropic-beta: fast-mode-2026-02-01`) into every Claude Opus 4.8 request, any thinking level. `--fast` flag + `/fast [on\|off\|status]`. OFF by default. |
129
70
 
130
- ### sword-header — themed ASCII startup header
71
+ Full routing rules, size-gate mechanics, and config: [doc/fetch.md](doc/fetch.md), [doc/doc-to-md.md](doc/doc-to-md.md).
131
72
 
132
- Replaces pi's built-in startup logo with a hero's greatsword (Michael J. Penick longsword, asciiart.eu). The ASCII art is verbatim; only the coloring is ours - hilt/grip/pommel use the `accent` token, the blade uses `text`, so it tracks whatever theme is active.
73
+ ## Key concepts
133
74
 
134
- **Behaviors:**
75
+ | Concept | Meaning |
76
+ | --- | --- |
77
+ | Size gate | Text/Markdown/JSON output over 32 KB or 1000 lines spills to a temp file with a 60-line preview instead of inlining. |
78
+ | Content routing | HTML -> Markdown, binary -> untouched file, GitHub URLs -> `gh` CLI, everything else -> the size gate. |
79
+ | Graceful degradation | Optional binaries (`gh`, `uv`, LibreOffice) are never hard install-time deps; each has a defined, documented fallback or failure mode. |
80
+ | Opt-in ergonomics | `session-name` and `sword-header` do nothing until explicitly enabled in `settings.json`. |
135
81
 
136
- - **TUI only.** Installs a custom header on `session_start` when `ctx.mode === "tui"`. In print/non-interactive mode (`-p`) it does nothing.
137
- - **`/builtin-header`** restores the built-in pi header at runtime (always available).
82
+ ## When to use
138
83
 
139
- **OFF by default.** The header is only installed when explicitly enabled via `settings.json`.
84
+ - An agent needs to reason from a real web page, GitHub issue/PR, or local PDF/DOCX/PPTX instead of memory.
85
+ - You want that ingestion to be safe by default, with no risk of a single call blowing the context budget.
140
86
 
141
- **Configuration** (`settings.json`, project `.pi/settings.json` overrides the global agent-dir layer; same resolution as `session-name`):
87
+ ## When NOT to use
142
88
 
143
- ```jsonc
144
- {
145
- "swordHeader": false // default; true installs the header
146
- // object form also accepted: "swordHeader": { "enabled": true }
147
- }
148
- ```
89
+ - You need a general-purpose web scraper (JS-rendered pages, pagination, auth flows) - `fetch` does plain HTTP + Readability extraction, nothing more.
90
+ - You need spreadsheet conversion - `doc_to_md` explicitly excludes spreadsheets (they paginate badly via PDF).
91
+ - You want automatic session naming or a custom header without opting in - both stay off until you flip the config.
149
92
 
150
93
  ## Install
151
94
 
@@ -176,6 +119,32 @@ git clone git@github.com:jjuraszek/pi-quiver.git ~/repos/pi-quiver
176
119
  pi -e ~/repos/pi-quiver/fetch.ts
177
120
  ```
178
121
 
122
+ ## Prerequisites
123
+
124
+ The npm package's bundled JS deps install automatically on `pi install`. A few **runtime system binaries** are optional; each degrades gracefully when absent:
125
+
126
+ | Prerequisite | Needed by | If absent |
127
+ | --- | --- | --- |
128
+ | `gh` (GitHub CLI, installed + `gh auth login`) | `fetch` GitHub issue/PR/repo/actions-run routing | Falls back to an HTTP fetch of the rendered page (private repos hit a login wall). |
129
+ | `uv` (+ managed Python 3.14, fetched on first use) | `doc_to_md` high-fidelity PDF conversion | Degrades to the pure-JS `unpdf` fallback (no faithful tables/headings). |
130
+ | LibreOffice (`soffice` on `PATH`) | `doc_to_md` DOCX/PPTX conversion | Office inputs error (no JS fallback for office->PDF); PDFs unaffected. |
131
+
132
+ None is a hard install-time dependency of the package; they are tools you provide in the environment where pi runs.
133
+
134
+ ### Opt-in extension config
135
+
136
+ Both are opt-in via `settings.json` (project `.pi/settings.json` overrides the global agent-dir layer):
137
+
138
+ ```jsonc
139
+ {
140
+ "sessionAutoName": { "enabled": false, "ghosttyTab": true }, // or boolean shorthand
141
+ "swordHeader": false, // or { "enabled": true }
142
+ "fastMode": false // or { "enabled": true }
143
+ }
144
+ ```
145
+
146
+ `sessionAutoName.enabled` makes one extra short LLM call per session (once, after the first turn) to title it; `false` (default) makes no model calls. `fastMode` only affects `claude-opus-4-8` requests on Anthropic's `anthropic-messages` API; enabling it opts into premium fast-mode pricing. `--fast` forces it on for one launch; `/fast on|off` toggles live. Proxy providers (opencode, cloudflare-ai-gateway) are excluded. `fastMode`'s header injection needs the `before_provider_headers` hook (pi bundling `@earendil-works/pi-coding-agent` >= 0.80.5); on older pi the beta header is silently not sent. See [doc/fetch.md](doc/fetch.md) and [doc/doc-to-md.md](doc/doc-to-md.md) for the ingestion tools' full reference; session-name/sword-header behavior above is complete.
147
+
179
148
  ## Development
180
149
 
181
150
  Deps are peers (`@earendil-works/*`, `@sinclair/typebox`) plus the bundled
@@ -189,6 +158,10 @@ npm run test:all # node --test *.test.ts + tsc --noEmit typecheck
189
158
  `npm test` runs the unit tests alone; `npm run typecheck` runs the type pass.
190
159
  Both run in CI on ubuntu + windows (`.github/workflows/test.yml`).
191
160
 
161
+ ## How this fits the platform
162
+
163
+ pi-quiver is how ground truth gets into an agent's context - real pages, PDFs, docs, cleanly and safely. The other three then coordinate work over it ([pi-cohort](https://github.com/jjuraszek/pi-cohort)), prune it once it's stale ([pi-condense](https://github.com/jjuraszek/pi-condense)), and govern the process end to end ([pi-gauntlet](https://github.com/jjuraszek/pi-gauntlet)).
164
+
192
165
  ## Support
193
166
 
194
167
  If this saves you time, consider [buying me a coffee](https://buymeacoffee.com/jjurasszek).
package/fast-mode.ts ADDED
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Fast mode for Claude Opus 4.8.
3
+ *
4
+ * When enabled, injects Anthropic's fast-mode signals into every qualifying
5
+ * Opus 4.8 request on the anthropic-messages API, regardless of thinking level:
6
+ * - payload: { ...payload, speed: "fast" } (before_provider_request)
7
+ * - header: anthropic-beta: ...,fast-mode-2026-02-01 (before_provider_headers)
8
+ *
9
+ * OFF BY DEFAULT. Three control surfaces, lowest precedence first:
10
+ * 1. settings.json "fastMode": true | { "enabled": true } (default false)
11
+ * 2. --fast launch flag (force-on only)
12
+ * 3. /fast [on|off|status] live toggle (wins for session)
13
+ *
14
+ * Header coupling to pi-ai internals: pi assembles `anthropic-beta` AFTER this
15
+ * hook and merges the hook's headers LAST, so setting the header here REPLACES
16
+ * pi's list. For opus-4-8 pi's conditional betas (fine-grained tool streaming,
17
+ * interleaved thinking) are never applied (eager tool streaming defaults on +
18
+ * forceAdaptiveThinking), so the only betas to preserve are the OAuth identity
19
+ * betas. We detect OAuth via the same token marker pi uses and rebuild the
20
+ * exact list. If pi later adds betas for opus-4-8, revisit buildBetaHeader.
21
+ */
22
+
23
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
24
+ import { resolveConfig } from "./extension-config.ts";
25
+
26
+ export const FAST_MODE_BETA = "fast-mode-2026-02-01";
27
+ export const FAST_SPEED = "fast";
28
+ // Loose prefix: matches dated snapshots (claude-opus-4-8-*). Opus 4.7 is out of
29
+ // scope (D1); a future 4.9 needs a one-line addition here.
30
+ export const FAST_MODE_MODEL_PREFIXES = ["claude-opus-4-8"];
31
+ export const OAUTH_IDENTITY_BETAS = ["claude-code-20250219", "oauth-2025-04-20"];
32
+ const STATUS_KEY = "fast-mode";
33
+ const BETA_HEADER = "anthropic-beta";
34
+
35
+ type Config = { enabled: boolean };
36
+ const DEFAULT_CONFIG: Config = { enabled: false };
37
+
38
+ export function coerce(raw: unknown): Partial<Config> | undefined {
39
+ if (raw === undefined) return undefined;
40
+ if (typeof raw === "boolean") return { enabled: raw };
41
+ if (raw && typeof raw === "object") {
42
+ const o = raw as Record<string, unknown>;
43
+ const out: Partial<Config> = {};
44
+ if (typeof o.enabled === "boolean") out.enabled = o.enabled;
45
+ return out;
46
+ }
47
+ return undefined;
48
+ }
49
+
50
+ type ModelLike = { id?: string; api?: string; provider?: string } | undefined;
51
+
52
+ export function shouldInject(enabled: boolean, model: ModelLike): boolean {
53
+ if (!enabled || !model) return false;
54
+ if (model.provider !== "anthropic") return false;
55
+ if (model.api !== "anthropic-messages") return false;
56
+ const id = model.id;
57
+ if (typeof id !== "string") return false;
58
+ return FAST_MODE_MODEL_PREFIXES.some((p) => id.startsWith(p));
59
+ }
60
+
61
+ export function injectSpeed(payload: unknown): unknown {
62
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
63
+ return payload;
64
+ }
65
+ return { ...(payload as Record<string, unknown>), speed: FAST_SPEED };
66
+ }
67
+
68
+ export function buildBetaHeader(existing: string | null | undefined, isOAuth: boolean): string {
69
+ const seen = new Set<string>();
70
+ const out: string[] = [];
71
+ const add = (b: string): void => {
72
+ const t = b.trim();
73
+ if (t && !seen.has(t)) {
74
+ seen.add(t);
75
+ out.push(t);
76
+ }
77
+ };
78
+ if (isOAuth) OAUTH_IDENTITY_BETAS.forEach(add);
79
+ if (typeof existing === "string") existing.split(",").forEach(add);
80
+ add(FAST_MODE_BETA);
81
+ return out.join(",");
82
+ }
83
+
84
+ type State = { config: boolean; flag: boolean; live: boolean | null };
85
+
86
+ export function resolveEnabled(s: State): boolean {
87
+ if (s.live !== null) return s.live;
88
+ return s.flag || s.config;
89
+ }
90
+
91
+ export default function (pi: ExtensionAPI) {
92
+ let liveOverride: boolean | null = null;
93
+ let enabled = false;
94
+
95
+ const readFlag = (): boolean => pi.getFlag("fast") === true;
96
+
97
+ const resolveState = (ctx: ExtensionContext): boolean => {
98
+ const config = resolveConfig(ctx.cwd, "fastMode", DEFAULT_CONFIG, coerce).enabled;
99
+ enabled = resolveEnabled({ config, flag: readFlag(), live: liveOverride });
100
+ return enabled;
101
+ };
102
+
103
+ const refreshStatus = (ctx: ExtensionContext): void => {
104
+ if (!enabled) {
105
+ ctx.ui.setStatus(STATUS_KEY, undefined);
106
+ return;
107
+ }
108
+ ctx.ui.setStatus(STATUS_KEY, shouldInject(enabled, ctx.model) ? "\u26a1 fast" : "\u26a1 n/a");
109
+ };
110
+
111
+ const detectOAuth = async (ctx: ExtensionContext): Promise<boolean | null> => {
112
+ if (!ctx.model) return null;
113
+ try {
114
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model);
115
+ if (!auth.ok) return null;
116
+ return typeof auth.apiKey === "string" && auth.apiKey.includes("sk-ant-oat");
117
+ } catch {
118
+ return null;
119
+ }
120
+ };
121
+
122
+ pi.registerFlag("fast", {
123
+ type: "boolean",
124
+ description: "Enable Anthropic fast mode for Opus 4.8 requests this launch",
125
+ });
126
+
127
+ pi.on("session_start", async (_event, ctx) => {
128
+ resolveState(ctx);
129
+ refreshStatus(ctx);
130
+ });
131
+
132
+ pi.on("model_select", async (_event, ctx) => {
133
+ refreshStatus(ctx);
134
+ });
135
+
136
+ pi.on("before_provider_request", (event, ctx) => {
137
+ if (!shouldInject(enabled, ctx.model)) return;
138
+ return injectSpeed(event.payload);
139
+ });
140
+
141
+ pi.on("before_provider_headers", async (event, ctx) => {
142
+ if (!shouldInject(enabled, ctx.model)) return;
143
+ if (!event.headers) return;
144
+ const isOAuth = await detectOAuth(ctx);
145
+ if (isOAuth === null) return;
146
+ event.headers[BETA_HEADER] = buildBetaHeader(event.headers[BETA_HEADER], isOAuth);
147
+ });
148
+
149
+ pi.registerCommand("fast", {
150
+ description: "Manage Opus 4.8 fast mode: /fast [on|off|status]",
151
+ getArgumentCompletions: (prefix) => {
152
+ const p = prefix.trim().toLowerCase();
153
+ if (p.includes(" ")) return null;
154
+ const matches = ["on", "off", "status"].filter((v) => v.startsWith(p));
155
+ return matches.length ? matches.map((value) => ({ value, label: value })) : null;
156
+ },
157
+ handler: async (args, ctx) => {
158
+ const arg = args.trim().toLowerCase();
159
+ if (arg === "status") {
160
+ const eff = liveOverride !== null ? "live toggle" : readFlag() ? "--fast flag" : "settings.json";
161
+ const model = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "(no model selected)";
162
+ const applies = shouldInject(enabled, ctx.model) ? "applies to current model" : "does not apply to current model";
163
+ ctx.ui.notify(
164
+ `Fast mode is ${enabled ? "on" : "off"} (source: ${eff}). Model: ${model} - ${applies}.`,
165
+ "info",
166
+ );
167
+ return;
168
+ }
169
+ if (arg === "on" || arg === "off") {
170
+ liveOverride = arg === "on";
171
+ } else if (arg === "") {
172
+ liveOverride = !enabled;
173
+ } else {
174
+ ctx.ui.notify("Usage: /fast [on|off|status]", "warning");
175
+ return;
176
+ }
177
+ resolveState(ctx);
178
+ refreshStatus(ctx);
179
+ ctx.ui.notify(`Fast mode ${enabled ? "enabled" : "disabled"}`, "info");
180
+ },
181
+ });
182
+ }
package/fetch.ts CHANGED
@@ -44,7 +44,8 @@ interface FetchToolDetails {
44
44
  type GhTarget =
45
45
  | { kind: "issue"; url: string }
46
46
  | { kind: "pr"; url: string }
47
- | { kind: "repo"; slug: string };
47
+ | { kind: "repo"; slug: string }
48
+ | { kind: "run"; slug: string; runId: string; url: string };
48
49
 
49
50
  const RESERVED_OWNERS = new Set([
50
51
  "orgs", "users", "sponsors", "topics", "marketplace", "apps",
@@ -67,6 +68,9 @@ export function classifyGitHubTarget(url: URL): GhTarget | null {
67
68
  if (segs.length === 4 && segs[2] === "pull" && /^\d+$/.test(segs[3])) {
68
69
  return { kind: "pr", url: `https://github.com/${owner}/${repo}/pull/${segs[3]}` };
69
70
  }
71
+ if (segs.length === 5 && segs[2] === "actions" && segs[3] === "runs" && /^\d+$/.test(segs[4])) {
72
+ return { kind: "run", slug: `${owner}/${repo}`, runId: segs[4], url: `https://github.com/${owner}/${repo}/actions/runs/${segs[4]}` };
73
+ }
70
74
  if (segs.length === 2) {
71
75
  return { kind: "repo", slug: `${owner}/${repo}` };
72
76
  }
@@ -76,6 +80,7 @@ export function classifyGitHubTarget(url: URL): GhTarget | null {
76
80
  export function buildGhArgs(target: GhTarget): string[] {
77
81
  if (target.kind === "issue") return ["issue", "view", target.url, "--comments"];
78
82
  if (target.kind === "pr") return ["pr", "view", target.url, "--comments"];
83
+ if (target.kind === "run") return ["run", "view", target.runId, "--repo", target.slug];
79
84
  return ["repo", "view", target.slug];
80
85
  }
81
86
 
@@ -120,12 +125,14 @@ export function planGhRouting(params: GhRoutingParams, url: URL): GhTarget | nul
120
125
  function ghCommandLabel(target: GhTarget): string {
121
126
  if (target.kind === "issue") return "issue view --comments";
122
127
  if (target.kind === "pr") return "pr view --comments";
128
+ if (target.kind === "run") return "run view";
123
129
  return "repo view";
124
130
  }
125
131
 
126
132
  function ghSourceLine(target: GhTarget, ref: string): string {
127
133
  if (target.kind === "issue") return `gh issue view ${ref} --comments`;
128
134
  if (target.kind === "pr") return `gh pr view ${ref} --comments`;
135
+ if (target.kind === "run") return `gh run view ${target.runId} --repo ${target.slug}`;
129
136
  return `gh repo view ${ref}`;
130
137
  }
131
138
 
@@ -451,14 +458,14 @@ export default function fetchExtension(pi: ExtensionAPI) {
451
458
  name: "fetch",
452
459
  label: "Fetch URL",
453
460
  description:
454
- "Fetch a URL over HTTP(S). HTML is extracted to Markdown (readability + turndown). Binary content (images, PDFs, archives) is saved untouched to a temp file and only a path is returned. Text/Markdown/JSON over 32KB or 1000 lines is written to a temp file with a 60-line preview; smaller content is returned inline. Parsable downloads are capped at 1MB, binary at 50MB. GitHub issue/PR/repo URLs are served via the gh CLI when available (falls back to HTTP otherwise).",
461
+ "Fetch a URL over HTTP(S). HTML is extracted to Markdown (readability + turndown). Binary content (images, PDFs, archives) is saved untouched to a temp file and only a path is returned. Text/Markdown/JSON over 32KB or 1000 lines is written to a temp file with a 60-line preview; smaller content is returned inline. Parsable downloads are capped at 1MB, binary at 50MB. GitHub issue/PR/repo/actions-run URLs are served via the gh CLI when available (falls back to HTTP otherwise).",
455
462
  promptSnippet: "Fetch the contents of a URL",
456
463
  promptGuidelines: [
457
464
  "Use fetch when the user provides a URL or asks to read web content.",
458
465
  "Binary responses return a file path only — pass that path to a tool that can process the bytes; do not expect inline content.",
459
466
  "When the body is written to a file, grep it or read with offset/limit. Converted Markdown is grep-able by heading (^#).",
460
467
  "Pass raw=true to skip Markdown/JSON conversion and get the decoded body as-is (still subject to the size gate).",
461
- "GitHub issue/PR/repo links are fetched through the gh CLI automatically; pass raw=true to force the rendered HTML page.",
468
+ "GitHub issue/PR/repo/actions-run links are fetched through the gh CLI automatically; pass raw=true to force the rendered HTML page.",
462
469
  ],
463
470
  parameters: Type.Object({
464
471
  url: Type.String({ description: "Absolute http(s) URL" }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-quiver",
3
- "version": "3.1.1",
3
+ "version": "3.2.0",
4
4
  "description": "Personal pack of Pi coding-agent extensions: context-safe fetch, doc_to_md PDF/DOCX/PPTX-to-Markdown conversion, session naming, and a themed ASCII startup header.",
5
5
  "author": "Jacek Juraszek",
6
6
  "license": "MIT",
@@ -32,6 +32,7 @@
32
32
  "doc_to_md.ts",
33
33
  "session-name.ts",
34
34
  "sword-header.ts",
35
+ "fast-mode.ts",
35
36
  "extension-config.ts",
36
37
  "scripts/pdf_to_md.py",
37
38
  "types/**/*.d.ts",
@@ -41,7 +42,7 @@
41
42
  "scripts": {
42
43
  "check:agents-core": "node scripts/check-agents-core.mjs",
43
44
  "test": "node --test \"*.test.ts\"",
44
- "typecheck": "npx -y tsc --noEmit --allowImportingTsExtensions --target es2022 --module nodenext --moduleResolution nodenext --strict --skipLibCheck --esModuleInterop --resolveJsonModule --lib es2022 --types node fetch.ts fetch.test.ts doc_to_md.ts doc_to_md.test.ts session-name.ts session-name.test.ts sword-header.ts sword-header.test.ts extension-config.ts types/turndown-plugin-gfm.d.ts",
45
+ "typecheck": "npx -y tsc --noEmit --allowImportingTsExtensions --target es2022 --module nodenext --moduleResolution nodenext --strict --skipLibCheck --esModuleInterop --resolveJsonModule --lib es2022 --types node fetch.ts fetch.test.ts doc_to_md.ts doc_to_md.test.ts session-name.ts session-name.test.ts sword-header.ts sword-header.test.ts fast-mode.ts fast-mode.test.ts extension-config.ts types/turndown-plugin-gfm.d.ts",
45
46
  "test:all": "npm run check:agents-core && npm run test && npm run typecheck"
46
47
  },
47
48
  "pi": {
@@ -49,7 +50,8 @@
49
50
  "./fetch.ts",
50
51
  "./doc_to_md.ts",
51
52
  "./session-name.ts",
52
- "./sword-header.ts"
53
+ "./sword-header.ts",
54
+ "./fast-mode.ts"
53
55
  ],
54
56
  "image": "https://raw.githubusercontent.com/jjuraszek/pi-quiver/main/pi-quiver.png"
55
57
  },
@@ -86,7 +88,7 @@
86
88
  },
87
89
  "devDependencies": {
88
90
  "@earendil-works/pi-ai": "^0.80.3",
89
- "@earendil-works/pi-coding-agent": "^0.80.3",
91
+ "@earendil-works/pi-coding-agent": "^0.80.5",
90
92
  "@earendil-works/pi-tui": "^0.80.3",
91
93
  "@sinclair/typebox": "^0.34.49",
92
94
  "@types/jsdom": "^28.0.3",