@zeldrisho/pi-web-fetch 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.
@@ -4,6 +4,7 @@ import { isIP, type LookupFunction } from "node:net";
4
4
  import { formatSize } from "@earendil-works/pi-coding-agent";
5
5
  import type { ValidatedTarget } from "./network-policy";
6
6
 
7
+ /** Maximum size in bytes for raw HTTP response bodies. */
7
8
  export const FETCH_MAX_BYTES = 5 * 1_024 * 1_024;
8
9
 
9
10
  /**
@@ -59,12 +60,18 @@ async function requestOnce(
59
60
  address: string,
60
61
  signal: AbortSignal,
61
62
  attemptTimeoutMs: number,
63
+ headers: Readonly<Record<string, string>>,
62
64
  ): Promise<IncomingMessage> {
63
65
  const family = isIP(address);
64
66
  if (family !== 4 && family !== 6) throw new Error(`web_fetch could not resolve ${address}.`);
65
67
  const lookup: LookupFunction = (_hostname, options, callback) => {
66
- if (options.all) callback(null, [{ address, family }]);
67
- else callback(null, address, family);
68
+ // Match dns.lookup's asynchronous callback contract. Calling back inline
69
+ // lets an immediately unreachable address emit a socket error before
70
+ // node:http has finished installing its forwarding listeners.
71
+ queueMicrotask(() => {
72
+ if (options.all) callback(null, [{ address, family }]);
73
+ else callback(null, address, family);
74
+ });
68
75
  };
69
76
  const request = target.url.protocol === "https:" ? httpsRequest : httpRequest;
70
77
  return await new Promise((resolve, reject) => {
@@ -95,6 +102,7 @@ async function requestOnce(
95
102
  headers: {
96
103
  Accept: "text/markdown, text/html, text/plain, application/json;q=0.9, */*;q=0.1",
97
104
  "User-Agent": "Mozilla/5.0 (compatible; PiWebFetch/1.0; +https://pi.dev)",
105
+ ...headers,
98
106
  },
99
107
  },
