@zeldrisho/pi-web-fetch 0.7.1 → 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.
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 {
@@ -329,6 +339,8 @@ function resolveCacheDirectory(name: string): string {
329
339
  /** Parameters for a web fetch operation. */
330
340
  export interface WebFetchParameters {
331
341
  url: string;
342
+ /** Optional query used to select matching Markdown sections before continuation slicing. */
343
+ query?: string;
332
344
  offset?: number;
333
345
  maxCharacters?: number;
334
346
  }
@@ -349,15 +361,24 @@ export interface WebFetchDetails {
349
361
  title?: string;
350
362
  extractor: CompleteDocument["extractor"];
351
363
  contentKind: ContentKind;
364
+ /** @deprecated Use extractionDiagnostics for explicit quality evidence. */
352
365
  shellSuspected: boolean;
366
+ extractionDiagnostics?: CompleteDocument["extractionDiagnostics"];
367
+ links?: CompleteDocument["links"];
353
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;
354
373
  /** True when the returned content is the site's /llms.txt served instead of the requested page. */
355
374
  llmsTxtFallback: boolean;
356
375
  /** True when the returned content is the page's advertised Markdown version served instead of a low-quality page. */
357
376
  markdownAlternateFallback: boolean;
358
377
  /** The site's /llms.txt index URL when one exists and the returned content is not that index itself. */
359
378
  llmsTxtUrl?: string;
379
+ /** True for a fresh cache hit or a successful 304 revalidation. */
360
380
  cached: boolean;
381
+ cacheStatus: CacheStatus;
361
382
  truncated: boolean;
362
383
  offset: number;
363
384
  nextOffset?: number;
@@ -385,7 +406,11 @@ const fetchCache = new ExpiringLruCache<string, CompleteDocument>(
385
406
  undefined,
386
407
  fetchCachePersistence,
387
408
  );
388
- 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);
389
414
 
390
415
  /**
391
416
  * Fetches a web page and returns formatted content with pagination and truncation metadata.
@@ -417,34 +442,66 @@ export async function executeWebFetch(
417
442
  `web_fetch maxCharacters must be an integer between ${FETCH_MIN_MAX_CHARACTERS} and ${FETCH_MAX_CHARACTERS}.`,
418
443
  );
419
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
+ }
420
453
  let document = fetchCache.get(params.url);
421
- 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";
422
459
  onUpdate?.({
423
460
  content: [
424
461
  {
425
462
  type: "text",
426
- 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}…`,
427
468
  },
428
469
  ],
429
470
  details: {},
430
471
  });
431
- if (!document) {
432
- document = await inflightFetches.run(
472
+ if (!fresh) {
473
+ const stale = document;
474
+ const acquisition = await inflightFetches.run(
433
475
  params.url,
434
476
  async (sharedSignal) => {
435
- const fetched = await fetchDocumentWithLlmsTxtSupport(
436
- params.url,
437
- sharedSignal,
438
- dependencies,
439
- );
440
- fetchCache.set(params.url, fetched, Date.now() + CACHE_TTL_MS);
441
- 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 };
442
493
  },
443
494
  signal,
444
495
  "web_fetch was cancelled.",
445
496
  );
497
+ document = acquisition.document;
498
+ cacheStatus = acquisition.cacheStatus;
446
499
  }
447
- 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);
448
505
  const requestedUrl = params.url;
449
506
  const finalUrl = result.url;
450
507
  const shellSuspected = result.shellSuspected;
@@ -471,8 +528,16 @@ export async function executeWebFetch(
471
528
  "",
472
529
  ]
473
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
+ : []),
474
539
  `<untrusted_web_content source=${JSON.stringify(result.url)}>`,
475
- result.markdown || "[The page contained no readable text.]",
540
+ result.markdown || (focused ? "" : "[The page contained no readable text.]"),
476
541
  "</untrusted_web_content>",
477
542
  ].join("\n");
478
543
  const outputTruncation = truncateHead(output, {
@@ -491,11 +556,16 @@ export async function executeWebFetch(
491
556
  extractor: result.extractor,
492
557
  contentKind,
493
558
  shellSuspected,
559
+ extractionDiagnostics: result.extractionDiagnostics,
560
+ links: result.links,
494
561
  confidence,
562
+ outline: createDocumentOutline(document.markdown),
563
+ focus: focused?.details,
495
564
  llmsTxtFallback: Boolean(result.llmsTxtFallback),
496
565
  markdownAlternateFallback: Boolean(result.markdownAlternateFallback),
497
566
  llmsTxtUrl: result.llmsTxtIndexUrl,
498
- cached,
567
+ cached: cacheStatus !== "miss",
568
+ cacheStatus,
499
569
  truncated,
500
570
  offset: result.offset,
501
571
  nextOffset: result.nextOffset,