pi-quiver 3.0.0 → 3.1.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,20 @@ 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.1.0 - 2026-07-04
12
+
13
+ - **`fetch` auto-routes GitHub URLs through the `gh` CLI.** `github.com` issue,
14
+ PR, and repo-root URLs are served by `gh issue|pr view --comments` /
15
+ `gh repo view` and returned through the existing size gate, tagged with a
16
+ `Source: gh ...` header. Falls back silently to the HTTP path when `gh` is
17
+ absent, unauthenticated, or errors; `raw=true` forces the rendered page. No
18
+ new npm dependencies. `gh` is documented as an optional runtime binary in the
19
+ new README Prerequisites section.
20
+
21
+ ## v3.0.1 - 2026-07-04
22
+
23
+ - **`release.yml` posts GitHub Release notes.** A new `release-notes` job (`needs: publish`, `contents: write`) extracts the CHANGELOG section matching the pushed tag with `awk` and publishes it as the GitHub Release body via `gh release create` (falling back to `gh release edit`). No LLM or API key; only `github.token`.
24
+
11
25
  ## v3.0.0 - 2026-07-02
12
26
 
13
27
  - **Distribution moved from git-tag pins to npm, and the package renamed
package/README.md CHANGED
@@ -11,6 +11,18 @@ A small pack of [Pi coding-agent](https://github.com/badlogic/pi-mono) extension
11
11
  | `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. |
12
12
  | `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. |
13
13
 
14
+ ## Prerequisites
15
+
16
+ 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:
17
+
18
+ | Prerequisite | Needed by | If absent |
19
+ |---|---|---|
20
+ | `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). |
21
+ | `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). |
22
+ | LibreOffice (`soffice` on `PATH`) | `doc_to_md` DOCX/PPTX conversion | Office inputs error (no JS fallback for office->PDF); PDFs unaffected. |
23
+
24
+ None is a hard install-time dependency of the package; they are tools you provide in the environment where pi runs.
25
+
14
26
  ### fetch — content routing & context hygiene
15
27
 
16
28
  `fetch` is the main way an agent pulls external bytes into context. This extension routes responses by type to keep context tight:
