/** * tm_webfetch — governed web fetching (the sanctioned network channel). * * Two-channel design: user-configured MCP/plugin tools (browser automation, * search, page fetchers riding the host tool surface) are the HIGH-priority * channel — the whitelist never touches them. tm_webfetch is the governed * FALLBACK: a domain-allowlisted fetch whose output rides the SAME * governance as the other tm_* tools (threshold offload, content-aware * preview, tm_fetch handle) so a web page can never flood the context. * * Red lines carried over from the P5 network-zero era: * - only http(s) URLs — file:// and every other scheme rejected outright; * - the HOST must be on the allowlist (seeded with the CN-reachable * lookup hosts: moegirl wiki / bilibili / bing CN+int / baidu / sogou / * 360 / npm registry / GitHub API — see config.ts * DEFAULT_WEBFETCH_DOMAINS; TM_WEBFETCH_ALLOWED_DOMAINS overrides, "*" * opens every host); * - redirects are followed MANUALLY and every hop re-checked against the * allowlist (an allowlisted shortener cannot bounce off-site); * - a URL naming an env file (isEnvFilePath) is refused — the R6 red * line applies to remote spellings too; * - response body capped at 2 MB, each request under a hard 20 s timeout, * HTML stripped to text before governance; * - a FAILED REQUEST (network throw, per-attempt timeout, HTTP 429/5xx) is * retried on the fixed 5s/10s/20s ladder (max 3 retries, same config the * provider-error governor reads) and the reply says when it was used — * a policy refusal (allowlist, red line, env-file, 403/418, other 4xx) is * never re-asked, because asking again earns the same verdict. */ import { type TmAskFn } from "./perm-ask.js"; import type { ToolResult } from "../types.js"; import { type TmConfig } from "./config.js"; /** T4 (tm_search quality upgrade): the two new no-key engine hosts. * DEFAULT_WEBFETCH_DOMAINS itself lives in config.ts (owned by the P0 * batch — NOT editable by this package), so the seed extension is applied * consumer-side: `seedWebfetchDomains` appends these ONLY to the built-in * default list. A narrowed TM_WEBFETCH_ALLOWED_DOMAINS is never widened * (an out-of-allowlist engine host still routes to the official dialog). */ export declare const T4_SEEDED_DOMAINS: readonly string[]; export declare function seedWebfetchDomains(allowlist: readonly string[]): string[]; import { type TmPipelines } from "./pipelines.js"; import type { WebCache } from "./cache.js"; /** * Markdown-first, for the ONE path that reads a whole page: tm_webfetch. * Measured on the user's own connection, 3 samples each (2026-09-24): * `learn.microsoft.com` answers this header with `text/markdown` at **11 449 B * where the browser-shaped request gets 60 778 B of HTML** — a 5.3× cut on a * seeded documentation host, for free. Every other host tried (MDN, * docs.python.org, cn.bing.com SERP, csdn, zhihu) returns the SAME document * either way, so asking costs nothing where the hint is ignored; the csdn 521 * seen once also happened on the browser-shaped request, so it is not * negotiation-related. * The tail stays byte-identical to Chrome's (`image/avif`, `image/webp`) — the * header is part of the fingerprint the UA disguise buys us, so only the * preference is added, never the shape changed. tm_search's engine legs keep * `WEBFETCH_ACCEPT` verbatim: the 2026-09-14 anti-bot benchmark calibrated * exactly that string, and a SERP is not a document. */ export declare const WEBFETCH_ACCEPT_MD = "text/markdown,text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8"; /** True when `hostname` is the domain itself or a subdomain of it. */ export declare function hostAllowed(hostname: string, allowlist: readonly string[]): boolean; export type UrlVerdict = { ok: true; url: URL; via?: "explicit-host" | "private-allowed" | "loopback"; } | { ok: false; message: string; /** Allowlist misses are ASKABLE (official dialog via ctx.ask); scheme / * env-file / URL-shape violations are hard red lines, never * dialog-governed. */ askable?: boolean; url?: URL; }; export type PrivateSpacePolicy = "ask" | "allow" | "deny"; export declare function setPrivateSpacePolicy(raw: unknown): PrivateSpacePolicy; export declare function privateSpacePolicy(): PrivateSpacePolicy; /** * Validate a fetch target: absolute http(s) URL, host on the allowlist * ("*" opens every host), and never an env-file spelling (R6 red line on * remote `.env` / shell-rc names). */ export declare function checkWebUrl(raw: unknown, allowlist: readonly string[]): UrlVerdict; /** Crude but dependency-free HTML → text (script/style/comments stripped, * block tags become newlines, common entities decoded). */ export declare function htmlToText(html: string): string; /** Search-engine result pages whose raw HTML is worth collapsing into a * clean hit list (title + URL) instead of a wall of stripped page text. */ export declare const SEARCH_PAGE_RE: RegExp; /** BING WRAPS EVERY RESULT of its international layout (`…&ensearch=1`) in * `https://www.bing.com/ck/a?...&u=a1`. Dropping those * anchors (what ENGINE_TRACKER_RE does) is why the 2026-09-14 benchmark * recorded "bing-int serves an anti-bot shell / 100% empty" — the shell was * fine, our extractor threw every hit away (re-probed 2026-09-19: 10 real * `b_algo` results, 0 extracted). Decode the wrapper so the hit survives; * anything unrecognised still falls through to the skip. */ export declare function decodeEngineWrapperUrl(href: string): string | null; /** Domains that collide with common search terms (e.g. maimai.cn is the * Chinese professional-networking site 脉脉, NOT the SEGA maimai DX rhythm * game — a real session showed bing returning 10/10 maimai.cn hits for * every maimai DX query). Extend via the `hitBlacklist` config key * (merged with the built-in default). */ export declare const HIT_DOMAIN_BLACKLIST_DEFAULT: readonly string[]; /** Set the extra hit-domain blacklist (from `config.hitBlacklist`). Returns * the normalized list. */ export declare function setHitBlacklist(list: readonly string[]): string[]; export declare function hitDomainBlacklist(): string[]; export interface SearchHit { title: string; url: string; /** 1-2 line summary when the ENGINE carries one (bing b_caption, SO's * score/tags composite, HN's points/comments) — never fabricated. */ snippet?: string; /** Which engine produced the hit (set by tm_search auto-fusion only). */ source?: string; /** 1-based fused rank (auto-fusion only). */ rank?: number; /** False when the host is outside the fetch allowlist (auto-fusion only) — * following it will open an approval dialog, so the agent should prefer a * hit it can actually read. Undefined = not evaluated. */ fetchable?: boolean; } /** * Pull external result hits (title + URL [+ engine snippet when present]) * out of a search-engine result page's RAW HTML. Dependency-free: anchor * scan + noise filters (relative hrefs, click trackers, engine chrome, * duplicates). Engines rearrange their markup often, so the filter set is * intentionally loose — callers fall back to plain text extraction when * the list comes back thin. */ export declare function extractSearchHits(html: string, max?: number, blacklist?: readonly string[]): SearchHit[]; /** Compact numbered rendering — what actually enters the context. Carries * the engine-provided snippet (one line) and the fusion source tag when * the caller supplied them; plain title+URL otherwise. */ /** * Today, in the words the model reads — computed at render time, never baked * into a constant. * * The desktop is a long-lived process (the README's whole "quit and restart" * section exists because of it), so anything that captures a date at plugin * startup goes stale precisely in the sessions that run for days — and a stale * "current month" is what makes a model search `react latest version 2025` in * 2026. ZCode solves this on its side with `get description()`, re-rendered per * read; we cannot copy that surface, because this plugin has no evidence that * the host re-reads a tool description after registration (capabilities.ts would * grade that `unverified`, and acting on it would be a guess). The REPLY, on * the other hand, is ours to build on every call — so the date lives there, on * the one header every search route renders. ~20 tokens per lookup, once. */ export declare function searchDateLine(now?: Date): string; export declare function renderSearchHits(query: string, engine: string, hits: SearchHit[]): string; export interface WebFetchResult { text: string; contentType: string; finalUrl: string; /** Set when the body came from the URL cache instead of the network — the * reply says so, because a re-served page is not a freshly-verified one. */ cachedAt?: number; /** The HTTP retries actually taken before this body arrived (absent = the * first request answered). Rides up to the reply so 第N次重试后成功 is a * measurement of what happened, not a restatement of the config. */ retries?: number[]; } export type FetchImpl = (input: string, init?: Record) => Promise<{ ok?: boolean; status: number; headers: { get(name: string): string | null; }; body?: { getReader(): ReadableStreamDefaultReader; } | null; arrayBuffer?: () => Promise; text?: () => Promise; }>; /** A 429/503 that says WHEN, not just NO. Only the delta-seconds spelling * becomes a sentence: an HTTP-date would be laundered into a countdown the * agent has no way to honor (there is no sleep in this toolset), and a wrong * number is worse than no number. * `retriesTaken` keeps the advice consistent with what this tool just DID: * telling the agent "别对同一 URL 原地重试" while the reply carries our own * 「重试3次」 is one sentence contradicting the other (review finding #4). * Either way the Retry-After number stays visible. */ export declare function retryAfterNote(raw: unknown, retriesTaken?: number): string; /** Retries per request: attempt #1 waits 5s, #2 waits 10s, #3 waits 20s, then * the failure is reported. Same ladder the provider-error governor promises, * derived from the SAME config (`retryBaseMs` doubling, no jitter), so the two * failure surfaces cannot drift into two different meanings of "retry". */ export declare const HTTP_RETRY_MAX = 3; /** A transient HTTP failure worth asking again: 429 (rate limited) and any 5xx. * Everything else in the 4xx band is a VERDICT about this request — a retry * would earn the same bytes, and 403/418 carry their own browser directive. */ export declare function isRetryableHttpStatus(status: number): boolean; /** Pure so a test can pin the ladder without sleeping: retry `attempt` (1-based) * waits `baseMs × 2^(attempt-1)` clamped to `maxMs`; attempt 0 or anything past * `HTTP_RETRY_MAX` waits nothing (0 = no further retry). The clamp is what * makes the docs' promise true (review finding #3): with `retryBaseMs > 15000` * the un-clamped third rung (base×4) exceeded `retryMaxMs`, while the `retry` * key says BOTH surfaces share one capped ladder. Defaults read from the * registry, so the shipped path stays exactly 5s/10s/20s (all under 60s). */ export declare function httpRetryDelayMs(attempt: number, baseMs?: number, maxMs?: number): number; export interface HttpRetryPolicy { enabled: boolean; baseMs: number; maxRetries: number; /** One rung's ceiling — the registry's `retryMaxMs`, threaded so the ladder * and the governor cannot drift into two meanings of "capped". */ maxMs: number; } /** The registry is the one source of truth: `retry: "off"` disables the HTTP * retries as well as the governor's directive, and `retryBaseMs` sets the * first wait. Callers holding a resolved `TmConfig` pass it in; a caller with * none (the search engine legs' inner helper, a PTC bridge) gets the defaults. */ export declare function httpRetryFromConfig(cfg?: Partial>): HttpRetryPolicy; /** The same policy from an explicit per-call partial (what `fetchWebText`'s * `retry` option carries): every field falls back to the registry, never to a * guessed number, so a caller that passes `{}` retries exactly like the tool. * `maxRetries` is CLAMPED to HTTP_RETRY_MAX (review finding #5): the ladder has * three rungs, and `httpRetryDelayMs` answers 0 past them — an accepted 9 would * take six zero-delay "retries" the note renderers used to print as 「1s」, a * wait that never happened. */ export declare function resolveHttpRetry(policy?: Partial): HttpRetryPolicy; /** Honest labelling (design goal #6): a page that arrived on the third try is * not the same fact as a page that arrived on the first, and a failure after * three tries says how long the caller waited for nothing. Zero-delay entries * are filtered before rendering (review finding #5): a 0 is "the ladder was * already walked", and `Math.max(1, round(0/1000))` would print it as a 「1s」 * wait that never happened. With the maxRetries clamp these cannot reach the * list any more — this is the belt under the braces. */ export declare function httpRetryNote(delaysMs: readonly number[]): string; /** The failure counterpart — how many times, at what waits, still no answer. * Zero-delay entries filtered for the same reason as above. */ export declare function httpRetryFailNote(delaysMs: readonly number[]): string; /** * Fetch a URL with hard timeout, byte cap, and MANUAL redirects — every * hop is re-checked against the allowlist before it is requested. * `fetchImpl` is injectable for tests (default: globalThis.fetch), as is * `sleep` (default: setTimeout) so the ladder can be pinned without waiting * on it. * * HTTP retries: a failed request (network throw, per-attempt timeout, 429, * 5xx) is retried up to `HTTP_RETRY_MAX` times with the fixed 5s/10s/20s * ladder. The timeout stays PER ATTEMPT, so the worst case for one URL is * 4 × 20s of fetching plus 35s of waiting ≈ 115s — accepted deliberately: * a transient failure that clears in 20s should not cost the caller a new * round, and the reply says out loud when the ladder was used up. * A policy refusal is never retried, and a retry re-requests THIS hop only — * it never re-follows a redirect chain and never re-asks a dialog. */ export declare function fetchWebText(url: URL, allowlist: readonly string[], opts?: { timeoutMs?: number; maxBytes?: number; fetchImpl?: FetchImpl; /** When provided, an allowlist-missed hop drives the OFFICIAL permission * dialog (ctx.ask) instead of rejecting; approved hosts are remembered * for the rest of THIS fetch only. */ ask?: TmAskFn; /** Hosts already approved by the caller (e.g. the initial target) — * their hops pass without a second dialog. */ skipAskHosts?: Set; /** URL-level TTL cache (see tm/cache.ts). Consulted and written ONLY for * a hop the static allowlist admitted, so a hit can never bypass a * dialog the current config still requires. */ cache?: WebCache; /** Skip the READ, keep the WRITE. `fresh: true` means "do not hand me a * past observation" — it does not mean "throw the observation you just * made away". Skipping the whole cache object here used to forfeit the * write too, so a fresh fetch left nothing behind and the next caller paid * for the same network trip again (measured live: two calls, 7 408 bytes * each, no 缓存命中 on the second). */ noCacheRead?: boolean; /** Content negotiation for THIS call. tm_webfetch asks for Markdown (see * WEBFETCH_ACCEPT_MD); the search engine legs pass nothing and keep the * browser-shaped default. The cache key stays the URL ALONE: one page is * one entry (§6m-c pins it), and whoever reads it routes on the stored * content-type — which is also why the reply says so when a body arrived * as Markdown. A re-negotiation is a `fresh: true` away. */ accept?: string; /** HTTP retry policy (see `httpRetryFromConfig`). Omitted = the registry * defaults, so tm_webfetch, every tm_search engine leg and the PTC bridge * all retry the same way without repeating the knobs at each call site. */ retry?: Partial; /** Injectable sleep so a test can pin the 5s/10s/20s ladder instantly. */ sleep?: (ms: number) => Promise; /** Audit sink: one row per retry ACTUALLY taken (the caller writes it to the * trajectory — fetchWebText owns no pipeline instance of its own). */ onRetry?: (row: { attempt: number; delayMs: number; reason: string; }) => void; }): Promise; /** Content extraction: HTML stripped to text; JSON kept for the json * preview branch; anything else passes through untouched. */ export declare function extractWebResponse(raw: string, contentType: string): { text: string; isHtml: boolean; }; /** Build the tm_webfetch ToolDefinition over the SHARED main pipelines * instance (same step counter as tm_read/tm_grep/tm_bash — refs stay * unambiguous). `fetchImpl` is injectable for tests. */ export declare function buildTmWebfetchTool(deps: { pipelines: TmPipelines; cfg: TmConfig; args?: Record; fetchImpl?: FetchImpl; /** Shared URL cache (tm/index.ts owns the one instance so tm_search and * tm_webfetch cannot drift into two stores of the same page). */ cache?: WebCache; /** Injectable sleep for the HTTP retry ladder (tests pin 5s/10s/20s without * waiting on it). Omitted = real setTimeout. */ sleep?: (ms: number) => Promise; }): { description: string; args: Record; execute: (rawArgs: Record, ctx: unknown) => Promise; }; //# sourceMappingURL=webfetch.d.ts.map