@zeldrisho/pi-web-fetch 0.9.2 → 0.9.3

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/src/service.ts CHANGED
@@ -9,12 +9,10 @@ import {
9
9
  } from "@earendil-works/pi-coding-agent";
10
10
  import { ExpiringLruCache, stableKeyHash, type CachePersistence } from "./cache";
11
11
  import { sliceCompleteDocument, type CompleteDocument } from "./content";
12
- import {
13
- fetchCompleteDocument,
14
- revalidateCompleteDocument,
15
- type FetchRemoteDependencies,
16
- } from "./fetch";
12
+ import { revalidateCompleteDocument, type FetchRemoteDependencies } from "./fetch";
13
+ import { fetchDocumentWithLlmsTxtSupport } from "./llms";
17
14
  import { focusMarkdown, type FocusDetails } from "./focus";
15
+ import { resolveFragmentOffset, urlWithoutFragment } from "./fragments";
18
16
  import { InflightCoalescer } from "./inflight";
19
17
  import { createDocumentOutline, type DocumentOutline } from "./outline";
20
18
  import { redactUrlForDisplay } from "./redact";
@@ -38,8 +36,6 @@ const CACHE_MAX_MARKDOWN_BYTES = 20 * 1_024 * 1_024;
38
36
  const MAX_INFLIGHT_REQUESTS = 100;
39
37
 
40
38
  /** Minimum extracted length for both the low-quality trigger and llms.txt acceptance. */
41
- const LLMS_TXT_MIN_MARKDOWN_CHARACTERS = 200;
42
-
43
39
  const encoder = new TextEncoder();
44
40
 
45
41
  /** Coarse classification of what kind of page a fetch returned. */
@@ -133,277 +129,7 @@ export function classifyConfidence(
133
129
  return markdownLength >= 200 ? "medium" : "low";
134
130
  }
135
131
 