@@ -37,8 +49,11 @@ A small pack of [Pi coding-agent](https://github.com/badlogic/pi-mono) extension
37
49
 
38
50
  **JSON:** Pretty-printed with 2-space indent before the gate.
39
51
 
52
+ **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.
53
+
40
54
  **Parameters:**
41
55
  - `raw=true`: Skip HTML→Markdown and JSON pretty-printing; return decoded body as-is (still subject to the size gate).
56
+ - `raw=true` also bypasses GitHub `gh` routing (forces the HTTP/rendered path).
42
57
 
43
58
  **Truncation:** Parsable content over 1 MB is truncated with a `(truncated to 1MB)` note; binary over 50 MB notes `(truncated to 50MB)`.
44
59
 
@@ -68,7 +83,7 @@ A small pack of [Pi coding-agent](https://github.com/badlogic/pi-mono) extension
68
83
 
69
84
  Python is pinned to **3.14** and is not configurable.
70
85
 
71
- **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.
86
+ **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.
72
87
 
73
88
  **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).
74
89
 
package/fetch.ts CHANGED
@@ -15,6 +15,8 @@ import { rm } from "node:fs/promises";
15
15
  import { tmpdir } from "node:os";
16
16
  import { join } from "node:path";
17
17
  import { createHash } from "node:crypto";
18
+ import { execFile } from "node:child_process";
19
+ import { promisify } from "node:util";
18
20
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
21
  import { formatSize, keyHint } from "@earendil-works/pi-coding-agent";
20
22
  import { Text } from "@earendil-works/pi-tui";
@@ -35,6 +37,148 @@ interface FetchToolDetails {
35
37
  spilled?: boolean;
36
38
  file?: string;
37
39
  lines?: number;
40
+ via?: "gh";
41
+ ghCommand?: string;
42
+ }
43
+
44
+ type GhTarget =
45
+ | { kind: "issue"; url: string }
46
+ | { kind: "pr"; url: string }
47
+ | { kind: "repo"; slug: string };
48
+
49
+ const RESERVED_OWNERS = new Set([
50
+ "orgs", "users", "sponsors", "topics", "marketplace", "apps",
51
+ "collections", "stars", "settings", "notifications", "codespaces",
52
+ "features", "trending", "security", "customer-stories",
53
+ ]);
54
+ const GH_NAME = /^[A-Za-z0-9._-]+$/;
55
+
56
+ export function classifyGitHubTarget(url: URL): GhTarget | null {
57
+ const host = url.hostname.toLowerCase();
58
+ if (host !== "github.com" && host !== "www.github.com") return null;
59
+ const segs = url.pathname.split("/").filter((s) => s.length > 0);
60
+ if (segs.length < 2) return null;
61
+ const [owner, repo] = segs;
62
+ if (!GH_NAME.test(owner) || !GH_NAME.test(repo)) return null;
63
+ if (RESERVED_OWNERS.has(owner.toLowerCase())) return null;
64
+ if (segs.length === 4 && segs[2] === "issues" && /^\d+$/.test(segs[3])) {
65
+ return { kind: "issue", url: `https://github.com/${owner}/${repo}/issues/${segs[3]}` };
66
+ }
67
+ if (segs.length === 4 && segs[2] === "pull" && /^\d+$/.test(segs[3])) {
68
+ return { kind: "pr", url: `https://github.com/${owner}/${repo}/pull/${segs[3]}` };
69
+ }
70
+ if (segs.length === 2) {
71
+ return { kind: "repo", slug: `${owner}/${repo}` };
72
+ }
73
+ return null;
74
+ }
75
+
76
+ export function buildGhArgs(target: GhTarget): string[] {
77
+ if (target.kind === "issue") return ["issue", "view", target.url, "--comments"];
78
+ if (target.kind === "pr") return ["pr", "view", target.url, "--comments"];
79
+ return ["repo", "view", target.slug];
80
+ }
81
+
82
+ const GH_MAX_BUFFER = 10_000_000; // 10 MB — an order above PARSABLE_MAX_BYTES
83
+
84
+ type GhResult = { ok: true; stdout: string } | { ok: false };
85
+ export type GhRunner = (args: string[], timeoutMs: number, signal?: AbortSignal) => Promise<GhResult>;
86
+
87
+ const execFileAsync = promisify(execFile);
88
+
89
+ export const runGh: GhRunner = async (args, timeoutMs, signal) => {
90
+ try {
91
+ const { stdout } = await execFileAsync("gh", args, {
92
+ timeout: timeoutMs,
93
+ signal,
94
+ maxBuffer: GH_MAX_BUFFER,
95
+ encoding: "utf8",
96
+ });
97
+ if (!stdout.trim()) return { ok: false };
98
+ return { ok: true, stdout };
99
+ } catch {
100
+ return { ok: false };
101
+ }
102
+ };
103
+
104
+ interface GhRoutingParams {
105
+ raw?: boolean;
106
+ method?: string;
107
+ body?: string;
108
+ headers?: Record<string, string>;
109
+ timeoutMs?: number;
110
+ }
111
+
112
+ export function planGhRouting(params: GhRoutingParams, url: URL): GhTarget | null {
113
+ if (params.raw) return null;
114
+ if ((params.method ?? "GET") !== "GET") return null;
115
+ if (params.body) return null;
116
+ if (params.headers && Object.keys(params.headers).length > 0) return null;
117
+ return classifyGitHubTarget(url);
118
+ }
119
+
120
+ function ghCommandLabel(target: GhTarget): string {
121
+ if (target.kind === "issue") return "issue view --comments";
122
+ if (target.kind === "pr") return "pr view --comments";
123
+ return "repo view";
124
+ }
125
+
126
+ function ghSourceLine(target: GhTarget, ref: string): string {
127
+ if (target.kind === "issue") return `gh issue view ${ref} --comments`;
128
+ if (target.kind === "pr") return `gh pr view ${ref} --comments`;
129
+ return `gh repo view ${ref}`;
130
+ }
131
+
132
+ function renderGhResult(target: GhTarget, stdout: string): { content: { type: "text"; text: string }[]; details: FetchToolDetails } {
133
+ const body = stdout.trimEnd();
134
+ const ref = target.kind === "repo" ? target.slug : target.url;
135
+ const { spill, bytes, lines } = applyGate(body);
136
+ const baseDetails: FetchToolDetails = {
137
+ url: ref,
138
+ bytes,
139
+ lines,
140
+ category: "markdown",
141
+ via: "gh",
142
+ ghCommand: ghCommandLabel(target),
143
+ };
144
+ const source = `Source: ${ghSourceLine(target, ref)}`;
145
+ if (!spill) {
146
+ return {
147
+ content: [{ type: "text", text: [source, "", body].join("\n") }],
148
+ details: { ...baseDetails, spilled: false },
149
+ };
150
+ }
151
+ const spillUrl = target.kind === "repo" ? `https://github.com/${target.slug}` : target.url;
152
+ const file = spillToFile(spillUrl, body, "md");
153
+ return {
154
+ content: [{
155
+ type: "text",
156
+ text: [
157
+ source,
158
+ `Body: ${formatSize(bytes)} across ${lines} lines — written to file (too large to inline)`,
159
+ `Saved-To: ${file}`,
160
+ "",
161
+ "Read slices of this file with the read tool (offset/limit) or grep it; do not read the whole file unless you must. Markdown is grep-able by heading (^#).",
162
+ "",
163
+ `----- preview (first ${PREVIEW_LINES} lines) -----`,
164
+ buildPreview(body),
165
+ ].join("\n"),
166
+ }],
167
+ details: { ...baseDetails, spilled: true, file },
168
+ };
169
+ }
170
+
171
+ export async function executeGhRouting(
172
+ params: GhRoutingParams,
173
+ url: URL,
174
+ signal: AbortSignal | undefined,
175
+ runner: GhRunner = runGh,
176
+ ): Promise<{ content: { type: "text"; text: string }[]; details: FetchToolDetails } | null> {
177
+ const target = planGhRouting(params, url);
178
+ if (!target) return null;
179
+ const gh = await runner(buildGhArgs(target), params.timeoutMs ?? DEFAULT_TIMEOUT_MS, signal);
180
+ if (!gh.ok) return null;
181
+ return renderGhResult(target, gh.stdout);
38
182
  }
39
183
 
40
184
  const PARSABLE_MAX_BYTES = 1_000_000; // text/markdown/json download ceiling
@@ -307,13 +451,14 @@ export default function fetchExtension(pi: ExtensionAPI) {
307
451
  name: "fetch",
308
452
  label: "Fetch URL",
309
453
  description:
310
- "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.",
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).",
311
455
  promptSnippet: "Fetch the contents of a URL",
312
456
  promptGuidelines: [
313
457
  "Use fetch when the user provides a URL or asks to read web content.",
314
458
  "Binary responses return a file path only — pass that path to a tool that can process the bytes; do not expect inline content.",
315
459
  "When the body is written to a file, grep it or read with offset/limit. Converted Markdown is grep-able by heading (^#).",
316
460
  "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.",
317
462
  ],
318
463
  parameters: Type.Object({
319
464
  url: Type.String({ description: "Absolute http(s) URL" }),
@@ -340,6 +485,9 @@ export default function fetchExtension(pi: ExtensionAPI) {
340
485
  throw new Error(`Unsupported protocol: ${url.protocol}`);
341
486
  }
342
487
 
488
+ const ghResult = await executeGhRouting(params, url, signal ?? undefined);
489
+ if (ghResult) return ghResult;
490
+
343
491
  const headers = new Headers(params.headers ?? {});
344
492
  if (!headers.has("user-agent")) headers.set("user-agent", FIREFOX_UA);
345
493
  if (!headers.has("accept")) headers.set("accept", DEFAULT_ACCEPT);
@@ -493,9 +641,11 @@ export default function fetchExtension(pi: ExtensionAPI) {
493
641
  return new Text(theme.fg("error", firstLine), 0, 0);
494
642
  }
495
643
 
644
+ const isGh = details?.via === "gh";
496
645
  const status = details?.status;
497
- const statusStyled =
498
- status === undefined
646
+ const statusStyled = isGh
647
+ ? theme.fg("success", "gh")
648
+ : status === undefined
499
649
  ? theme.fg("muted", "HTTP ?")
500
650
  : status >= 200 && status < 300
501
651
  ? theme.fg("success", `HTTP ${status}`)
@@ -505,7 +655,9 @@ export default function fetchExtension(pi: ExtensionAPI) {
505
655
 
506
656
  const sep = theme.fg("dim", " · ");
507
657
  const parts: string[] = [statusStyled];
508
- if (details?.contentType) {
658
+ if (isGh) {
659
+ if (details?.ghCommand) parts.push(theme.fg("muted", details.ghCommand));
660
+ } else if (details?.contentType) {
509
661
  parts.push(theme.fg("muted", details.contentType.split(";")[0].trim()));
510
662
  }
511
663
  if (typeof details?.bytes === "number") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-quiver",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "Personal pack of Pi coding-agent extensions. First-party-quality tools that keep context clean. Ships a context-safe fetch tool and a doc_to_md PDF/DOCX/PPTX-to-Markdown converter.",
5
5
  "author": "Jacek Juraszek",
6
6
  "license": "MIT",
@@ -37,9 +37,10 @@
37
37
  "CHANGELOG.md"
38
38
  ],
39
39
  "scripts": {
40
+ "check:agents-core": "node scripts/check-agents-core.mjs",
40
41
  "test": "node --test \"*.test.ts\"",
41
42
  "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",
42
- "test:all": "npm run test && npm run typecheck"
43
+ "test:all": "npm run check:agents-core && npm run test && npm run typecheck"
43
44
  },
44
45
  "pi": {
45
46
  "extensions": [