@herbertgao/pi-extensions 2026.10.0 → 2026.10.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/README.md +7 -7
- package/THIRD_PARTY_NOTICES.md +2 -2
- package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail-audit.md +1 -1
- package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail-debt.md +1 -1
- package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail-gain.md +1 -1
- package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail-review.md +1 -1
- package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail.md +1 -1
- package/node_modules/@dietrichgebert/ponytail/.opencode/plugins/index.js +4 -0
- package/node_modules/@dietrichgebert/ponytail/.opencode/plugins/ponytail-frontmatter.cjs +43 -3
- package/node_modules/@dietrichgebert/ponytail/.opencode/plugins/ponytail.mjs +142 -59
- package/node_modules/@dietrichgebert/ponytail/.qoder-plugin/plugin.json +1 -1
- package/node_modules/@dietrichgebert/ponytail/README.es.md +87 -204
- package/node_modules/@dietrichgebert/ponytail/README.ja.md +212 -0
- package/node_modules/@dietrichgebert/ponytail/README.ko.md +92 -226
- package/node_modules/@dietrichgebert/ponytail/README.md +58 -244
- package/node_modules/@dietrichgebert/ponytail/README.zh-CN.md +212 -0
- package/node_modules/@dietrichgebert/ponytail/hooks/claude-codex-hooks.json +3 -3
- package/node_modules/@dietrichgebert/ponytail/hooks/copilot-hooks.json +4 -4
- package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-activate.js +63 -16
- package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-config.js +8 -7
- package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-mode-tracker.js +46 -23
- package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-runtime.js +39 -7
- package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-subagent.js +20 -3
- package/node_modules/@dietrichgebert/ponytail/hooks/qoder-hooks.json +3 -3
- package/node_modules/@dietrichgebert/ponytail/package.json +2 -2
- package/node_modules/@dietrichgebert/ponytail/pi-extension/index.js +47 -17
- package/node_modules/@dietrichgebert/ponytail/scripts/uninstall.js +21 -7
- package/node_modules/@dietrichgebert/ponytail/skills/plugin.json +5 -0
- package/node_modules/@dietrichgebert/ponytail/skills/ponytail-audit/SKILL.md +7 -2
- package/node_modules/@dietrichgebert/ponytail/skills/ponytail-debt/SKILL.md +1 -1
- package/node_modules/@dietrichgebert/ponytail/skills/ponytail-gain/SKILL.md +16 -14
- package/node_modules/@dietrichgebert/ponytail/skills/ponytail-help/SKILL.md +3 -3
- package/node_modules/@dietrichgebert/ponytail/skills/ponytail-review/SKILL.md +12 -7
- package/node_modules/@narumitw/pi-caffeinate/package.json +4 -4
- package/node_modules/pi-typesafe/README.md +1 -1
- package/node_modules/pi-typesafe/dist/backends.d.ts +15 -5
- package/node_modules/pi-typesafe/dist/backends.js +18 -0
- package/node_modules/pi-typesafe/dist/client.d.ts +4 -3
- package/node_modules/pi-typesafe/dist/client.js +22 -7
- package/node_modules/pi-typesafe/dist/index.d.ts +2 -2
- package/node_modules/pi-typesafe/dist/index.js +1 -1
- package/node_modules/pi-typesafe/dist/usage.d.ts +14 -4
- package/node_modules/pi-typesafe/dist/usage.js +147 -42
- package/node_modules/pi-typesafe/package.json +1 -1
- package/node_modules/pi-web-access/CHANGELOG.md +48 -0
- package/node_modules/pi-web-access/README.md +57 -11
- package/node_modules/pi-web-access/brave-rate-limit.ts +8 -2
- package/node_modules/pi-web-access/curator-page.ts +5 -1
- package/node_modules/pi-web-access/curator-server.ts +2 -0
- package/node_modules/pi-web-access/declared-web-links.ts +2 -2
- package/node_modules/pi-web-access/degoog.ts +323 -0
- package/node_modules/pi-web-access/dist/index.js +3483 -2745
- package/node_modules/pi-web-access/dist/mcp-cli.js +27443 -0
- package/node_modules/pi-web-access/exa.ts +5 -1
- package/node_modules/pi-web-access/extract.ts +41 -3
- package/node_modules/pi-web-access/firecrawl.ts +35 -4
- package/node_modules/pi-web-access/gemini-search.ts +34 -11
- package/node_modules/pi-web-access/gemini-web.ts +14 -9
- package/node_modules/pi-web-access/index.ts +153 -1202
- package/node_modules/pi-web-access/keenable.ts +221 -0
- package/node_modules/pi-web-access/mcp-cli.ts +40 -0
- package/node_modules/pi-web-access/mcp-server.ts +81 -0
- package/node_modules/pi-web-access/mcp-tool-definitions.ts +117 -0
- package/node_modules/pi-web-access/openai-search.ts +34 -1
- package/node_modules/pi-web-access/package.json +23 -5
- package/node_modules/pi-web-access/perplexity.ts +64 -56
- package/node_modules/pi-web-access/tool-arguments.ts +20 -0
- package/node_modules/pi-web-access/video-extract.ts +1 -1
- package/node_modules/pi-web-access/web-tool-contract.ts +86 -0
- package/node_modules/pi-web-access/web-tool-core.ts +1502 -0
- package/node_modules/pi-web-access/youtube-extract.ts +2 -5
- package/package.json +6 -6
- package/node_modules/@dietrichgebert/ponytail/assets/benchmark-3model.svg +0 -21
- package/node_modules/@dietrichgebert/ponytail/assets/benchmark-agentic.svg +0 -62
- package/node_modules/@dietrichgebert/ponytail/assets/logo-dark.png +0 -0
- package/node_modules/@dietrichgebert/ponytail/assets/logo-dark.svg +0 -115
- package/node_modules/@dietrichgebert/ponytail/assets/logo-greenpt-dark.svg +0 -27
- package/node_modules/@dietrichgebert/ponytail/assets/logo-greenpt.svg +0 -27
- package/node_modules/@dietrichgebert/ponytail/assets/logo.png +0 -0
- package/node_modules/@dietrichgebert/ponytail/assets/retriever-icon.png +0 -0
- package/node_modules/@dietrichgebert/ponytail/assets/retriever-logo-dark.svg +0 -4
- package/node_modules/@dietrichgebert/ponytail/assets/retriever-logo-light.svg +0 -4
- package/node_modules/@dietrichgebert/ponytail/assets/social-preview.png +0 -0
- package/node_modules/@dietrichgebert/ponytail/assets/waitlist-banner-es.png +0 -0
- package/node_modules/@dietrichgebert/ponytail/assets/waitlist-banner-ko.png +0 -0
- package/node_modules/@dietrichgebert/ponytail/assets/waitlist-banner.png +0 -0
|
@@ -64,6 +64,12 @@ export function parseRetryAfterMs(headers: Headers, now = Date.now()): number |
|
|
|
64
64
|
return Math.max(0, date - now);
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
// Prepaid plans report a monthly bucket with limit 0: it always reads
|
|
68
|
+
// remaining 0 but never gates requests.
|
|
69
|
+
function exhaustedBuckets(headers: Headers): BraveRateLimitBucket[] {
|
|
70
|
+
return parseBraveRateLimitHeaders(headers).filter(bucket => bucket.limit > 0 && bucket.remaining === 0);
|
|
71
|
+
}
|
|
72
|
+
|
|
67
73
|
function defaultSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
68
74
|
if (signal?.aborted) return Promise.reject(new Error("Aborted"));
|
|
69
75
|
return new Promise((resolve, reject) => {
|
|
@@ -122,7 +128,7 @@ export class BraveRateLimitCoordinator {
|
|
|
122
128
|
}
|
|
123
129
|
|
|
124
130
|
observe(headers: Headers): void {
|
|
125
|
-
const exhausted =
|
|
131
|
+
const exhausted = exhaustedBuckets(headers);
|
|
126
132
|
if (exhausted.length === 0) return;
|
|
127
133
|
const resetMs = Math.max(...exhausted.map(bucket => bucket.resetSeconds * 1000));
|
|
128
134
|
this.blockFor(resetMs);
|
|
@@ -130,7 +136,7 @@ export class BraveRateLimitCoordinator {
|
|
|
130
136
|
|
|
131
137
|
retryDelay(headers: Headers): number | null {
|
|
132
138
|
const advertised = parseRetryAfterMs(headers, this.now());
|
|
133
|
-
const exhausted =
|
|
139
|
+
const exhausted = exhaustedBuckets(headers);
|
|
134
140
|
const bucketDelay = exhausted.length > 0
|
|
135
141
|
? Math.max(...exhausted.map(bucket => bucket.resetSeconds * 1000))
|
|
136
142
|
: null;
|
|
@@ -33,6 +33,7 @@ function buildProviderButtons(
|
|
|
33
33
|
{ value: "bocha", label: "Bocha", available: available.bocha },
|
|
34
34
|
{ value: "ollama", label: "Ollama", available: available.ollama },
|
|
35
35
|
{ value: "searxng", label: "SearXNG", available: available.searxng },
|
|
36
|
+
{ value: "degoog", label: "degoog", available: available.degoog },
|
|
36
37
|
{ value: "duckduckgo", label: "DuckDuckGo", available: available.duckduckgo },
|
|
37
38
|
{ value: "perplexity", label: "Perplexity", available: available.perplexity },
|
|
38
39
|
{ value: "gemini", label: "Gemini", available: available.gemini },
|
|
@@ -50,6 +51,7 @@ function buildProviderButtons(
|
|
|
50
51
|
{ value: "baizhi", label: "Baizhi", available: available.baizhi },
|
|
51
52
|
{ value: "zai", label: "Z.ai", available: available.zai },
|
|
52
53
|
{ value: "valyu", label: "Valyu", available: available.valyu },
|
|
54
|
+
{ value: "keenable", label: "Keenable", available: available.keenable },
|
|
53
55
|
];
|
|
54
56
|
|
|
55
57
|
return providers
|
|
@@ -1476,7 +1478,7 @@ const SCRIPT = `(function() {
|
|
|
1476
1478
|
var token = DATA.sessionToken;
|
|
1477
1479
|
var timeoutSec = DATA.timeout;
|
|
1478
1480
|
var queries = Array.isArray(DATA.queries) ? DATA.queries : [];
|
|
1479
|
-
var providers = ["auto", "all", "openai", "exa", "brave", "parallel", "parallel-mcp", "tinyfish", "search1api", "searchinfinity", "querit", "tavily", "firecrawl", "jina", "serpdive", "kagi", "bocha", "ollama", "searxng", "duckduckgo", "perplexity", "gemini", "kimi", "anysearch", "xcrawl", "xai", "mistral", "brightdata", "serpbase", "serpapi", "serper", "serply", "valyu", "baizhi", "you", "zai"];
|
|
1481
|
+
var providers = ["auto", "all", "openai", "exa", "brave", "parallel", "parallel-mcp", "tinyfish", "search1api", "searchinfinity", "querit", "tavily", "firecrawl", "jina", "serpdive", "kagi", "bocha", "ollama", "searxng", "degoog", "duckduckgo", "perplexity", "gemini", "kimi", "anysearch", "xcrawl", "xai", "mistral", "brightdata", "serpbase", "serpapi", "serper", "serply", "valyu", "baizhi", "you", "zai", "keenable"];
|
|
1480
1482
|
var availProviders = DATA.availableProviders && typeof DATA.availableProviders === "object" ? DATA.availableProviders : {};
|
|
1481
1483
|
var workflow = "summary-review";
|
|
1482
1484
|
var initialDefaultProvider = typeof DATA.defaultProvider === "string" ? DATA.defaultProvider : "exa";
|
|
@@ -1697,6 +1699,7 @@ const SCRIPT = `(function() {
|
|
|
1697
1699
|
if (provider === "bocha") return "Bocha";
|
|
1698
1700
|
if (provider === "ollama") return "Ollama";
|
|
1699
1701
|
if (provider === "searxng") return "SearXNG";
|
|
1702
|
+
if (provider === "degoog") return "degoog";
|
|
1700
1703
|
if (provider === "duckduckgo") return "DuckDuckGo";
|
|
1701
1704
|
if (provider === "perplexity") return "Perplexity";
|
|
1702
1705
|
if (provider === "exa") return "Exa";
|
|
@@ -1715,6 +1718,7 @@ const SCRIPT = `(function() {
|
|
|
1715
1718
|
if (provider === "baizhi") return "Baizhi";
|
|
1716
1719
|
if (provider === "zai") return "Z.ai";
|
|
1717
1720
|
if (provider === "valyu") return "Valyu";
|
|
1721
|
+
if (provider === "keenable") return "Keenable";
|
|
1718
1722
|
return "Unknown";
|
|
1719
1723
|
}
|
|
1720
1724
|
|
|
@@ -292,6 +292,7 @@ export function startCuratorServer(
|
|
|
292
292
|
if (provider === "bocha") return availableProviders.bocha;
|
|
293
293
|
if (provider === "ollama") return availableProviders.ollama;
|
|
294
294
|
if (provider === "searxng") return availableProviders.searxng;
|
|
295
|
+
if (provider === "degoog") return availableProviders.degoog;
|
|
295
296
|
if (provider === "duckduckgo") return availableProviders.duckduckgo;
|
|
296
297
|
if (provider === "perplexity") return availableProviders.perplexity;
|
|
297
298
|
if (provider === "exa") return availableProviders.exa;
|
|
@@ -309,6 +310,7 @@ export function startCuratorServer(
|
|
|
309
310
|
if (provider === "baizhi") return availableProviders.baizhi;
|
|
310
311
|
if (provider === "zai") return availableProviders.zai;
|
|
311
312
|
if (provider === "valyu") return availableProviders.valyu;
|
|
313
|
+
if (provider === "keenable") return availableProviders.keenable;
|
|
312
314
|
return false;
|
|
313
315
|
}
|
|
314
316
|
|
|
@@ -23,7 +23,7 @@ export interface DeclaredWebLink {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export function discoverDeclaredWebLinks(
|
|
26
|
-
document: Document,
|
|
26
|
+
document: Document | null,
|
|
27
27
|
linkHeader: string | null,
|
|
28
28
|
responseUrl: string,
|
|
29
29
|
): DeclaredWebLink[] {
|
|
@@ -41,7 +41,7 @@ export function discoverDeclaredWebLinks(
|
|
|
41
41
|
if (links.size >= MAX_DECLARED_LINKS) break;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
if (links.size < MAX_DECLARED_LINKS) {
|
|
44
|
+
if (document && links.size < MAX_DECLARED_LINKS) {
|
|
45
45
|
const declaredBase = document.querySelector("base[href]")?.getAttribute("href");
|
|
46
46
|
const documentBase = resolveHttpUrl(declaredBase, responseUrl) ?? responseUrl;
|
|
47
47
|
for (const element of document.querySelectorAll("link[rel][href], a[rel][href]")) {
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { activityMonitor } from "./activity.ts";
|
|
3
|
+
import { redactCredential, resolveCredential } from "./credential-source.ts";
|
|
4
|
+
import { normalizeDomain } from "./domain-filter-normalization.ts";
|
|
5
|
+
import { fetchRemoteUrl, loadSsrfConfig } from "./ssrf-protection.ts";
|
|
6
|
+
import type { SearchOptions, SearchResult, SearchResponse } from "./perplexity.ts";
|
|
7
|
+
import { formatSearchResultsAsAnswer } from "./search-answer-formatting.ts";
|
|
8
|
+
import { normalizeSearchResultCount } from "./search-result-count-normalization.ts";
|
|
9
|
+
import { getWebSearchConfigPath } from "./utils.ts";
|
|
10
|
+
|
|
11
|
+
const CONFIG_PATH = getWebSearchConfigPath();
|
|
12
|
+
// degoog is a self-hosted metasearch front end. The public instance keeps the
|
|
13
|
+
// provider usable without configuration; DEGOOG_URL or degoogBaseUrl point it at
|
|
14
|
+
// a private deployment, which is what SSRF-guarded internal hosts require.
|
|
15
|
+
const DEFAULT_BASE_URL = "https://degoog.org";
|
|
16
|
+
const SEARCH_TIMEOUT_MS = 30_000;
|
|
17
|
+
|
|
18
|
+
interface WebSearchConfig {
|
|
19
|
+
degoogBaseUrl?: unknown;
|
|
20
|
+
degoogApiKey?: unknown;
|
|
21
|
+
degoogEngines?: unknown;
|
|
22
|
+
degoogHeaders?: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface NormalizedDomainFilters {
|
|
26
|
+
allowed: string[];
|
|
27
|
+
blocked: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface DegoogResult {
|
|
31
|
+
title?: unknown;
|
|
32
|
+
url?: unknown;
|
|
33
|
+
snippet?: unknown;
|
|
34
|
+
content?: unknown;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let cachedConfig: WebSearchConfig | null = null;
|
|
38
|
+
|
|
39
|
+
function loadConfig(): WebSearchConfig {
|
|
40
|
+
if (cachedConfig) return cachedConfig;
|
|
41
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
42
|
+
cachedConfig = {};
|
|
43
|
+
return cachedConfig;
|
|
44
|
+
}
|
|
45
|
+
const raw = readFileSync(CONFIG_PATH, "utf-8");
|
|
46
|
+
let parsed: unknown;
|
|
47
|
+
try {
|
|
48
|
+
parsed = JSON.parse(raw);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
51
|
+
throw new Error(`Failed to parse ${CONFIG_PATH}: ${message}`);
|
|
52
|
+
}
|
|
53
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
54
|
+
throw new Error(`Invalid config in ${CONFIG_PATH}: expected a JSON object`);
|
|
55
|
+
}
|
|
56
|
+
cachedConfig = parsed as WebSearchConfig;
|
|
57
|
+
return cachedConfig;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function normalizeBaseUrl(value: unknown): string | null {
|
|
61
|
+
if (typeof value !== "string") return null;
|
|
62
|
+
const trimmed = value.trim();
|
|
63
|
+
if (!trimmed) return null;
|
|
64
|
+
try {
|
|
65
|
+
const url = new URL(trimmed);
|
|
66
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
67
|
+
if (url.username || url.password) return null;
|
|
68
|
+
url.pathname = url.pathname.replace(/\/+$/, "");
|
|
69
|
+
url.search = "";
|
|
70
|
+
url.hash = "";
|
|
71
|
+
return url.toString().replace(/\/+$/, "");
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function getBaseUrl(): string | null {
|
|
78
|
+
// An explicitly set variable wins, including an empty one that disables the
|
|
79
|
+
// provider, so a configured base never silently falls back to the public instance.
|
|
80
|
+
if (process.env.DEGOOG_URL !== undefined) return normalizeBaseUrl(process.env.DEGOOG_URL);
|
|
81
|
+
const configured = loadConfig().degoogBaseUrl;
|
|
82
|
+
if (configured !== undefined) return normalizeBaseUrl(configured);
|
|
83
|
+
return DEFAULT_BASE_URL;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function normalizeEngines(value: unknown): string[] {
|
|
87
|
+
if (!Array.isArray(value)) return [];
|
|
88
|
+
const engines: string[] = [];
|
|
89
|
+
for (const entry of value) {
|
|
90
|
+
if (typeof entry !== "string") continue;
|
|
91
|
+
const engine = entry.trim();
|
|
92
|
+
if (engine && !engines.includes(engine)) engines.push(engine);
|
|
93
|
+
}
|
|
94
|
+
return engines;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isValidHeaderValue(value: string): boolean {
|
|
98
|
+
try {
|
|
99
|
+
new Headers({ "x-pi-web-access-validation": value });
|
|
100
|
+
return true;
|
|
101
|
+
} catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function normalizeHeaders(value: unknown): Record<string, string> {
|
|
107
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
108
|
+
const headers: Record<string, string> = {};
|
|
109
|
+
for (const [key, headerValue] of Object.entries(value as Record<string, unknown>)) {
|
|
110
|
+
if (typeof headerValue !== "string") continue;
|
|
111
|
+
const name = key.trim();
|
|
112
|
+
// RFC 7230 token chars only — reject empty or malformed header names.
|
|
113
|
+
if (!name || !/^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/.test(name)) continue;
|
|
114
|
+
if (!isValidHeaderValue(headerValue)) continue;
|
|
115
|
+
// Fetch strips surrounding whitespace before sending; store the sent form so
|
|
116
|
+
// redaction matches what an upstream error body can echo back.
|
|
117
|
+
headers[name] = headerValue.trim();
|
|
118
|
+
}
|
|
119
|
+
return headers;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function mergeHeaders(extra: Record<string, string>): Record<string, string> {
|
|
123
|
+
const headers: Record<string, string> = { Accept: "application/json", "Content-Type": "application/json" };
|
|
124
|
+
for (const [name, value] of Object.entries(extra)) {
|
|
125
|
+
for (const existing of Object.keys(headers)) {
|
|
126
|
+
if (existing.toLowerCase() === name.toLowerCase()) delete headers[existing];
|
|
127
|
+
}
|
|
128
|
+
headers[name] = value;
|
|
129
|
+
}
|
|
130
|
+
return headers;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function requireBaseUrl(): string {
|
|
134
|
+
const baseUrl = getBaseUrl();
|
|
135
|
+
if (!baseUrl) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
"degoog base URL is invalid or missing. Either:\n" +
|
|
138
|
+
` 1. Create ${CONFIG_PATH} with { "degoogBaseUrl": "https://degoog.example.com" }\n` +
|
|
139
|
+
" 2. Set DEGOOG_URL to an HTTP(S) URL\n" +
|
|
140
|
+
" 3. Remove the override to use the public instance at https://degoog.org",
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
return baseUrl;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function normalizeDomainFilters(domainFilter: string[] | undefined): NormalizedDomainFilters {
|
|
147
|
+
const filters: NormalizedDomainFilters = { allowed: [], blocked: [] };
|
|
148
|
+
for (const raw of domainFilter ?? []) {
|
|
149
|
+
const domain = normalizeDomain(raw);
|
|
150
|
+
if (!domain) continue;
|
|
151
|
+
const target = raw.trim().startsWith("-") ? filters.blocked : filters.allowed;
|
|
152
|
+
if (!target.includes(domain)) target.push(domain);
|
|
153
|
+
}
|
|
154
|
+
return filters;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function buildQuery(query: string, filters: NormalizedDomainFilters): string {
|
|
158
|
+
const parts = [query];
|
|
159
|
+
if (filters.allowed.length === 1) {
|
|
160
|
+
parts.push(`site:${filters.allowed[0]}`);
|
|
161
|
+
} else if (filters.allowed.length > 1) {
|
|
162
|
+
// Group the alternatives so the query terms apply to every site branch.
|
|
163
|
+
parts.push(`(${filters.allowed.map(domain => `site:${domain}`).join(" OR ")})`);
|
|
164
|
+
}
|
|
165
|
+
for (const domain of filters.blocked) parts.push(`-site:${domain}`);
|
|
166
|
+
return parts.join(" ");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function hostMatchesDomain(hostname: string, domain: string): boolean {
|
|
170
|
+
return hostname === domain || hostname.endsWith(`.${domain}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function matchesDomainFilters(url: string, filters: NormalizedDomainFilters): boolean {
|
|
174
|
+
if (filters.allowed.length === 0 && filters.blocked.length === 0) return true;
|
|
175
|
+
let hostname: string;
|
|
176
|
+
try {
|
|
177
|
+
hostname = new URL(url).hostname.toLowerCase();
|
|
178
|
+
} catch {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
if (filters.allowed.length > 0 && !filters.allowed.some(domain => hostMatchesDomain(hostname, domain))) return false;
|
|
182
|
+
return !filters.blocked.some(domain => hostMatchesDomain(hostname, domain));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function resultText(value: unknown): string {
|
|
186
|
+
return typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function errorMessage(err: unknown): string {
|
|
190
|
+
return err instanceof Error ? err.message : String(err);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function invalidResponse(message: string): Error {
|
|
194
|
+
return new Error(`degoog returned invalid response: ${message}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function parseResponse(value: unknown): DegoogResult[] {
|
|
198
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw invalidResponse("expected an object envelope");
|
|
199
|
+
const envelope = value as Record<string, unknown>;
|
|
200
|
+
if (!Array.isArray(envelope.results)) throw invalidResponse("expected a results array");
|
|
201
|
+
return envelope.results as DegoogResult[];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function redactSecrets(text: string, secrets: Array<string | null | undefined>): string {
|
|
205
|
+
let redacted = text;
|
|
206
|
+
for (const secret of secrets) redacted = redactCredential(redacted, secret);
|
|
207
|
+
return redacted;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function isDegoogAvailable(): boolean {
|
|
211
|
+
return getBaseUrl() !== null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export async function searchWithDegoog(query: string, options: SearchOptions = {}): Promise<SearchResponse> {
|
|
215
|
+
const baseUrl = requireBaseUrl();
|
|
216
|
+
const numResults = normalizeSearchResultCount(options.numResults);
|
|
217
|
+
const filters = normalizeDomainFilters(options.domainFilter);
|
|
218
|
+
const searchQuery = buildQuery(query, filters);
|
|
219
|
+
const engines = normalizeEngines(loadConfig().degoogEngines);
|
|
220
|
+
const apiKey = await resolveCredential({
|
|
221
|
+
provider: "degoog",
|
|
222
|
+
configuredValue: loadConfig().degoogApiKey,
|
|
223
|
+
environmentValue: process.env.DEGOOG_API_KEY,
|
|
224
|
+
signal: options.signal,
|
|
225
|
+
});
|
|
226
|
+
const customHeaders = normalizeHeaders(loadConfig().degoogHeaders);
|
|
227
|
+
const headers = mergeHeaders(customHeaders);
|
|
228
|
+
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
|
229
|
+
// Reverse-proxy header values can be credentials, so redact them alongside the API
|
|
230
|
+
// key before an upstream error body can echo them back.
|
|
231
|
+
const secrets = [apiKey, ...Object.values(customHeaders)];
|
|
232
|
+
|
|
233
|
+
const body: Record<string, unknown> = { query: searchQuery, type: "web", page: 1 };
|
|
234
|
+
if (options.recencyFilter) body.time = options.recencyFilter;
|
|
235
|
+
if (engines.length > 0) body.engines = engines;
|
|
236
|
+
|
|
237
|
+
const url = new URL(`${baseUrl}/api/search`);
|
|
238
|
+
const activityId = activityMonitor.logStart({ type: "api", query: searchQuery });
|
|
239
|
+
const timeoutSignal = AbortSignal.timeout(SEARCH_TIMEOUT_MS);
|
|
240
|
+
let response: Response;
|
|
241
|
+
let entries: DegoogResult[];
|
|
242
|
+
try {
|
|
243
|
+
response = await fetchRemoteUrl(url, {
|
|
244
|
+
method: "POST",
|
|
245
|
+
headers,
|
|
246
|
+
body: JSON.stringify(body),
|
|
247
|
+
signal: options.signal ? AbortSignal.any([timeoutSignal, options.signal]) : timeoutSignal,
|
|
248
|
+
}, {
|
|
249
|
+
...loadSsrfConfig(),
|
|
250
|
+
// Credentials and reverse-proxy headers must not follow a cross-origin redirect.
|
|
251
|
+
onRedirect: ({ from, to, init }) => from.origin === to.origin
|
|
252
|
+
? init
|
|
253
|
+
: { ...init, headers: { Accept: "application/json", "Content-Type": "application/json" } },
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
if (!response.ok) {
|
|
257
|
+
const errorText = redactSecrets(await response.text(), secrets).slice(0, 300);
|
|
258
|
+
if (response.status === 401) {
|
|
259
|
+
throw new Error(
|
|
260
|
+
`degoog search error 401: this instance protects search routes. Set degoogApiKey in ${CONFIG_PATH} or DEGOOG_API_KEY. ${errorText.slice(0, 200)}`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
throw new Error(`degoog search error ${response.status}: ${errorText}`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
let rawData: unknown;
|
|
267
|
+
try {
|
|
268
|
+
rawData = await response.json();
|
|
269
|
+
} catch (err) {
|
|
270
|
+
if (err instanceof Error && err.name === "TimeoutError") throw err;
|
|
271
|
+
throw new Error(`degoog returned invalid JSON: ${errorMessage(err)}`);
|
|
272
|
+
}
|
|
273
|
+
entries = parseResponse(rawData);
|
|
274
|
+
} catch (err) {
|
|
275
|
+
if (options.signal?.aborted) {
|
|
276
|
+
activityMonitor.logComplete(activityId, 0);
|
|
277
|
+
throw new Error("Aborted");
|
|
278
|
+
}
|
|
279
|
+
const message = errorMessage(err);
|
|
280
|
+
// A caller cancellation is not a failure, but our own deadline is: distinguish
|
|
281
|
+
// them so a timeout is not recorded as a completed request with status 0.
|
|
282
|
+
const providerTimeout = timeoutSignal.aborted || (err instanceof Error && err.name === "TimeoutError");
|
|
283
|
+
const outgoing = providerTimeout
|
|
284
|
+
? new Error(`degoog request timed out after ${Math.round(SEARCH_TIMEOUT_MS / 1000)}s`)
|
|
285
|
+
: (() => {
|
|
286
|
+
const redactedMessage = redactSecrets(message, secrets);
|
|
287
|
+
if (redactedMessage === message && err instanceof Error) return err;
|
|
288
|
+
const redactedError = new Error(redactedMessage);
|
|
289
|
+
if (err instanceof Error) redactedError.name = err.name;
|
|
290
|
+
return redactedError;
|
|
291
|
+
})();
|
|
292
|
+
activityMonitor.logError(activityId, redactSecrets(errorMessage(outgoing), secrets));
|
|
293
|
+
throw outgoing;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
activityMonitor.logComplete(activityId, response.status);
|
|
297
|
+
const results: SearchResult[] = [];
|
|
298
|
+
const seen = new Set<string>();
|
|
299
|
+
for (const item of entries) {
|
|
300
|
+
if (!item || typeof item !== "object") continue;
|
|
301
|
+
const rawUrl = resultText(item.url);
|
|
302
|
+
if (!rawUrl) continue;
|
|
303
|
+
let resultUrl: URL;
|
|
304
|
+
try {
|
|
305
|
+
resultUrl = new URL(rawUrl);
|
|
306
|
+
} catch {
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
// Only absolute HTTP(S) links are usable as citations or fetch targets.
|
|
310
|
+
if (resultUrl.protocol !== "http:" && resultUrl.protocol !== "https:") continue;
|
|
311
|
+
if (!matchesDomainFilters(resultUrl.href, filters)) continue;
|
|
312
|
+
if (seen.has(resultUrl.href)) continue;
|
|
313
|
+
seen.add(resultUrl.href);
|
|
314
|
+
results.push({
|
|
315
|
+
title: resultText(item.title) || resultUrl.href,
|
|
316
|
+
url: resultUrl.href,
|
|
317
|
+
snippet: resultText(item.snippet) || resultText(item.content),
|
|
318
|
+
});
|
|
319
|
+
if (results.length >= numResults) break;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return { answer: formatSearchResultsAsAnswer(results), results };
|
|
323
|
+
}
|