136
- /**
137
- * Builds candidate `/llms.txt` URLs for a page: the site-root index plus each ancestor
138
- * directory index up to `MAX_LLMS_TXT_DIRECTORY_DEPTH` levels deep, mirroring sites that
139
- * publish per-section indexes (for example `developers.cloudflare.com/r2/llms.txt`).
140
- * Returns them ordered shallow → deep, and an empty list for non-HTTP(S) targets or when
141
- * the page itself is already a `llms.txt` path, so a fallback never retries itself.
142
- */
143
- function isGitHubLikeHost(hostname: string): boolean {
144
- return (
145
- hostname === "github.com" ||
146
- hostname === "raw.githubusercontent.com" ||
147
- hostname === "gist.github.com" ||
148
- hostname === "gist.githubusercontent.com"
149
- );
150
- }
151
-
152
- /**
153
- * Build candidate /llms.txt URLs to probe for a given page.
154
- *
155
- * Generates a list of potential /llms.txt index URLs by checking the site root
156
- * and ancestor directories up to a maximum depth. Returns URLs ordered from
157
- * shallowest to deepest.
158
- *
159
- * @param rawUrl - The original page URL
160
- * @returns Array of candidate /llms.txt URLs to probe (empty for non-HTTP/S or GitHub)
161
- */
162
- export function buildLlmsTxtCandidateUrls(rawUrl: string): URL[] {
163
- try {
164
- const url = new URL(rawUrl);
165
-
166
- if (url.protocol !== "https:" && url.protocol !== "http:") return [];
167
-
168
- if (isGitHubLikeHost(url.hostname)) return [];
169
-
170
- if (url.pathname === "/llms.txt" || url.pathname.endsWith("/llms.txt")) return [];
171
-
172
- // Ancestor directories only, one level deep: for `/r2/buckets/x` probe
173
- // `/llms.txt` and `/r2/llms.txt`, never deeper. A trailing slash marks the final
174
- // segment as a directory itself, so `/r2/` still probes `/r2/llms.txt`.
175
- const trimmedDirectory = url.pathname.endsWith("/")
176
- ? url.pathname
177
- : url.pathname.replace(/\/[^/]*$/, "/");
178
-
179
- const directories = ["/"];
180
- const parts = trimmedDirectory.split("/").filter(Boolean);
181
-
182
- for (let depth = 1; depth <= Math.min(MAX_LLMS_TXT_DIRECTORY_DEPTH, parts.length); depth += 1) {
183
- directories.push(`/${parts.slice(0, depth).join("/")}/`);
184
- }
185
-
186
- return directories.map((pathname) => {
187
- const candidate = new URL(url.origin);
188
- candidate.pathname = `${pathname}llms.txt`;
189
-
190
- return candidate;
191
- });
192
- } catch {
193
- return [];
194
- }
195
- }
196
-
197
- function isLowQualityDocument(document: CompleteDocument): boolean {
198
- return (
199
- document.shellSuspected ||
200
- (document.extractor !== "raw" && document.markdown.length < LLMS_TXT_MIN_MARKDOWN_CHARACTERS)
201
- );
202
- }
203
-
204
- /** A probed `/llms.txt` outcome for one candidate URL; an absent document means "unavailable". */
205
- interface LlmsTxtProbe {
206
- document?: CompleteDocument;
207
- expires: number;
208
- }
209
-
210
- const MAX_LLMS_TXT_PROBE_ENTRIES = 512;
211
-
212
- /** Deepest ancestor-directory `/llms.txt` probed, e.g. `/r2/x/y` probes `/r2/llms.txt`. */
213
- const MAX_LLMS_TXT_DIRECTORY_DEPTH = 1;
214
-
215
- const llmsTxtProbes = new ExpiringLruCache<string, LlmsTxtProbe>(
216
- MAX_LLMS_TXT_PROBE_ENTRIES,
217
- 8 * 1024 * 1024,
218
- (probe) => encoder.encode(JSON.stringify(probe)).byteLength,
219
- );
220
-
221
- const llmsTxtProbeRequests = new InflightCoalescer<string, LlmsTxtProbe>(
222
- MAX_LLMS_TXT_PROBE_ENTRIES,
223
- );
224
-
225
- function rememberLlmsTxtProbe(origin: string, probe: LlmsTxtProbe): void {
226
- llmsTxtProbes.set(origin, probe, probe.expires);
227
- }
228
-
229
- function isUsableLlmsTxtIndex(document: CompleteDocument): boolean {
230
- return (
231
- document.extractor === "raw" &&
232
- !document.shellSuspected &&
233
- document.markdown.length >= LLMS_TXT_MIN_MARKDOWN_CHARACTERS
234
- );
235
- }
236
-
237
- function toAbsoluteUrl(value: string): URL | undefined {
238
- try {
239
- return new URL(value);
240
- } catch {
241
- return undefined;
242
- }
243
- }
244
-
245
- /**
246
- * Returns the deepest usable `/llms.txt` index among the candidates, probing each
247
- * uncached candidate at most once per TTL window (negative results are cached too).
248
- * Any failure is treated as absence so a missing index can never degrade the primary
249
- * fetch.
250
- */
251
- async function ensureLlmsTxtIndex(
252
- candidates: URL[],
253
- signal: AbortSignal | undefined,
254
- dependencies: FetchRemoteDependencies,
255
- ): Promise<{ url: string; document: CompleteDocument } | undefined> {
256
- const documents = await Promise.all(
257
- candidates.map((candidate) => probeUsableRawText(candidate, signal, dependencies)),
258
- );
259
-
260
- // Candidates are ordered shallow → deep; prefer the deepest usable index because a
261
- // section index is the more relevant table of contents for the requested page.
262
- for (let depth = documents.length - 1; depth >= 0; depth -= 1) {
263
- const document = documents[depth];
264
-
265
- if (document) return { url: candidates[depth].href, document };
266
- }
267
-
268
- return undefined;
269
- }
270
-
271
- /**
272
- * Probes one URL at most once per TTL window (negative results are cached too) and
273
- * returns its document only when it is usable raw text. Used for `/llms.txt` indexes
274
- * and for advertised Markdown versions of a page. Any failure is treated as absence so
275
- * a missing resource can never degrade the primary fetch.
276
- */
277
- async function probeUsableRawText(
278
- candidate: URL,
279
- signal: AbortSignal | undefined,
280
- dependencies: FetchRemoteDependencies,
281
- ): Promise<CompleteDocument | undefined> {
282
- const cached = llmsTxtProbes.get(candidate.href);
283
-
284
- if (cached) return cached.document;
285
-
286
- const probe = await llmsTxtProbeRequests.run(
287
- candidate.href,
288
- async (sharedSignal) => {
289
- const existing = llmsTxtProbes.get(candidate.href);
290
-
291
- if (existing) return existing;
292
-
293
- try {
294
- const document = await fetchCompleteDocument(
295
- candidate.toString(),
296
- sharedSignal,
297
- dependencies,
298
- );
299
-
300
- const available = isUsableLlmsTxtIndex(document) ? document : undefined;
301
- const result = { document: available, expires: Date.now() + CACHE_TTL_MS };
302
- rememberLlmsTxtProbe(candidate.href, result);
303
-
304
- return result;
305
- } catch (error) {
306
- if (sharedSignal?.aborted) throw error;
307
- const result = { expires: Date.now() + CACHE_TTL_MS };
308
- rememberLlmsTxtProbe(candidate.href, result);
309
-
310
- return result;
311
- }
312
- },
313
- signal,
314
- "web_fetch was cancelled.",
315
- );
316
-
317
- return probe.document;
318
- }
319
-
320
- /**
321
- * Validates that a candidate URL is safe to probe: must use HTTP(S) and match the primary
322
- * page's origin. This prevents SSRF-like issues where a fetched page could embed meta tags
323
- * pointing to arbitrary/internal/cross-origin URLs.
324
- */
325
- function isSafeToProbe(candidate: URL | undefined, primaryUrl: string): boolean {
326
- if (!candidate) return false;
327
- const protocol = candidate.protocol;
328
-
329
- if (protocol !== "http:" && protocol !== "https:") return false;
330
- const primaryParsed = safeUrl(primaryUrl);
331
-
332
- if (!primaryParsed) return false;
333
-
334
- return candidate.origin === primaryParsed.origin;
335
- }
336
-
337
- /**
338
- * Fetches a page with llms.txt awareness:
339
- *
340
- * - Probes the site's `/llms.txt` index once per origin per TTL window, in parallel with
341
- * the primary fetch, so later calls can advertise it.
342
- * - Serves the index instead of the page when the page looks like an app shell or carries
343
- * too little readable text.
344
- * - Otherwise annotates the page with the index URL so agents can discover sibling pages.
345
- *
346
- * A missing or useless index can never degrade the primary fetch.
347
- */
348
- async function fetchDocumentWithLlmsTxtSupport(
349
- rawUrl: string,
350
- signal: AbortSignal | undefined,
351
- dependencies: FetchRemoteDependencies,
352
- ): Promise<CompleteDocument> {
353
- try {
354
- if (isGitHubLikeHost(new URL(rawUrl).hostname)) {
355
- return fetchCompleteDocument(rawUrl, signal, dependencies);
356
- }
357
- } catch {
358
- // Invalid URL falls through to fetchCompleteDocument which will throw.
359
- }
360
-
361
- const candidates = buildLlmsTxtCandidateUrls(rawUrl);
362
-
363
- const [primary, blindIndex] = await Promise.all([
364
- fetchCompleteDocument(rawUrl, signal, dependencies),
365
- candidates.length > 0
366
- ? ensureLlmsTxtIndex(candidates, signal, dependencies)
367
- : Promise.resolve(undefined),
368
- ]);
369
-
370
- // A `describedby` advertisement names the covering index authoritatively per
371
- // llmstxt.org v2, so it outranks the blind root/section probes whenever usable.
372
- let index = blindIndex;
373
-
374
- if (primary.llmsTxtDescribedBy) {
375
- const describedBy = toAbsoluteUrl(primary.llmsTxtDescribedBy);
376
-
377
- const described =
378
- describedBy && isSafeToProbe(describedBy, primary.url)
379
- ? await probeUsableRawText(describedBy, signal, dependencies)
380
- : undefined;
381
-
382
- if (described && describedBy) index = { url: describedBy.href, document: described };
383
- }
384
-
385
- if (isLowQualityDocument(primary)) {
386
- // The page's own advertised Markdown version is strictly better than an index.
387
- const markdownAlternate = primary.markdownAlternateUrl
388
- ? toAbsoluteUrl(primary.markdownAlternateUrl)
389
- : undefined;
390
-
391
- const alternate =
392
- markdownAlternate && isSafeToProbe(markdownAlternate, primary.url)
393
- ? await probeUsableRawText(markdownAlternate, signal, dependencies)
394
- : undefined;
395
-
396
- if (alternate && markdownAlternate) {
397
- return { ...alternate, markdownAlternateFallback: true };
398
- }
399
-
400
- if (index) return { ...index.document, llmsTxtFallback: true };
401
- } else if (index) {
402
- return { ...primary, llmsTxtIndexUrl: index.url };
403
- }
404
-
405
- return primary;
406
- }
132
+ export { buildLlmsTxtCandidateUrls } from "./llms";
407
133
 
