@zeldrisho/pi-web-fetch 0.16.0 → 0.16.1

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
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.16.1] - 2026-10-28
11
+
12
+ ### Changed
13
+
14
+ - Show focus queries in tool calls, and display expansion and continuation notices with clearer spacing.
15
+
10
16
  ## [0.16.0] - 2026-10-28
11
17
 
12
18
  ### Added
@@ -295,7 +301,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
295
301
 
296
302
  - Add bounded public page fetching ([b979496](https://github.com/zeldrisho/pi-packages/commit/b979496b32de1cead172ba570307e4a4a7b3421d))
297
303
 
298
- [Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.16.0...HEAD
304
+ [Unreleased]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.16.1...HEAD
305
+ [0.16.1]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.16.0...pi-web-fetch-v0.16.1
299
306
  [0.16.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.15.0...pi-web-fetch-v0.16.0
300
307
  [0.15.0]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.14.1...pi-web-fetch-v0.15.0
301
308
  [0.14.1]: https://github.com/zeldrisho/pi-packages/compare/pi-web-fetch-v0.14.0...pi-web-fetch-v0.14.1
package/README.md CHANGED
@@ -25,7 +25,7 @@ The `web_fetch` tool accepts public HTTP and HTTPS URLs and supports HTML, Markd
25
25
 
26
26
  Output is bounded to 6,000 characters by default. For long pages, use the returned `nextOffset` to continue. Use `query` to focus on matching sections, or omit it to read the complete document. Pi's preview is collapsed by default; press `Ctrl+O` to expand it.
27
27
 
28
- For example, a URL such as `https://github.com/shadcn-ui/lint#get-started` starts at the matching heading. GitHub `blob` links and bare gists are converted to raw source URLs; repository roots use their README. GitHub directory listings are not supported.
28
+ For example, a URL such as `https://github.com/zeldrisho/pi-packages/blob/main/packages/pi-web-fetch/README.md#usage` starts at the matching heading. GitHub `blob` links and bare gists are converted to raw source URLs; repository roots use their README. GitHub directory listings are not supported.
29
29
 
30
30
  ### Page alternatives and bot challenges
31
31
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeldrisho/pi-web-fetch",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
4
4
  "description": "Pi extension for secure, bounded public web page fetching and Markdown extraction",
5
5
  "keywords": [
6
6
  "pi",
package/src/cache.ts CHANGED
@@ -39,6 +39,26 @@ export function stableKeyHash(key: string): string {
39
39
  return createHash("sha256").update(key).digest("hex");
40
40
  }
41
41
 
42
+ /** Hash canonical JSON, sorting object keys recursively while preserving array order. */
43
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- JSON serialization defines the accepted values.
44
+ export function canonicalKey(value: unknown): string {
45
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- JSON replacer receives arbitrary JSON values.
46
+ const json = JSON.stringify(value, (_key, entry: unknown) => {
47
+ // oxlint-disable-next-line anti-slop/no-runtime-typeof -- recursively sort JSON objects.
48
+ if (entry !== null && typeof entry === "object" && !Array.isArray(entry)) {
49
+ return Object.fromEntries(
50
+ Object.entries(entry).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
51
+ );
52
+ }
53
+
54
+ return entry;
55
+ });
56
+
57
+ if (json === undefined) throw new TypeError("Cache key must be JSON serializable");
58
+
59
+ return stableKeyHash(json);
60
+ }
61
+
42
62
  /**
43
63
  * Resolves a cache entry's on-disk path and refuses to escape the cache
44
64
  * directory, guarding against a `keyToPath` that returns `..` segments. The
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { getMarkdownTheme, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
- import { Markdown, Text } from "@earendil-works/pi-tui";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
3
  import { Type } from "typebox";
4
4
  import {
5
5
  FETCH_MAX_OFFSET_CHARACTERS,
@@ -7,7 +7,7 @@ import {
7
7
  FETCH_MAX_URL_CHARACTERS,
8
8
  } from "./limits";
9
9
  import { redactUrlForDisplay } from "./redact";
10
- import { formatCollapsibleOutput } from "./render";
10
+ import { renderCollapsibleOutput } from "./render";
11
11
  import { executeWebFetch, webFetchOutputSchema, type WebFetchDetails } from "./service";
12
12
 
13
13
  export { ExpiringLruCache } from "./cache";
@@ -108,7 +108,7 @@ export default function (pi: ExtensionAPI) {
108
108
  /** Renders the redacted URL, optional focus query, and character offset in the call preview. */
109
109
  renderCall(args, theme) {
110
110
  return new Text(
111
- `${theme.fg("toolTitle", theme.bold("web_fetch"))} ${theme.fg("accent", redactUrlForDisplay(args.url))}`,
111
+ `${theme.fg("toolTitle", theme.bold("web_fetch"))} ${theme.fg("accent", redactUrlForDisplay(args.url))}${args.query === undefined ? "" : theme.fg("muted", ` query=${JSON.stringify(args.query)}`)}`,
112
112
  0,
113
113
  0,
114
114
  );
@@ -122,15 +122,21 @@ export default function (pi: ExtensionAPI) {
122
122
  * Renders partial text or "Fetching…" while pending. Completed text gets a
123
123
  * detail summary when available and a collapsible body; absent text shows "No content".
124
124
  */
125
- renderResult(result, { expanded, isPartial }, theme) {
125
+ renderResult(result, { expanded, isPartial }, theme, context) {
126
126
  const content = result.content.find((item) => item.type === "text");
127
127
 
128
128
  if (isPartial) return new Text(theme.fg("warning", content?.text || "Fetching…"), 0, 0);
129
129
 
130
130
  return content?.type === "text"
131
- ? new Markdown(formatCollapsibleOutput(content.text, expanded), 0, 0, getMarkdownTheme(), {
132
- color: (text) => theme.fg("toolOutput", text),
133
- })
131
+ ? renderCollapsibleOutput(
132
+ content.text,
133
+ expanded,
134
+ theme,
135
+ context?.isError,
136
+ result.details?.truncated && result.details.nextOffset !== undefined
137
+ ? `[Content truncated. Continue with offset=${result.details.nextOffset} to read the next chunk.]`
138
+ : undefined,
139
+ )
134
140
  : new Text(theme.fg("dim", "No content"), 0, 0);
135
141
  },
136
142
  });
package/src/render.ts CHANGED
@@ -1,21 +1,46 @@
1
- import { keyHint } from "@earendil-works/pi-coding-agent";
1
+ import { getMarkdownTheme, keyHint } from "@earendil-works/pi-coding-agent";
2
+ import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
2
3
 
3
4
  const COLLAPSED_LINES = 10;
4
5
 
5
- /** Format tool output using Pi's built-in collapsed-preview convention. */
6
- export function formatCollapsibleOutput(output: string, expanded: boolean): string {
6
+ /** Render document content separately from Pi-style interface notices. */
7
+ export function renderCollapsibleOutput(
8
+ output: string,
9
+ expanded: boolean,
10
+ theme: { fg(color: "toolOutput" | "muted" | "warning", text: string): string },
11
+ isError = false,
12
+ warning?: string,
13
+ ): Container {
7
14
  const lines = output.replace(/\r\n/g, "\n").split("\n");
8
15
 
9
16
  while (lines.at(-1) === "") lines.pop();
10
- const totalLines = lines.length;
11
- const visibleLines = expanded ? lines : lines.slice(0, COLLAPSED_LINES);
12
- let text = visibleLines.join("\n");
17
+ const visible = expanded ? lines : lines.slice(0, COLLAPSED_LINES);
18
+ const remaining = lines.length - visible.length;
19
+ const component = new Container();
20
+ const body = `\n${visible.join("\n")}`;
13
21
 
14
- const remaining = totalLines - visibleLines.length;
22
+ component.addChild(
23
+ isError
24
+ ? new Text(theme.fg("toolOutput", body), 0, 0)
25
+ : new Markdown(body, 0, 0, getMarkdownTheme(), {
26
+ color: (text) => theme.fg("toolOutput", text),
27
+ }),
28
+ );
15
29
 
16
30
  if (remaining > 0) {
17
- text += `\n... (${remaining} more lines, ${totalLines} total, ${keyHint("app.tools.expand", "to expand")})`;
31
+ component.addChild(
32
+ new Text(
33
+ `${theme.fg("muted", `... (${remaining} more lines, ${lines.length} total,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("muted", ")")}`,
34
+ 0,
35
+ 0,
36
+ ),
37
+ );
18
38
  }
19
39
 
20
- return `\n${text}`;
40
+ if (warning) {
41
+ component.addChild(new Spacer(1));
42
+ component.addChild(new Text(theme.fg("warning", warning), 0, 0));
43
+ }
44
+
45
+ return component;
21
46
  }
package/src/service.ts CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  truncateHead,
9
9
  type AgentToolResult,
10
10
  } from "@earendil-works/pi-coding-agent";
11
- import { ExpiringLruCache, stableKeyHash, type CachePersistence } from "./cache";
11
+ import { ExpiringLruCache, stableKeyHash, canonicalKey, type CachePersistence } from "./cache";
12
12
  import { sliceCompleteDocument, type CompleteDocument } from "./document/content.ts";
13
13
  import { revalidateCompleteDocument, type FetchRemoteDependencies } from "./acquisition/fetch.ts";
14
14
  import { fetchDocumentWithLlmsTxtSupport } from "./acquisition/llms.ts";
@@ -430,7 +430,8 @@ export async function executeWebFetch(
430
430
 
431
431
  const displayRequestedUrl = redactUrlForDisplay(params.url);
432
432
  const sourceUrl = urlWithoutFragment(params.url);
433
- let document = fetchCache.get(sourceUrl);
433
+ const cacheKey = canonicalKey({ url: sourceUrl });
434
+ let document = fetchCache.get(cacheKey);
434
435
  const now = Date.now();
435
436
 
436
437
  const fresh = Boolean(
@@ -459,7 +460,7 @@ export async function executeWebFetch(
459
460
  const stale = document;
460
461
 
461
462
  const acquisition = await inflightFetches.run(
462
- sourceUrl,
463
+ cacheKey,
463
464
  async (sharedSignal) => {
464
465
  let fetched: CompleteDocument;
465
466
  let acquisitionStatus: FetchAcquisition["cacheStatus"] = "miss";
@@ -479,7 +480,7 @@ export async function executeWebFetch(
479
480
  }
480
481
 
481
482
  if (!fetched.extractionDiagnostics?.botWall) {
482
- fetchCache.set(sourceUrl, fetched, Date.now() + CACHE_STALE_RETENTION_MS);
483
+ fetchCache.set(cacheKey, fetched, Date.now() + CACHE_STALE_RETENTION_MS);
483
484
  }
484
485
 
485
486
  return { document: fetched, cacheStatus: acquisitionStatus };