@browserwright/pi 0.0.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/LICENSE +661 -0
- package/README.md +312 -0
- package/config.json +14 -0
- package/core/chain.ts +137 -0
- package/core/config.ts +90 -0
- package/core/exec-command.ts +80 -0
- package/core/exec-http.ts +138 -0
- package/core/exec-module.ts +95 -0
- package/core/format.ts +211 -0
- package/core/predicates.ts +228 -0
- package/core/probe.ts +182 -0
- package/core/results.ts +141 -0
- package/core/types.ts +241 -0
- package/index.ts +276 -0
- package/package.json +51 -0
- package/probe-cases.json +22 -0
- package/probe-run.ts +34 -0
- package/providers/browserwright-search.json +56 -0
- package/providers/browserwright-search.ts +427 -0
- package/providers/browserwright.json +54 -0
- package/verify.ts +91 -0
package/core/probe.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Probe: learn what a provider's failures actually look like.
|
|
3
|
+
*
|
|
4
|
+
* A provider's failWhen rules are supposed to come from evidence, not from
|
|
5
|
+
* guesses, so this runs one provider against a fixed set of real URLs chosen
|
|
6
|
+
* to sit on its capability boundary (SPA shell, bot wall, login wall, PDF,
|
|
7
|
+
* huge page, localhost) and reports what came back.
|
|
8
|
+
*
|
|
9
|
+
* Two deliberate constraints:
|
|
10
|
+
* - Probe is manual only. It hits real sites, and the browserwright rung
|
|
11
|
+
* opens tabs in the user's own browser.
|
|
12
|
+
* - Evidence files store summaries only, never whole pages. Real pages can
|
|
13
|
+
* carry the user's logged-in content.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { makeExecutor } from "./chain.ts";
|
|
20
|
+
import { EXTENSION_DIR } from "./config.ts";
|
|
21
|
+
import { failureReason, inspectText, resolveFailWhen } from "./predicates.ts";
|
|
22
|
+
import type { PiConfig, Provider } from "./types.ts";
|
|
23
|
+
|
|
24
|
+
export interface ProbeCase {
|
|
25
|
+
name: string;
|
|
26
|
+
url: string;
|
|
27
|
+
/** What this case is meant to expose. Kept in the evidence file. */
|
|
28
|
+
expose: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ProbeRow {
|
|
32
|
+
case: string;
|
|
33
|
+
url: string;
|
|
34
|
+
expose: string;
|
|
35
|
+
/** Transport-level success, before failWhen is applied. */
|
|
36
|
+
fetched: boolean;
|
|
37
|
+
ms: number;
|
|
38
|
+
chars: number;
|
|
39
|
+
/** Why the transport failed. */
|
|
40
|
+
error?: string;
|
|
41
|
+
/** Why the current failWhen rule would reject the content, if it would. */
|
|
42
|
+
wouldReject?: string;
|
|
43
|
+
/** First 200 characters, whitespace collapsed. The signature to write rules from. */
|
|
44
|
+
head?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const DEFAULT_CASES: ProbeCase[] = [
|
|
48
|
+
{ name: "article", url: "https://en.wikipedia.org/wiki/Markdown", expose: "normal long article" },
|
|
49
|
+
{ name: "spa-shell", url: "https://web.telegram.org/", expose: "client-rendered shell, no server HTML" },
|
|
50
|
+
{ name: "bot-wall", url: "https://www.g2.com/", expose: "bot challenge / interstitial" },
|
|
51
|
+
{ name: "login-wall", url: "https://x.com/elonmusk", expose: "needs the user's session cookies" },
|
|
52
|
+
{ name: "not-found", url: "https://example.com/definitely-not-here", expose: "404 handling" },
|
|
53
|
+
{
|
|
54
|
+
name: "huge",
|
|
55
|
+
url: "https://en.wikipedia.org/wiki/List_of_Latin_phrases_(full)",
|
|
56
|
+
expose: "page far past the 50KB truncation limit",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "pdf",
|
|
60
|
+
url: "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf",
|
|
61
|
+
expose: "non-HTML content type",
|
|
62
|
+
},
|
|
63
|
+
{ name: "localhost", url: "http://localhost:3000/", expose: "local dev server (remote providers cannot reach it)" },
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
export function loadProbeCases(dir: string = EXTENSION_DIR): ProbeCase[] {
|
|
67
|
+
const path = join(dir, "probe-cases.json");
|
|
68
|
+
if (!existsSync(path)) return DEFAULT_CASES;
|
|
69
|
+
try {
|
|
70
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as ProbeCase[];
|
|
71
|
+
return Array.isArray(parsed) && parsed.length > 0 ? parsed : DEFAULT_CASES;
|
|
72
|
+
} catch (error) {
|
|
73
|
+
console.error(`[webfetch] probe-cases.json unreadable (${(error as Error).message}), using defaults`);
|
|
74
|
+
return DEFAULT_CASES;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function runProbe(
|
|
79
|
+
provider: Provider,
|
|
80
|
+
cases: ProbeCase[],
|
|
81
|
+
options: {
|
|
82
|
+
config: PiConfig;
|
|
83
|
+
dir: string;
|
|
84
|
+
onCase?: (probeCase: ProbeCase, index: number) => void;
|
|
85
|
+
},
|
|
86
|
+
): Promise<ProbeRow[]> {
|
|
87
|
+
// Bypass the chain on purpose: probing is about one provider's raw
|
|
88
|
+
// behaviour, including on URLs its `when` predicate would normally skip.
|
|
89
|
+
// Probe cases are URLs, so a probe run is always a fetch-role call.
|
|
90
|
+
const executor = makeExecutor<string>(options.config, { dir: options.dir, role: "fetch" });
|
|
91
|
+
const rule = resolveFailWhen(options.config.defaultFailWhen, provider.failWhen);
|
|
92
|
+
const rows: ProbeRow[] = [];
|
|
93
|
+
|
|
94
|
+
for (const [index, probeCase] of cases.entries()) {
|
|
95
|
+
// Respect a provider's declared rate limit, or its own limiter turns the
|
|
96
|
+
// probe into a page of 429s that read like "this provider cannot do it".
|
|
97
|
+
if (index > 0 && provider.probeDelayMs) {
|
|
98
|
+
await new Promise((resolve) => setTimeout(resolve, provider.probeDelayMs));
|
|
99
|
+
}
|
|
100
|
+
options.onCase?.(probeCase, index);
|
|
101
|
+
const startedAt = performance.now();
|
|
102
|
+
let outcome: Awaited<ReturnType<typeof executor>>;
|
|
103
|
+
try {
|
|
104
|
+
outcome = await executor(provider, probeCase.url);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
outcome = { ok: false, reason: (error as Error).message };
|
|
107
|
+
}
|
|
108
|
+
const ms = Math.round(performance.now() - startedAt);
|
|
109
|
+
|
|
110
|
+
if (!outcome.ok || outcome.content === undefined) {
|
|
111
|
+
rows.push({
|
|
112
|
+
case: probeCase.name,
|
|
113
|
+
url: probeCase.url,
|
|
114
|
+
expose: probeCase.expose,
|
|
115
|
+
fetched: false,
|
|
116
|
+
ms,
|
|
117
|
+
chars: 0,
|
|
118
|
+
error: outcome.reason ?? "failed",
|
|
119
|
+
});
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
rows.push({
|
|
124
|
+
case: probeCase.name,
|
|
125
|
+
url: probeCase.url,
|
|
126
|
+
expose: probeCase.expose,
|
|
127
|
+
fetched: true,
|
|
128
|
+
ms,
|
|
129
|
+
chars: outcome.content.length,
|
|
130
|
+
wouldReject: failureReason(outcome.content, rule, inspectText),
|
|
131
|
+
head: outcome.content.replace(/\s+/g, " ").trim().slice(0, 200),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return rows;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function formatProbeReport(providerName: string, rows: ProbeRow[]): string {
|
|
139
|
+
const lines = [`webfetch probe — ${providerName}`, ""];
|
|
140
|
+
for (const row of rows) {
|
|
141
|
+
const verdict = !row.fetched
|
|
142
|
+
? `ERROR ${row.error}`
|
|
143
|
+
: row.wouldReject
|
|
144
|
+
? `REJECTED (${row.wouldReject})`
|
|
145
|
+
: "accepted";
|
|
146
|
+
lines.push(`${row.case.padEnd(11)} ${String(row.chars).padStart(7)} chars ${String(row.ms).padStart(5)}ms ${verdict}`);
|
|
147
|
+
if (row.head) lines.push(` head: ${row.head.slice(0, 140)}`);
|
|
148
|
+
}
|
|
149
|
+
lines.push(
|
|
150
|
+
"",
|
|
151
|
+
"Write failWhen rules for this provider from the signatures above:",
|
|
152
|
+
"a `matches` entry for each wall/shell phrase, and minChars only if a",
|
|
153
|
+
"legitimately short page cannot be confused with an empty one.",
|
|
154
|
+
);
|
|
155
|
+
return lines.join("\n");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function saveProbeEvidence(providerName: string, rows: ProbeRow[], dir: string = EXTENSION_DIR): string {
|
|
159
|
+
const payload = {
|
|
160
|
+
provider: providerName,
|
|
161
|
+
// Stamped by the caller's clock; probe results drift as sites change,
|
|
162
|
+
// so a rule traced back to this file needs to know how old it is.
|
|
163
|
+
probedAt: new Date().toISOString(),
|
|
164
|
+
note: "Summaries only — never whole pages, which may contain the user's logged-in content.",
|
|
165
|
+
rows,
|
|
166
|
+
};
|
|
167
|
+
const body = `${JSON.stringify(payload, null, 2)}\n`;
|
|
168
|
+
const path = join(dir, "providers", `${providerName}.probe.json`);
|
|
169
|
+
|
|
170
|
+
// Evidence belongs next to the declaration it justifies — but when this
|
|
171
|
+
// package is installed from npm it lives under node_modules, which is both
|
|
172
|
+
// read-only in some setups and wiped on the next update. Fall back rather
|
|
173
|
+
// than losing a probe run that just spent a minute opening real tabs.
|
|
174
|
+
try {
|
|
175
|
+
writeFileSync(path, body, "utf8");
|
|
176
|
+
return path;
|
|
177
|
+
} catch {
|
|
178
|
+
const fallback = join(tmpdir(), `${providerName}.probe.json`);
|
|
179
|
+
writeFileSync(fallback, body, "utf8");
|
|
180
|
+
return fallback;
|
|
181
|
+
}
|
|
182
|
+
}
|
package/core/results.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coercing an arbitrary JSON list into SearchResult rows.
|
|
3
|
+
*
|
|
4
|
+
* Every hosted search API returns the same three facts under different names
|
|
5
|
+
* (`link` vs `url` vs `href`, `snippet` vs `description` vs `content`). Doing
|
|
6
|
+
* the aliasing here is what keeps a new search provider a pure JSON drop-in
|
|
7
|
+
* instead of a code change — the same promise `kind: "http"` already makes for
|
|
8
|
+
* reader APIs.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { AnswerBox, KnowledgeGraph, SearchPayload, SearchResult } from "./types.ts";
|
|
12
|
+
|
|
13
|
+
const TITLE_KEYS = ["title", "name", "heading"] as const;
|
|
14
|
+
const URL_KEYS = ["url", "link", "href"] as const;
|
|
15
|
+
const SNIPPET_KEYS = ["snippet", "description", "content", "text", "excerpt"] as const;
|
|
16
|
+
const DATE_KEYS = ["date", "published", "publishedDate", "published_date"] as const;
|
|
17
|
+
|
|
18
|
+
const RESULTS_KEYS = ["results", "organic", "organic_results", "items", "webPages"] as const;
|
|
19
|
+
const ANSWER_KEYS = ["answerBox", "answer_box", "answer", "featured_snippet"] as const;
|
|
20
|
+
const KG_KEYS = ["knowledgeGraph", "knowledge_graph"] as const;
|
|
21
|
+
const PAA_KEYS = ["peopleAlsoAsk", "people_also_ask", "relatedQuestions"] as const;
|
|
22
|
+
const RELATED_KEYS = ["relatedSearches", "related_searches", "relatedQueries"] as const;
|
|
23
|
+
|
|
24
|
+
function firstValue(row: Record<string, unknown>, keys: readonly string[]): unknown {
|
|
25
|
+
for (const key of keys) {
|
|
26
|
+
if (row[key] !== undefined && row[key] !== null) return row[key];
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Pull a list of plain strings out of whatever shape the API used for it. */
|
|
32
|
+
function stringList(value: unknown): string[] | undefined {
|
|
33
|
+
if (!Array.isArray(value)) return undefined;
|
|
34
|
+
const out: string[] = [];
|
|
35
|
+
for (const item of value) {
|
|
36
|
+
if (typeof item === "string" && item.trim()) {
|
|
37
|
+
out.push(item.trim());
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (item && typeof item === "object") {
|
|
41
|
+
// Serper-style rows: {question: "..."} / {query: "..."}
|
|
42
|
+
const row = item as Record<string, unknown>;
|
|
43
|
+
const text = firstString(row, ["question", "query", "title", "text", "name"]);
|
|
44
|
+
if (text) out.push(text);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return out.length > 0 ? [...new Set(out)] : undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function firstString(row: Record<string, unknown>, keys: readonly string[]): string | undefined {
|
|
51
|
+
for (const key of keys) {
|
|
52
|
+
const value = row[key];
|
|
53
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Keep only rows that carry at least a URL — a row without one is not a search
|
|
60
|
+
* result the model can act on, and silently keeping it would inflate the count
|
|
61
|
+
* that `minResults` guards.
|
|
62
|
+
*/
|
|
63
|
+
export function normalizeResults(value: unknown): SearchResult[] {
|
|
64
|
+
if (!Array.isArray(value)) return [];
|
|
65
|
+
|
|
66
|
+
const out: SearchResult[] = [];
|
|
67
|
+
for (const item of value) {
|
|
68
|
+
if (!item || typeof item !== "object") continue;
|
|
69
|
+
const row = item as Record<string, unknown>;
|
|
70
|
+
const url = firstString(row, URL_KEYS);
|
|
71
|
+
if (!url) continue;
|
|
72
|
+
out.push({
|
|
73
|
+
position: out.length + 1,
|
|
74
|
+
title: firstString(row, TITLE_KEYS) ?? url,
|
|
75
|
+
url,
|
|
76
|
+
snippet: firstString(row, SNIPPET_KEYS),
|
|
77
|
+
date: firstString(row, DATE_KEYS),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function normalizeAnswerBox(value: unknown): AnswerBox | undefined {
|
|
84
|
+
if (typeof value === "string") {
|
|
85
|
+
return value.trim() ? { kind: "featured-snippet", text: value.trim() } : undefined;
|
|
86
|
+
}
|
|
87
|
+
if (!value || typeof value !== "object") return undefined;
|
|
88
|
+
const row = value as Record<string, unknown>;
|
|
89
|
+
const text = firstString(row, ["text", "answer", "snippet", "description", "content"]);
|
|
90
|
+
if (!text) return undefined;
|
|
91
|
+
const kind = firstString(row, ["kind", "type"]);
|
|
92
|
+
return { kind: kind === "ai-overview" ? "ai-overview" : "featured-snippet", text };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function normalizeKnowledgeGraph(value: unknown): KnowledgeGraph | undefined {
|
|
96
|
+
if (!value || typeof value !== "object") return undefined;
|
|
97
|
+
const row = value as Record<string, unknown>;
|
|
98
|
+
const attributes = row.attributes;
|
|
99
|
+
const graph: KnowledgeGraph = {
|
|
100
|
+
title: firstString(row, ["title", "name"]),
|
|
101
|
+
subtitle: firstString(row, ["subtitle", "type", "category"]),
|
|
102
|
+
description: firstString(row, ["description", "snippet"]),
|
|
103
|
+
};
|
|
104
|
+
if (attributes && typeof attributes === "object" && !Array.isArray(attributes)) {
|
|
105
|
+
const flat: Record<string, string> = {};
|
|
106
|
+
for (const [key, raw] of Object.entries(attributes as Record<string, unknown>)) {
|
|
107
|
+
if (typeof raw === "string" && raw.trim()) flat[key] = raw.trim();
|
|
108
|
+
}
|
|
109
|
+
if (Object.keys(flat).length > 0) graph.attributes = flat;
|
|
110
|
+
}
|
|
111
|
+
return graph.title || graph.description || graph.attributes ? graph : undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Coerce a whole search response into a SearchPayload.
|
|
116
|
+
*
|
|
117
|
+
* Accepts a bare array (just organic rows) or an object keyed the way hosted
|
|
118
|
+
* APIs key it. That aliasing is what keeps a hosted search provider a pure JSON
|
|
119
|
+
* drop-in: a Serper response, for instance, maps field-for-field with no code.
|
|
120
|
+
*/
|
|
121
|
+
export function normalizeSearchPayload(value: unknown): SearchPayload {
|
|
122
|
+
if (Array.isArray(value)) return { results: normalizeResults(value) };
|
|
123
|
+
if (!value || typeof value !== "object") return { results: [] };
|
|
124
|
+
|
|
125
|
+
const body = value as Record<string, unknown>;
|
|
126
|
+
const payload: SearchPayload = { results: normalizeResults(firstValue(body, RESULTS_KEYS)) };
|
|
127
|
+
|
|
128
|
+
const answerBox = normalizeAnswerBox(firstValue(body, ANSWER_KEYS));
|
|
129
|
+
if (answerBox) payload.answerBox = answerBox;
|
|
130
|
+
|
|
131
|
+
const knowledgeGraph = normalizeKnowledgeGraph(firstValue(body, KG_KEYS));
|
|
132
|
+
if (knowledgeGraph) payload.knowledgeGraph = knowledgeGraph;
|
|
133
|
+
|
|
134
|
+
const peopleAlsoAsk = stringList(firstValue(body, PAA_KEYS));
|
|
135
|
+
if (peopleAlsoAsk) payload.peopleAlsoAsk = peopleAlsoAsk;
|
|
136
|
+
|
|
137
|
+
const relatedSearches = stringList(firstValue(body, RELATED_KEYS));
|
|
138
|
+
if (relatedSearches) payload.relatedSearches = relatedSearches;
|
|
139
|
+
|
|
140
|
+
return payload;
|
|
141
|
+
}
|
package/core/types.ts
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for the provider contract.
|
|
3
|
+
*
|
|
4
|
+
* A provider is a declaration, not code. Three kinds are supported:
|
|
5
|
+
* - "http" : issue one HTTP request, optionally pluck a field out of JSON
|
|
6
|
+
* - "command" : run one argv, take stdout
|
|
7
|
+
* - "module" : hand off to a TS module in this package — the escape hatch for
|
|
8
|
+
* a provider that needs real logic (a session lifecycle, retry,
|
|
9
|
+
* progress reporting) rather than one shot at a subprocess.
|
|
10
|
+
*
|
|
11
|
+
* Everything a provider needs to say about itself lives in its JSON file: which
|
|
12
|
+
* tool it serves, what format it returns, when it is applicable, and what its
|
|
13
|
+
* output looks like when it has failed despite reporting success.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Which tool a provider serves. A provider belongs to exactly one. */
|
|
17
|
+
export type Role = "fetch" | "search";
|
|
18
|
+
|
|
19
|
+
export const ROLES: readonly Role[] = ["fetch", "search"];
|
|
20
|
+
|
|
21
|
+
/** What the provider hands back. The core never converts between these. */
|
|
22
|
+
export type ReturnFormat = "markdown" | "html" | "text" | "results";
|
|
23
|
+
|
|
24
|
+
/** One organic row of a `search` provider's payload. */
|
|
25
|
+
export interface SearchResult {
|
|
26
|
+
position: number;
|
|
27
|
+
title: string;
|
|
28
|
+
url: string;
|
|
29
|
+
snippet?: string;
|
|
30
|
+
/** Publication date, when the engine states one separately from the snippet. */
|
|
31
|
+
date?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The engine's own direct answer.
|
|
36
|
+
*
|
|
37
|
+
* `ai-overview` is Google's generated summary. Measured 2026-08-10: its body is
|
|
38
|
+
* **not** in the server-rendered HTML at all — it streams in afterwards — which
|
|
39
|
+
* is the single reason this extractor has to run against the live DOM rather
|
|
40
|
+
* than the document response.
|
|
41
|
+
*/
|
|
42
|
+
export interface AnswerBox {
|
|
43
|
+
kind: "ai-overview" | "featured-snippet";
|
|
44
|
+
text: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The entity panel: what the engine thinks the query is *about*. */
|
|
48
|
+
export interface KnowledgeGraph {
|
|
49
|
+
title?: string;
|
|
50
|
+
subtitle?: string;
|
|
51
|
+
description?: string;
|
|
52
|
+
/** Remaining labelled facts, keyed by the engine's own attribute name. */
|
|
53
|
+
attributes?: Record<string, string>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* What a `search` provider returns. Organic rows are the contract; everything
|
|
58
|
+
* else is present only when that query happened to trigger it, so every
|
|
59
|
+
* consumer must treat the optional fields as absent by default.
|
|
60
|
+
*/
|
|
61
|
+
export interface SearchPayload {
|
|
62
|
+
results: SearchResult[];
|
|
63
|
+
answerBox?: AnswerBox;
|
|
64
|
+
knowledgeGraph?: KnowledgeGraph;
|
|
65
|
+
peopleAlsoAsk?: string[];
|
|
66
|
+
relatedSearches?: string[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Subject predicate. All fields are optional; an empty match means "always".
|
|
71
|
+
* Globs are matched with `*` = any run of characters (no path semantics).
|
|
72
|
+
*
|
|
73
|
+
* The subject is a URL for `fetch` providers and the query string for `search`
|
|
74
|
+
* providers. `hostGlob` only means anything for the former — it silently never
|
|
75
|
+
* matches when the subject does not parse as a URL.
|
|
76
|
+
*/
|
|
77
|
+
export interface SubjectMatch {
|
|
78
|
+
/** Glob against the whole subject, e.g. "http://localhost*". */
|
|
79
|
+
urlGlob?: string[];
|
|
80
|
+
/** Glob against the hostname only, e.g. "*.local". URLs only. */
|
|
81
|
+
hostGlob?: string[];
|
|
82
|
+
/** Negation. `{ not: { hostGlob: ["localhost"] } }` = anything but localhost. */
|
|
83
|
+
not?: SubjectMatch;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* When to treat a "successful" call as a failure and drop to the next rung.
|
|
88
|
+
*
|
|
89
|
+
* Per-provider values REPLACE the core defaults field by field; they do not
|
|
90
|
+
* merge. That is deliberate: `{"matches": []}` is how a provider opts out of
|
|
91
|
+
* the default match list entirely, which any provider returning raw HTML needs
|
|
92
|
+
* (a `<noscript>` block legitimately contains "enable JavaScript").
|
|
93
|
+
*/
|
|
94
|
+
export interface FailWhen {
|
|
95
|
+
/** Fail when the returned text is shorter than this. 0 disables. */
|
|
96
|
+
minChars?: number;
|
|
97
|
+
/** Fail when a list payload has fewer than this many items. 0 disables. */
|
|
98
|
+
minResults?: number;
|
|
99
|
+
/** Case-insensitive substrings that mark the payload as a wall/shell. */
|
|
100
|
+
matches?: string[];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface ProviderCommon {
|
|
104
|
+
name: string;
|
|
105
|
+
/** Which tool this provider serves. Defaults to "fetch". */
|
|
106
|
+
role?: Role;
|
|
107
|
+
/** Human-readable label for status lines. Defaults to `name`. */
|
|
108
|
+
label?: string;
|
|
109
|
+
/** Set false to keep the declaration around without using it. */
|
|
110
|
+
enabled?: boolean;
|
|
111
|
+
returns: ReturnFormat;
|
|
112
|
+
/** Static capability declaration, e.g. "I am remote, I cannot see your LAN". */
|
|
113
|
+
when?: SubjectMatch;
|
|
114
|
+
failWhen?: FailWhen;
|
|
115
|
+
timeoutMs?: number;
|
|
116
|
+
/**
|
|
117
|
+
* Extra attempts at this rung after a TRANSPORT failure. Default 0.
|
|
118
|
+
*
|
|
119
|
+
* Content rejected by `failWhen` is never retried: that verdict is
|
|
120
|
+
* deterministic, so a second identical call only costs the user time (and,
|
|
121
|
+
* for a browser rung, another tab).
|
|
122
|
+
*/
|
|
123
|
+
retries?: number;
|
|
124
|
+
/**
|
|
125
|
+
* Case-insensitive substrings of the failure reason that make a retry
|
|
126
|
+
* worthwhile. Omit to retry every transport failure. Use it to retry only
|
|
127
|
+
* what is genuinely transient rather than, say, a 404.
|
|
128
|
+
*/
|
|
129
|
+
retryWhen?: string[];
|
|
130
|
+
/**
|
|
131
|
+
* Milliseconds to wait between probe cases. A provider's own rate limit is a
|
|
132
|
+
* static fact about it, like `when`. Probe-only: at runtime a 429 is just
|
|
133
|
+
* another transport failure that drops a rung.
|
|
134
|
+
*/
|
|
135
|
+
probeDelayMs?: number;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export interface HttpProvider extends ProviderCommon {
|
|
139
|
+
kind: "http";
|
|
140
|
+
method?: "GET" | "POST";
|
|
141
|
+
/** Supports {url}/{query}, the {…Encoded} variants, {dir}, and $ENV_VAR. */
|
|
142
|
+
url: string;
|
|
143
|
+
headers?: Record<string, string>;
|
|
144
|
+
/** JSON body; string values support the same interpolation as `url`. */
|
|
145
|
+
body?: unknown;
|
|
146
|
+
/** Dot path into a JSON response, e.g. "result". Omit to use the raw body. */
|
|
147
|
+
pick?: string;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface CommandProvider extends ProviderCommon {
|
|
151
|
+
kind: "command";
|
|
152
|
+
/**
|
|
153
|
+
* argv. Supports {url}/{query}, the {…Encoded} variants, and {dir}.
|
|
154
|
+
* Exit code contract: 0 = success, 2 = not applicable (drop a rung),
|
|
155
|
+
* anything else = hard error (also drops a rung, but is reported as an error).
|
|
156
|
+
*/
|
|
157
|
+
command: string[];
|
|
158
|
+
cwd?: string;
|
|
159
|
+
env?: Record<string, string>;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface ModuleProvider extends ProviderCommon {
|
|
163
|
+
kind: "module";
|
|
164
|
+
/** Module path relative to the package directory, e.g. "./providers/foo.ts". */
|
|
165
|
+
module: string;
|
|
166
|
+
/** Passed through to the runner verbatim. */
|
|
167
|
+
options?: Record<string, unknown>;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export type Provider = HttpProvider | CommandProvider | ModuleProvider;
|
|
171
|
+
|
|
172
|
+
/** What a `kind: "module"` runner receives. */
|
|
173
|
+
export interface ModuleContext {
|
|
174
|
+
/** The package directory — the same value `{dir}` interpolates to. */
|
|
175
|
+
dir: string;
|
|
176
|
+
timeoutMs: number;
|
|
177
|
+
signal?: AbortSignal;
|
|
178
|
+
options: Record<string, unknown>;
|
|
179
|
+
/**
|
|
180
|
+
* Report intermediate progress. This is the capability a subprocess cannot
|
|
181
|
+
* have, and the reason this provider kind exists.
|
|
182
|
+
*/
|
|
183
|
+
onProgress?: (text: string) => void;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** A `kind: "module"` provider's default export. */
|
|
187
|
+
export type ModuleRunner<T = unknown> = (subject: string, ctx: ModuleContext) => Promise<ProviderOutcome<T>>;
|
|
188
|
+
|
|
189
|
+
/** Global config, from config.json. */
|
|
190
|
+
export interface PiConfig {
|
|
191
|
+
/** Provider names per role, in the order they are attempted. */
|
|
192
|
+
order: Record<Role, string[]>;
|
|
193
|
+
/** Core default line of defence, overridable per provider. */
|
|
194
|
+
defaultFailWhen: FailWhen;
|
|
195
|
+
timeoutMs: number;
|
|
196
|
+
maxBytes: number;
|
|
197
|
+
maxLines: number;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** One rung attempt, recorded for the chain trace. */
|
|
201
|
+
export interface Attempt {
|
|
202
|
+
provider: string;
|
|
203
|
+
ok: boolean;
|
|
204
|
+
ms: number;
|
|
205
|
+
/** Why it failed or was skipped. Absent when ok. */
|
|
206
|
+
reason?: string;
|
|
207
|
+
/** True when the provider was never called (when-predicate or missing env). */
|
|
208
|
+
skipped?: boolean;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** What a provider executor returns. */
|
|
212
|
+
export interface ProviderOutcome<T = string> {
|
|
213
|
+
ok: boolean;
|
|
214
|
+
content?: T;
|
|
215
|
+
/** Populated on failure; becomes the Attempt reason. */
|
|
216
|
+
reason?: string;
|
|
217
|
+
/** HTTP status, when the executor knows one. */
|
|
218
|
+
status?: number;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export interface ChainResult<T = string> {
|
|
222
|
+
ok: boolean;
|
|
223
|
+
attempts: Attempt[];
|
|
224
|
+
provider?: string;
|
|
225
|
+
format?: ReturnFormat;
|
|
226
|
+
content?: T;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* A payload reduced to the two things `failWhen` can reason about. Supplying
|
|
231
|
+
* one of these per role is what lets a single predicate guard both a Markdown
|
|
232
|
+
* blob and a list of search results.
|
|
233
|
+
*/
|
|
234
|
+
export interface Inspected {
|
|
235
|
+
/** The text the `matches` needles are searched in. */
|
|
236
|
+
text: string;
|
|
237
|
+
/** Item count, for list payloads. Undefined for text payloads. */
|
|
238
|
+
count?: number;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export type Inspector<T> = (value: T) => Inspected;
|