408
134
  /** Resolves the private, cross-session cache directory for a web tool. */
409
135
  function resolveCacheDirectory(name: string): string {
@@ -553,87 +279,6 @@ interface FetchAcquisition {
553
279
 
554
280
  const inflightFetches = new InflightCoalescer<string, FetchAcquisition>(MAX_INFLIGHT_REQUESTS);
555
281
 
556
- /** Removes a URL fragment before acquisition so fragment variants share cache entries. */
557
- function urlWithoutFragment(rawUrl: string): string {
558
- try {
559
- const url = new URL(rawUrl);
560
- url.hash = "";
561
-
562
- return url.href;
563
- } catch {
564
- return rawUrl;
565
- }
566
- }
567
-
568
- interface ResolvedFragment {
569
- fragment?: string;
570
- offset?: number;
571
- endOffset?: number;
572
- }
573
-
574
- /** Converts a one-based source line and column into a Markdown character offset. */
575
- function sourceOffset(markdown: string, lineNumber: number, columnNumber = 1): number | undefined {
576
- if (lineNumber < 1 || columnNumber < 1) return undefined;
577
-
578
- const lines = markdown.split("\n");
579
-
580
- if (lineNumber > lines.length) return undefined;
581
-
582
- let offset = 0;
583
-
584
- for (let index = 0; index < lineNumber - 1; index += 1) offset += lines[index].length + 1;
585
-
586
- return offset + Math.min(columnNumber - 1, lines[lineNumber - 1].length);
587
- }
588
-
589
- /** Resolves heading and source-location URL fragments against a fetched document. */
590
- function resolveFragmentOffset(document: CompleteDocument, rawUrl: string): ResolvedFragment {
591
- let fragment: string | undefined;
592
-
593
- try {
594
- const hash = new URL(rawUrl).hash;
595
-
596
- if (hash) fragment = decodeURIComponent(hash.slice(1));
597
- } catch {
598
- return {};
599
- }
600
-
601
- if (!fragment) return {};
602
-
603
- const lineAnchor = /^L(\d+)(?:C(\d+))?(?:-L?(\d+)(?:C(\d+))?)?$/i.exec(fragment);
604
-
605
- if (lineAnchor) {
606
- const [, firstLine, firstColumn, lastLine, lastColumn] = lineAnchor;
607
- const offset = sourceOffset(document.markdown, Number(firstLine), Number(firstColumn ?? 1));
608
-
609
- if (offset === undefined) return { fragment };
610
-
611
- if (lastLine) {
612
- const endLine = Number(lastLine);
613
- const endColumn = lastColumn === undefined ? undefined : Number(lastColumn);
614
- const lines = document.markdown.split("\n");
615
- const line = lines[endLine - 1];
616
-
617
- if (line === undefined) return { fragment };
618
-
619
- const endOffset =
620
- endColumn === undefined
621
- ? sourceOffset(document.markdown, endLine, line.length + 1)
622
- : sourceOffset(document.markdown, endLine, endColumn + 1);
623
-
624
- return endOffset !== undefined && endOffset >= offset
625
- ? { fragment, offset, endOffset }
626
- : { fragment };
627
- }
628
-
629
- return { fragment, offset };
630
- }
631
-
632
- const offsets = document.fragmentOffsets ?? {};
633
-
634
- return { fragment, offset: offsets[fragment] ?? offsets[fragment.toLowerCase()] };
635
- }
636
-
637
282
  /** Executes a validated web fetch, including caching, focusing, and bounded pagination. */
638
283
  export async function executeWebFetch(
639
284
  params: WebFetchParameters,