100
108
  (response) => {
@@ -120,6 +128,8 @@ async function requestOnce(
120
128
  export interface RequestPinnedOptions {
121
129
  /** Per-address connect deadline used to fall back to the next address. */
122
130
  attemptTimeoutMs?: number;
131
+ /** Additional fixed request headers, used for conditional cache revalidation. */
132
+ headers?: Readonly<Record<string, string>>;
123
133
  }
124
134
 
125
135
  /**
@@ -140,7 +150,7 @@ export async function requestPinned(
140
150
  let lastError: unknown;
141
151
  for (const address of addresses) {
142
152
  try {
143
- return await requestOnce(target, address, signal, attemptTimeoutMs);
153
+ return await requestOnce(target, address, signal, attemptTimeoutMs, options.headers ?? {});
144
154
  } catch (error) {
145
155
  lastError = error;
146
156
  if (signal.aborted) throw error;
@@ -161,6 +171,18 @@ export function responseHeader(response: IncomingMessage, name: string): string
161
171
  return Array.isArray(value) ? value[0] : value;
162
172
  }
163
173
 
174
+ /**
175
+ * Read and buffer the complete response body.
176
+ *
177
+ * Enforces the maximum byte limit and respects abort signals during the read.
178
+ * Destroys the response stream if limits are exceeded or abort is signaled.
179
+ *
180
+ * @param response - HTTP response to read
181
+ * @param maxBytes - Maximum allowed response size in bytes
182
+ * @param signal - Optional abort signal to cancel the read
183
+ * @returns Buffered response body as bytes
184
+ * @throws When response exceeds maxBytes or abort signal fires
185
+ */
164
186
  export async function readResponseBytes(
165
187
  response: IncomingMessage,
166
188
  maxBytes: number,
@@ -208,6 +230,16 @@ export async function readResponseBytes(
208
230
  return output;
209
231
  }
210
232
 
233
+ /**
234
+ * Decode HTTP response bytes to a string using the charset from Content-Type.
235
+ *
236
+ * Extracts the charset parameter from the Content-Type header and uses it
237
+ * to decode the response. Falls back to UTF-8 if charset is missing or invalid.
238
+ *
239
+ * @param bytes - Raw response bytes to decode
240
+ * @param contentTypeHeader - Value of the Content-Type response header
241
+ * @returns Decoded response string
242
+ */
211
243
  export function decodeResponse(bytes: Uint8Array, contentTypeHeader: string): string {
212
244
  const charset = contentTypeHeader.match(/(?:^|;)\s*charset\s*=\s*["']?([^;"'\s]+)/i)?.[1];
213
245
  try {
package/src/outline.ts ADDED
@@ -0,0 +1,240 @@
1
+ const FENCE = /^[ \t]{0,3}(`{3,}|~{3,})/;
2
+ const MAX_FALLBACK_HEADING_CHARACTERS = 60;
3
+ export const MAX_OUTLINE_HEADINGS = 12;
4
+ export const MAX_OUTLINE_HEADING_CHARACTERS = 120;
5
+
6
+ /** One bounded heading exposed as untrusted document-shape metadata. */
7
+ export interface OutlineHeading {
8
+ level: number;
9
+ text: string;
10
+ words: number;
11
+ inferred: boolean;
12
+ }
13
+
14
+ /** Bounded structural summary of a complete extracted document. */
15
+ export interface DocumentOutline {
16
+ totalWords: number;
17
+ totalHeadings: number;
18
+ headings: OutlineHeading[];
19
+ omittedHeadings: number;
20
+ }
21
+
22
+ interface HeadingLocation {
23
+ level: number;
24
+ text: string;
25
+ line: number;
26
+ inferred: boolean;
27
+ }
28
+
29
+ interface HeadingCollection {
30
+ locations: HeadingLocation[];
31
+ total: number;
32
+ }
33
+
34
+ interface ParsedAtxHeading {
35
+ level: number;
36
+ text: string;
37
+ }
38
+
39
+ interface MarkdownFence {
40
+ character: string;
41
+ length: number;
42
+ }
43
+
44
+ function updateFence(line: string, fence: MarkdownFence | undefined): MarkdownFence | undefined {
45
+ const match = FENCE.exec(line);
46
+ if (!match) return fence;
47
+ const marker = match[1];
48
+ if (!fence) return { character: marker[0], length: marker.length };
49
+ const closes =
50
+ marker[0] === fence.character &&
51
+ marker.length >= fence.length &&
52
+ !line.slice(match[0].length).trim();
53
+ return closes ? undefined : fence;
54
+ }
55
+
56
+ /**
57
+ * Checks if a character is a space or tab.
58
+ *
59
+ * @param character - The character to check
60
+ * @returns `true` if the character is a space or tab
61
+ */
62
+ function isSpaceOrTab(character: string | undefined): boolean {
63
+ return character === " " || character === "\t";
64
+ }
65
+
66
+ /** Parses the bounded ATX prefix with a linear scan to avoid backtracking on remote input. */
67
+ function parseAtxHeading(line: string): ParsedAtxHeading | undefined {
68
+ let index = 0;
69
+ while (index < 3 && isSpaceOrTab(line[index])) index += 1;
70
+ if (isSpaceOrTab(line[index])) return undefined;
71
+
72
+ const markerStart = index;
73
+ while (line[index] === "#") index += 1;
74
+ const level = index - markerStart;
75
+ if (level < 1 || level > 6 || !isSpaceOrTab(line[index])) return undefined;
76
+
77
+ while (isSpaceOrTab(line[index])) index += 1;
78
+ return { level, text: line.slice(index) };
79
+ }
80
+
81
+ /**
82
+ * Counts the number of whitespace-separated words in a string.
83
+ *
84
+ * @param value - The string to count words in
85
+ * @returns The number of words, or 0 if the string is empty after trimming
86
+ */
87
+ function countWords(value: string): number {
88
+ const trimmed = value.trim();
89
+ return trimmed ? trimmed.split(/\s+/).length : 0;
90
+ }
91
+
92
+ /**
93
+ * Counts the total words in a document while respecting fenced code blocks.
94
+ *
95
+ * @param lines - The document lines to count words in
96
+ * @returns The total word count across all lines, including fenced content
97
+ */
98
+ function countDocumentWords(lines: string[]): number {
99
+ let fence: MarkdownFence | undefined;
100
+ return lines.reduce((total, line) => {
101
+ const wasInsideFence = Boolean(fence);
102
+ fence = updateFence(line, fence);
103
+ const heading = wasInsideFence || fence ? undefined : parseAtxHeading(line);
104
+ return total + countWords(heading ? cleanHeading(heading.text) : line);
105
+ }, 0);
106
+ }
107
+
108
+ /**
109
+ * Removes trailing whitespace and optional closing markers from heading text, then bounds the result.
110
+ *
111
+ * @param value - The raw heading text to clean
112
+ * @returns The cleaned and bounded heading text
113
+ */
114
+ function cleanHeading(value: string): string {
115
+ let contentEnd = value.length;
116
+ while (contentEnd > 0 && isSpaceOrTab(value[contentEnd - 1])) contentEnd -= 1;
117
+
118
+ let markerStart = contentEnd;
119
+ while (markerStart > 0 && value[markerStart - 1] === "#") markerStart -= 1;
120
+ if (markerStart < contentEnd && markerStart > 0 && isSpaceOrTab(value[markerStart - 1])) {
121
+ contentEnd = markerStart - 1;
122
+ while (contentEnd > 0 && isSpaceOrTab(value[contentEnd - 1])) contentEnd -= 1;
123
+ }
124
+
125
+ return value.slice(0, contentEnd).trim().slice(0, MAX_OUTLINE_HEADING_CHARACTERS);
126
+ }
127
+
128
+ /**
129
+ * Collects ATX headings from Markdown lines while respecting fenced code blocks.
130
+ *
131
+ * @param lines - The document lines to scan for ATX headings
132
+ * @returns Collection of heading locations and total heading count
133
+ */
134
+ function collectAtxHeadings(lines: string[]): HeadingCollection {
135
+ const locations: HeadingLocation[] = [];
136
+ let total = 0;
137
+ let fence: MarkdownFence | undefined;
138
+
139
+ for (const [lineNumber, line] of lines.entries()) {
140
+ const wasInsideFence = Boolean(fence);
141
+ fence = updateFence(line, fence);
142
+ if (wasInsideFence || fence) continue;
143
+ const heading = parseAtxHeading(line);
144
+ if (!heading) continue;
145
+ const text = cleanHeading(heading.text);
146
+ if (!text) continue;
147
+ total += 1;
148
+ if (locations.length <= MAX_OUTLINE_HEADINGS) {
149
+ locations.push({ level: heading.level, text, line: lineNumber, inferred: false });
150
+ }
151
+ }
152
+ return { locations, total };
153
+ }
154
+
155
+ /**
156
+ * Heuristically determines if a line looks like a heading when ATX headings are unavailable.
157
+ *
158
+ * @param line - The current line to evaluate
159
+ * @param nextLine - The following non-empty line for context
160
+ * @returns `true` if the line appears to be a heading
161
+ */
162
+ function looksLikeFallbackHeading(line: string, nextLine: string): boolean {
163
+ if (/^\s{4,}/.test(line)) return false;
164
+ const candidate = line.trim();
165
+ if (
166
+ !candidate ||
167
+ candidate.length > MAX_FALLBACK_HEADING_CHARACTERS ||
168
+ !/[A-Za-z]/.test(candidate) ||
169
+ /[.!?…,;:]$/.test(candidate) ||
170
+ /[,;]/.test(candidate) ||
171
+ /^([-*+]|\d+\.)\s/.test(candidate) ||
172
+ /^(>|\||https?:\/\/)/i.test(candidate)
173
+ ) {
174
+ return false;
175
+ }
176
+ if (!/^[A-Z]/.test(candidate) && !/[`()._]/.test(candidate)) return false;
177
+ const following = nextLine.trim();
178
+ return following.length > MAX_FALLBACK_HEADING_CHARACTERS || /[.!?]$/.test(following);
179
+ }
180
+
181
+ /**
182
+ * Collects inferred headings using heuristics when no ATX headings are present.
183
+ *
184
+ * @param lines - The document lines to scan for heading-like content
185
+ * @returns Collection of inferred heading locations and total count
186
+ */
187
+ function collectFallbackHeadings(lines: string[]): HeadingCollection {
188
+ const locations: HeadingLocation[] = [];
189
+ let total = 0;
190
+ const nextNonEmptyIndexes = new Int32Array(lines.length);
191
+ nextNonEmptyIndexes.fill(-1);
192
+ let nextNonEmptyIndex = -1;
193
+ for (let lineNumber = lines.length - 1; lineNumber >= 0; lineNumber -= 1) {
194
+ nextNonEmptyIndexes[lineNumber] = nextNonEmptyIndex;
195
+ if (lines[lineNumber].trim()) nextNonEmptyIndex = lineNumber;
196
+ }
197
+ let fence: MarkdownFence | undefined;
198
+
199
+ for (const [lineNumber, line] of lines.entries()) {
200
+ const wasInsideFence = Boolean(fence);
201
+ fence = updateFence(line, fence);
202
+ if (wasInsideFence || fence) continue;
203
+ const followingIndex = nextNonEmptyIndexes[lineNumber];
204
+ const following = followingIndex === -1 ? "" : lines[followingIndex];
205
+ if (looksLikeFallbackHeading(line, following)) {
206
+ total += 1;
207
+ if (locations.length <= MAX_OUTLINE_HEADINGS) {
208
+ locations.push({ level: 2, text: cleanHeading(line), line: lineNumber, inferred: true });
209
+ }
210
+ }
211
+ }
212
+ return { locations, total };
213
+ }
214
+
215
+ /**
216
+ * Builds a bounded, fence-aware outline from complete extracted Markdown.
217
+ *
218
+ * ATX headings are preferred. Only documents with no ATX headings use conservative plain-text
219
+ * heading inference. Heading text is remote, untrusted metadata and is bounded before exposure.
220
+ */
221
+ export function createDocumentOutline(markdown: string): DocumentOutline {
222
+ const lines = markdown.split("\n");
223
+ const explicit = collectAtxHeadings(lines);
224
+ const collection = explicit.total > 0 ? explicit : collectFallbackHeadings(lines);
225
+ const headings = collection.locations.slice(0, MAX_OUTLINE_HEADINGS).map((heading, index) => {
226
+ const next = collection.locations[index + 1]?.line ?? lines.length;
227
+ return {
228
+ level: heading.level,
229
+ text: heading.text,
230
+ words: countWords(lines.slice(heading.line + 1, next).join("\n")),
231
+ inferred: heading.inferred,
232
+ };
233
+ });
234
+ return {
235
+ totalWords: countDocumentWords(lines),
236
+ totalHeadings: collection.total,
237
+ headings,
238
+ omittedHeadings: Math.max(0, collection.total - headings.length),
239
+ };
240
+ }
package/src/service.ts CHANGED
@@ -7,17 +7,25 @@ import {
7
7
  } from "@earendil-works/pi-coding-agent";
8
8
  import { ExpiringLruCache, stableKeyHash, type CachePersistence } from "./cache";
9
9
  import { sliceCompleteDocument, type CompleteDocument } from "./content";
10
- import { fetchCompleteDocument, type FetchRemoteDependencies } from "./fetch";
10
+ import {
11
+ fetchCompleteDocument,
12
+ revalidateCompleteDocument,
13
+ type FetchRemoteDependencies,
14
+ } from "./fetch";
15
+ import { focusMarkdown, type FocusDetails } from "./focus";
11
16
  import { InflightCoalescer } from "./inflight";
17
+ import { createDocumentOutline, type DocumentOutline } from "./outline";
12
18
  import {
13
19
  FETCH_DEFAULT_MAX_CHARACTERS,
14
20
  FETCH_DEFAULT_OFFSET,
15
21
  FETCH_MAX_CHARACTERS,
16
22
  FETCH_MAX_OFFSET_CHARACTERS,
23
+ FETCH_MAX_QUERY_CHARACTERS,
17
24
  FETCH_MIN_MAX_CHARACTERS,
18
25
  } from "./limits";
19
26
 
20
27
  const CACHE_TTL_MS = 24 * 60 * 60 * 1_000;
28
+ const CACHE_STALE_RETENTION_MS = 7 * CACHE_TTL_MS;
21
29
  const CACHE_MAX_ENTRIES = 100;
22
30
  const CACHE_MAX_MARKDOWN_BYTES = 20 * 1_024 * 1_024;
23
31
  const MAX_INFLIGHT_REQUESTS = 100;
@@ -38,6 +46,8 @@ export type ContentKind =
38
46
 
39
47
  /** Confidence that the returned content faithfully represents the source page. */
40
48
  export type FetchConfidence = "high" | "medium" | "low";
49
+ /** Cache evidence for this call. */
50
+ export type CacheStatus = "hit" | "revalidated" | "miss";
41
51
 
42
52
  function safeUrl(value: string): URL | undefined {
43
53
  try {
@@ -47,6 +57,17 @@ function safeUrl(value: string): URL | undefined {
47
57
  }
48
58
  }
49
59
 
60
+ /**
61
+ * Classify the kind of content returned by a fetch operation.
62
+ *
63
+ * Determines the content type based on URL patterns, extractor used,
64
+ * and whether an app shell was detected.
65
+ *
66
+ * @param url - The fetched URL
67
+ * @param extractor - Extraction method used
68
+ * @param shellSuspected - Whether the page appears to be an app shell
69
+ * @returns Content classification
70
+ */
50
71
  export function classifyContentKind(
51
72
  url: string,
52
73
  extractor: CompleteDocument["extractor"],
@@ -69,6 +90,17 @@ export function classifyContentKind(
69
90
  return "unknown";
70
91
  }
71
92
 
93
+ /**
94
+ * Classify confidence level for fetched content quality.
95
+ *
96
+ * Determines how confident we are that the extracted content accurately
97
+ * represents the source page, based on extraction method and content length.
98
+ *
99
+ * @param extractor - Extraction method used
100
+ * @param shellSuspected - Whether the page appears to be an app shell
101
+ * @param markdownLength - Length of extracted markdown content
102
+ * @returns Confidence level (high, medium, or low)
103
+ */
72
104
  export function classifyConfidence(
73
105
  extractor: CompleteDocument["extractor"],
74
106
  shellSuspected: boolean,
@@ -87,10 +119,30 @@ export function classifyConfidence(
87
119
  * Returns them ordered shallow → deep, and an empty list for non-HTTP(S) targets or when
88
120
  * the page itself is already a `llms.txt` path, so a fallback never retries itself.
89
121
  */
122
+ function isGitHubLikeHost(hostname: string): boolean {
123
+ return (
124
+ hostname === "github.com" ||
125
+ hostname === "raw.githubusercontent.com" ||
126
+ hostname === "gist.github.com" ||
127
+ hostname === "gist.githubusercontent.com"
128
+ );
129
+ }
130
+
131
+ /**
132
+ * Build candidate /llms.txt URLs to probe for a given page.
133
+ *
134
+ * Generates a list of potential /llms.txt index URLs by checking the site root
135
+ * and ancestor directories up to a maximum depth. Returns URLs ordered from
136
+ * shallowest to deepest.
137
+ *
138
+ * @param rawUrl - The original page URL
139
+ * @returns Array of candidate /llms.txt URLs to probe (empty for non-HTTP/S or GitHub)
140
+ */
90
141
  export function buildLlmsTxtCandidateUrls(rawUrl: string): URL[] {
91
142
  try {
92
143
  const url = new URL(rawUrl);
93
144
  if (url.protocol !== "https:" && url.protocol !== "http:") return [];
145
+ if (isGitHubLikeHost(url.hostname)) return [];
94
146
  if (url.pathname === "/llms.txt" || url.pathname.endsWith("/llms.txt")) return [];
95
147
  // Ancestor directories only, one level deep: for `/r2/buckets/x` probe
96
148
  // `/llms.txt` and `/r2/llms.txt`, never deeper. A trailing slash marks the final
@@ -232,6 +284,13 @@ async function fetchDocumentWithLlmsTxtSupport(
232
284
  signal: AbortSignal | undefined,
233
285
  dependencies: FetchRemoteDependencies,
234
286
  ): Promise<CompleteDocument> {
287
+ try {
288
+ if (isGitHubLikeHost(new URL(rawUrl).hostname)) {
289
+ return fetchCompleteDocument(rawUrl, signal, dependencies);
290
+ }
291
+ } catch {
292
+ // Invalid URL falls through to fetchCompleteDocument which will throw.
293
+ }
235
294
  const candidates = buildLlmsTxtCandidateUrls(rawUrl);
236
295
  const [primary, blindIndex] = await Promise.all([
237
296
  fetchCompleteDocument(rawUrl, signal, dependencies),
@@ -277,18 +336,23 @@ function resolveCacheDirectory(name: string): string {
277
336
  return base;
278
337
  }
279
338
 
339
+ /** Parameters for a web fetch operation. */
280
340
  export interface WebFetchParameters {
281
341
  url: string;
342
+ /** Optional query used to select matching Markdown sections before continuation slicing. */
343
+ query?: string;
282
344
  offset?: number;
283
345
  maxCharacters?: number;
284
346
  }
285
347
 
348
+ /** Details about content truncation and pagination strategy. */
286
349
  export interface WebFetchTruncationDetails {
287
350
  truncated: boolean;
288
351
  strategy: "continuation" | "none";
289
352
  nextOffset?: number;
290
353
  }
291
354
 
355
+ /** Comprehensive metadata about a web fetch result. */
292
356
  export interface WebFetchDetails {
293
357
  url: string;
294
358
  requestedUrl: string;
@@ -297,15 +361,24 @@ export interface WebFetchDetails {
297
361
  title?: string;
298
362
  extractor: CompleteDocument["extractor"];
299
363
  contentKind: ContentKind;
364
+ /** @deprecated Use extractionDiagnostics for explicit quality evidence. */
300
365
  shellSuspected: boolean;
366
+ extractionDiagnostics?: CompleteDocument["extractionDiagnostics"];
367
+ links?: CompleteDocument["links"];
301
368
  confidence: FetchConfidence;
369
+ /** Bounded document-shape metadata for the complete, unfiltered document. Heading text is untrusted remote content. */
370
+ outline: DocumentOutline;
371
+ /** Deterministic focused-extraction evidence when a query was supplied. */
372
+ focus?: FocusDetails;
302
373
  /** True when the returned content is the site's /llms.txt served instead of the requested page. */
303
374
  llmsTxtFallback: boolean;
304
375
  /** True when the returned content is the page's advertised Markdown version served instead of a low-quality page. */
305
376
  markdownAlternateFallback: boolean;
306
377
  /** The site's /llms.txt index URL when one exists and the returned content is not that index itself. */
307
378
  llmsTxtUrl?: string;
379
+ /** True for a fresh cache hit or a successful 304 revalidation. */
308
380
  cached: boolean;
381
+ cacheStatus: CacheStatus;
309
382
  truncated: boolean;
310
383
  offset: number;
311
384
  nextOffset?: number;
@@ -333,7 +406,11 @@ const fetchCache = new ExpiringLruCache<string, CompleteDocument>(
333
406
  undefined,
334
407
  fetchCachePersistence,
335
408
  );
336
- const inflightFetches = new InflightCoalescer<string, CompleteDocument>(MAX_INFLIGHT_REQUESTS);
409
+ interface FetchAcquisition {
410
+ document: CompleteDocument;
411
+ cacheStatus: Exclude<CacheStatus, "hit">;
412
+ }
413
+ const inflightFetches = new InflightCoalescer<string, FetchAcquisition>(MAX_INFLIGHT_REQUESTS);
337
414
 
338
415
  /**
339
416
  * Fetches a web page and returns formatted content with pagination and truncation metadata.
@@ -365,34 +442,66 @@ export async function executeWebFetch(
365
442
  `web_fetch maxCharacters must be an integer between ${FETCH_MIN_MAX_CHARACTERS} and ${FETCH_MAX_CHARACTERS}.`,
366
443
  );
367
444
  }
445
+ if (
446
+ params.query !== undefined &&
447
+ (params.query.trim().length === 0 || params.query.length > FETCH_MAX_QUERY_CHARACTERS)
448
+ ) {
449
+ throw new Error(
450
+ `web_fetch query must contain between 1 and ${FETCH_MAX_QUERY_CHARACTERS} characters.`,
451
+ );
452
+ }
368
453
  let document = fetchCache.get(params.url);
369
- const cached = document !== undefined;
454
+ const now = Date.now();
455
+ const fresh = Boolean(
456
+ document && (document.cachedAt === undefined || now - document.cachedAt < CACHE_TTL_MS),
457
+ );
458
+ let cacheStatus: CacheStatus = fresh ? "hit" : "miss";
370
459
  onUpdate?.({
371
460
  content: [
372
461
  {
373
462
  type: "text",
374
- text: cached ? `Using cached content for ${params.url}…` : `Fetching ${params.url}…`,
463
+ text: fresh
464
+ ? `Using cached content for ${params.url}…`
465
+ : document
466
+ ? `Revalidating cached content for ${params.url}…`
467
+ : `Fetching ${params.url}…`,
375
468
  },
376
469
  ],
377
470
  details: {},
378
471
  });
379
- if (!document) {
380
- document = await inflightFetches.run(
472
+ if (!fresh) {
473
+ const stale = document;
474
+ const acquisition = await inflightFetches.run(
381
475
  params.url,
382
476
  async (sharedSignal) => {
383
- const fetched = await fetchDocumentWithLlmsTxtSupport(
384
- params.url,
385
- sharedSignal,
386
- dependencies,
387
- );
388
- fetchCache.set(params.url, fetched, Date.now() + CACHE_TTL_MS);
389
- return fetched;
477
+ let fetched: CompleteDocument;
478
+ let acquisitionStatus: FetchAcquisition["cacheStatus"] = "miss";
479
+ if (stale?.validators && !stale.llmsTxtFallback && !stale.markdownAlternateFallback) {
480
+ const outcome = await revalidateCompleteDocument(
481
+ params.url,
482
+ stale,
483
+ sharedSignal,
484
+ dependencies,
485
+ );
486
+ fetched = outcome.document;
487
+ acquisitionStatus = outcome.revalidated ? "revalidated" : "miss";
488
+ } else {
489
+ fetched = await fetchDocumentWithLlmsTxtSupport(params.url, sharedSignal, dependencies);
490
+ }
491
+ fetchCache.set(params.url, fetched, Date.now() + CACHE_STALE_RETENTION_MS);
492
+ return { document: fetched, cacheStatus: acquisitionStatus };
390
493
  },
391
494
  signal,
392
495
  "web_fetch was cancelled.",
393
496
  );
497
+ document = acquisition.document;
498
+ cacheStatus = acquisition.cacheStatus;
394
499
  }
395
- const result = sliceCompleteDocument(document, offset, maxCharacters);
500
+ if (!document) throw new Error("web_fetch failed to acquire a document.");
501
+ const focused =
502
+ params.query === undefined ? undefined : focusMarkdown(document.markdown, params.query);
503
+ const outputDocument = focused ? { ...document, markdown: focused.markdown } : document;
504
+ const result = sliceCompleteDocument(outputDocument, offset, maxCharacters);
396
505
  const requestedUrl = params.url;
397
506
  const finalUrl = result.url;
398
507
  const shellSuspected = result.shellSuspected;
@@ -419,8 +528,16 @@ export async function executeWebFetch(
419
528
  "",
420
529
  ]
421
530
  : []),
531
+ ...(focused
532
+ ? [
533
+ focused.details.matchedSections > 0
534
+ ? `[Showing ${focused.details.matchedSections} of ${focused.details.totalSections} source sections selected by the focus query. Offsets apply to this focused view; omit query to read the complete document.]`
535
+ : "[No source sections matched the focus query. Omit query to read the complete document.]",
536
+ "",
537
+ ]
538
+ : []),
422
539
  `<untrusted_web_content source=${JSON.stringify(result.url)}>`,
423
- result.markdown || "[The page contained no readable text.]",
540
+ result.markdown || (focused ? "" : "[The page contained no readable text.]"),
424
541
  "</untrusted_web_content>",
425
542
  ].join("\n");
426
543
  const outputTruncation = truncateHead(output, {
@@ -439,11 +556,16 @@ export async function executeWebFetch(
439
556
  extractor: result.extractor,
440
557
  contentKind,
441
558
  shellSuspected,
559
+ extractionDiagnostics: result.extractionDiagnostics,
560
+ links: result.links,
442
561
  confidence,
562
+ outline: createDocumentOutline(document.markdown),
563
+ focus: focused?.details,
443
564
  llmsTxtFallback: Boolean(result.llmsTxtFallback),
444
565
  markdownAlternateFallback: Boolean(result.markdownAlternateFallback),
445
566
  llmsTxtUrl: result.llmsTxtIndexUrl,
446
- cached,
567
+ cached: cacheStatus !== "miss",
568
+ cacheStatus,
447
569
  truncated,
448
570
  offset: result.offset,
449
571
  nextOffset: result.nextOffset,