@oh-my-pi/pi-coding-agent 16.2.5 → 16.2.7
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/CHANGELOG.md +41 -1
- package/dist/cli.js +3089 -3104
- package/dist/types/cli/bench-cli.d.ts +6 -1
- package/dist/types/commands/bench.d.ts +8 -0
- package/dist/types/config/model-discovery.d.ts +6 -1
- package/dist/types/config/service-tier.d.ts +39 -24
- package/dist/types/config/settings-schema.d.ts +37 -37
- package/dist/types/edit/index.d.ts +1 -0
- package/dist/types/edit/renderer.d.ts +4 -0
- package/dist/types/edit/snapshot-details.d.ts +33 -0
- package/dist/types/mcp/config-writer.d.ts +48 -0
- package/dist/types/mcp/oauth-flow.d.ts +4 -6
- package/dist/types/mcp/types.d.ts +3 -0
- package/dist/types/modes/controllers/tool-args-reveal.d.ts +9 -2
- package/dist/types/session/agent-session.d.ts +37 -13
- package/dist/types/session/messages.d.ts +1 -1
- package/dist/types/session/session-context.d.ts +2 -2
- package/dist/types/session/session-entries.d.ts +2 -2
- package/dist/types/session/session-manager.d.ts +5 -6
- package/dist/types/system-prompt.test.d.ts +1 -0
- package/dist/types/task/executor.d.ts +6 -6
- package/dist/types/task/types.d.ts +6 -0
- package/dist/types/task/worktree.d.ts +32 -6
- package/dist/types/tiny/title-client.d.ts +5 -1
- package/dist/types/tools/index.d.ts +3 -3
- package/dist/types/utils/git.d.ts +17 -0
- package/dist/types/web/search/providers/duckduckgo.d.ts +2 -2
- package/dist/types/web/search/types.d.ts +1 -1
- package/package.json +12 -12
- package/src/cli/args.ts +32 -1
- package/src/cli/bench-cli.ts +97 -22
- package/src/cli/tiny-models-cli.ts +18 -4
- package/src/cli/web-search-cli.ts +6 -1
- package/src/commands/bench.ts +10 -1
- package/src/config/mcp-schema.json +11 -2
- package/src/config/model-discovery.ts +66 -8
- package/src/config/model-registry.ts +13 -6
- package/src/config/service-tier.ts +85 -56
- package/src/config/settings-schema.ts +42 -36
- package/src/config/settings.ts +47 -0
- package/src/edit/hashline/execute.ts +15 -9
- package/src/edit/index.ts +19 -6
- package/src/edit/modes/patch.ts +3 -2
- package/src/edit/modes/replace.ts +3 -2
- package/src/edit/renderer.ts +4 -0
- package/src/edit/snapshot-details.ts +77 -0
- package/src/eval/agent-bridge.ts +4 -2
- package/src/extensibility/plugins/legacy-pi-compat.ts +2 -2
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/main.ts +1 -1
- package/src/mcp/config-writer.ts +121 -0
- package/src/mcp/config.ts +10 -6
- package/src/mcp/oauth-flow.ts +10 -8
- package/src/mcp/transports/stdio.ts +9 -17
- package/src/mcp/types.ts +3 -0
- package/src/modes/components/extensions/extension-dashboard.ts +46 -0
- package/src/modes/components/extensions/state-manager.ts +24 -3
- package/src/modes/controllers/event-controller.ts +24 -8
- package/src/modes/controllers/tool-args-reveal.ts +100 -22
- package/src/modes/utils/transcript-render-helpers.ts +2 -2
- package/src/prompts/bench.md +4 -10
- package/src/prompts/tools/irc.md +19 -29
- package/src/prompts/tools/job.md +8 -14
- package/src/prompts/tools/lsp.md +19 -30
- package/src/prompts/tools/task.md +42 -62
- package/src/sdk.ts +13 -11
- package/src/session/agent-session.ts +196 -76
- package/src/session/messages.ts +1 -1
- package/src/session/session-context.ts +4 -4
- package/src/session/session-entries.ts +2 -2
- package/src/session/session-listing.ts +9 -8
- package/src/session/session-loader.ts +98 -3
- package/src/session/session-manager.ts +43 -6
- package/src/slash-commands/builtin-registry.ts +2 -10
- package/src/subprocess/worker-client.ts +12 -4
- package/src/system-prompt.test.ts +158 -0
- package/src/system-prompt.ts +69 -26
- package/src/task/executor.ts +23 -16
- package/src/task/index.ts +7 -5
- package/src/task/isolation-runner.ts +15 -1
- package/src/task/types.ts +6 -0
- package/src/task/worktree.ts +219 -38
- package/src/tiny/title-client.ts +19 -13
- package/src/tools/index.ts +3 -3
- package/src/tools/irc.ts +9 -3
- package/src/tools/path-utils.ts +4 -2
- package/src/tools/read.ts +28 -28
- package/src/utils/file-mentions.ts +10 -1
- package/src/utils/git.ts +38 -0
- package/src/web/search/index.ts +14 -8
- package/src/web/search/providers/duckduckgo.ts +150 -78
- package/src/web/search/providers/gemini.ts +268 -185
- package/src/web/search/types.ts +1 -1
package/src/tools/read.ts
CHANGED
|
@@ -14,7 +14,15 @@ import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
|
|
|
14
14
|
import { glob, type SummaryResult, summarizeCode } from "@oh-my-pi/pi-natives";
|
|
15
15
|
import type { Component } from "@oh-my-pi/pi-tui";
|
|
16
16
|
import { Text } from "@oh-my-pi/pi-tui";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
getRemoteDir,
|
|
19
|
+
type ImageMetadata,
|
|
20
|
+
isProbablyBinary,
|
|
21
|
+
logger,
|
|
22
|
+
prompt,
|
|
23
|
+
readImageMetadata,
|
|
24
|
+
untilAborted,
|
|
25
|
+
} from "@oh-my-pi/pi-utils";
|
|
18
26
|
import { type } from "arktype";
|
|
19
27
|
import { LRUCache } from "lru-cache/raw";
|
|
20
28
|
import {
|
|
@@ -2314,6 +2322,25 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
2314
2322
|
content = [{ type: "text", text: `[Cannot read ${ext} file: conversion failed]` }];
|
|
2315
2323
|
}
|
|
2316
2324
|
} else {
|
|
2325
|
+
// Binary sniff before any UTF-8 text materialization. A binary file
|
|
2326
|
+
// (font, object, archive, packed blob) decodes to NUL/control bytes and
|
|
2327
|
+
// U+FFFD mojibake that corrupts the terminal and burns context. Images,
|
|
2328
|
+
// notebooks, and markit-convertible documents were already routed above;
|
|
2329
|
+
// everything reaching here is meant to be plain text. `:raw` stays the
|
|
2330
|
+
// explicit escape hatch for reading bytes verbatim. This single guard
|
|
2331
|
+
// covers both the multi-range and single-range disk paths below.
|
|
2332
|
+
if (!isRawSelector(parsed) && (await isProbablyBinary(absolutePath))) {
|
|
2333
|
+
return toolResult<ReadToolDetails>({ resolvedPath: absolutePath, suffixResolution })
|
|
2334
|
+
.text(
|
|
2335
|
+
prependSuffixResolutionNotice(
|
|
2336
|
+
`[Cannot read binary file '${formatPathRelativeToCwd(absolutePath, this.session.cwd)}' (${formatBytes(fileSize)}); not valid UTF-8 text. Use ':raw' to read bytes verbatim.]`,
|
|
2337
|
+
suffixResolution,
|
|
2338
|
+
),
|
|
2339
|
+
)
|
|
2340
|
+
.sourcePath(absolutePath)
|
|
2341
|
+
.done();
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2317
2344
|
if (
|
|
2318
2345
|
parsed.kind === "none" &&
|
|
2319
2346
|
this.session.settings.get("read.summarize.enabled") &&
|
|
@@ -2449,33 +2476,6 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
2449
2476
|
// counts in `truncation` keep reflecting the source, not the trimmed
|
|
2450
2477
|
// view — column truncation surfaces separately via `.limits()`.
|
|
2451
2478
|
const rawSelector = isRawSelector(parsed);
|
|
2452
|
-
// Binary sniff: NUL bytes mean the file is not displayable text
|
|
2453
|
-
// (binary, or UTF-16 which has NULs in the ASCII range) — emit a
|
|
2454
|
-
// notice instead of mojibake filling the line budget. `:raw`
|
|
2455
|
-
// stays an explicit escape hatch.
|
|
2456
|
-
//
|
|
2457
|
-
// `collectedLines` covers the common case where at least one
|
|
2458
|
-
// physical line terminates within the byte budget. Binary blobs
|
|
2459
|
-
// without newlines (videos, archives, packed JSON) leave it
|
|
2460
|
-
// empty; their bytes only land in `firstLinePreview`, which the
|
|
2461
|
-
// `firstLineExceedsLimit` branch below would otherwise emit
|
|
2462
|
-
// verbatim. Sniffing the preview here keeps the refusal uniform.
|
|
2463
|
-
if (!rawSelector) {
|
|
2464
|
-
const hasNul = (text: string): boolean => text.includes("\u0000");
|
|
2465
|
-
const binaryDetected =
|
|
2466
|
-
collectedLines.some(hasNul) || (firstLinePreview !== undefined && hasNul(firstLinePreview.text));
|
|
2467
|
-
if (binaryDetected) {
|
|
2468
|
-
return toolResult<ReadToolDetails>({ resolvedPath: absolutePath, suffixResolution })
|
|
2469
|
-
.text(
|
|
2470
|
-
prependSuffixResolutionNotice(
|
|
2471
|
-
`[Cannot read binary file '${formatPathRelativeToCwd(absolutePath, this.session.cwd)}' (${formatBytes(fileSize)}); content contains NUL bytes (binary or UTF-16 encoded)]`,
|
|
2472
|
-
suffixResolution,
|
|
2473
|
-
),
|
|
2474
|
-
)
|
|
2475
|
-
.sourcePath(absolutePath)
|
|
2476
|
-
.done();
|
|
2477
|
-
}
|
|
2478
|
-
}
|
|
2479
2479
|
const maxColumns = resolveOutputMaxColumns(this.session.settings);
|
|
2480
2480
|
// Column truncation is display-only. `collectedLines` MUST stay
|
|
2481
2481
|
// byte-for-byte with the on-disk content so the snapshot recorded
|
|
@@ -10,7 +10,7 @@ import path from "node:path";
|
|
|
10
10
|
import { formatHashlineHeader, formatNumberedLines, type SnapshotStore } from "@oh-my-pi/hashline";
|
|
11
11
|
import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
|
|
12
12
|
import type { ImageContent } from "@oh-my-pi/pi-ai";
|
|
13
|
-
import { formatAge, formatBytes, readImageMetadata } from "@oh-my-pi/pi-utils";
|
|
13
|
+
import { formatAge, formatBytes, isProbablyBinary, readImageMetadata } from "@oh-my-pi/pi-utils";
|
|
14
14
|
import { canonicalSnapshotKey } from "../edit/file-snapshot-store";
|
|
15
15
|
import { normalizeToLF } from "../edit/normalize";
|
|
16
16
|
import type { FileMentionMessage } from "../session/messages";
|
|
@@ -257,6 +257,15 @@ export async function generateFileMentionMessages(
|
|
|
257
257
|
});
|
|
258
258
|
continue;
|
|
259
259
|
}
|
|
260
|
+
if (await isProbablyBinary(absolutePath)) {
|
|
261
|
+
files.push({
|
|
262
|
+
path: resolvedPath,
|
|
263
|
+
content: `(skipped auto-read: binary file, ${formatBytes(stat.size)})`,
|
|
264
|
+
byteSize: stat.size,
|
|
265
|
+
skippedReason: "binary",
|
|
266
|
+
});
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
260
269
|
|
|
261
270
|
const content = await Bun.file(absolutePath).text();
|
|
262
271
|
const snapshotStore = options?.useHashLines ? options.snapshotStore : undefined;
|
package/src/utils/git.ts
CHANGED
|
@@ -74,8 +74,20 @@ export interface StatusOptions {
|
|
|
74
74
|
readonly z?: boolean;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
export interface CommitAuthor {
|
|
78
|
+
readonly date?: string;
|
|
79
|
+
readonly email: string;
|
|
80
|
+
readonly name: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface CommitDetails {
|
|
84
|
+
readonly author: CommitAuthor;
|
|
85
|
+
readonly message: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
77
88
|
export interface CommitOptions {
|
|
78
89
|
readonly allowEmpty?: boolean;
|
|
90
|
+
readonly author?: CommitAuthor;
|
|
79
91
|
readonly files?: readonly string[];
|
|
80
92
|
readonly signal?: AbortSignal;
|
|
81
93
|
}
|
|
@@ -91,6 +103,7 @@ export interface PatchOptions {
|
|
|
91
103
|
readonly cached?: boolean;
|
|
92
104
|
readonly check?: boolean;
|
|
93
105
|
readonly env?: Record<string, string | undefined>;
|
|
106
|
+
readonly threeWay?: boolean;
|
|
94
107
|
readonly signal?: AbortSignal;
|
|
95
108
|
}
|
|
96
109
|
|
|
@@ -359,6 +372,7 @@ function buildApplyArgs(patchPath: string, options: PatchOptions): string[] {
|
|
|
359
372
|
const args = ["apply"];
|
|
360
373
|
if (options.check) args.push("--check");
|
|
361
374
|
if (options.cached) args.push("--cached");
|
|
375
|
+
if (options.threeWay) args.push("--3way");
|
|
362
376
|
args.push("--binary", patchPath);
|
|
363
377
|
return args;
|
|
364
378
|
}
|
|
@@ -1122,6 +1136,10 @@ export const stage = {
|
|
|
1122
1136
|
/** Create a commit with the given message (passed via stdin). */
|
|
1123
1137
|
export async function commit(cwd: string, message: string, options: CommitOptions = {}): Promise<GitCommandResult> {
|
|
1124
1138
|
const args = ["commit", "-F", "-"];
|
|
1139
|
+
if (options.author) {
|
|
1140
|
+
args.push(`--author=${options.author.name} <${options.author.email}>`);
|
|
1141
|
+
if (options.author.date) args.push(`--date=${options.author.date}`);
|
|
1142
|
+
}
|
|
1125
1143
|
if (options.allowEmpty) args.push("--allow-empty");
|
|
1126
1144
|
if (options.files?.length) args.push("--", ...options.files);
|
|
1127
1145
|
return runChecked(cwd, args, { signal: options.signal, stdin: message });
|
|
@@ -1195,6 +1213,19 @@ export const show = Object.assign(
|
|
|
1195
1213
|
},
|
|
1196
1214
|
);
|
|
1197
1215
|
|
|
1216
|
+
/** Read commit message and author metadata for replay/rewrite flows. */
|
|
1217
|
+
export async function commitDetails(cwd: string, revision: string, signal?: AbortSignal): Promise<CommitDetails> {
|
|
1218
|
+
const raw = await runText(cwd, ["show", "-s", "--format=%an%x00%ae%x00%aI%x00%B", revision], {
|
|
1219
|
+
readOnly: true,
|
|
1220
|
+
signal,
|
|
1221
|
+
});
|
|
1222
|
+
const [name = "", email = "", date = "", ...messageParts] = raw.split("\0");
|
|
1223
|
+
return {
|
|
1224
|
+
author: { date, email, name },
|
|
1225
|
+
message: messageParts.join("\0").replace(/\n$/, ""),
|
|
1226
|
+
};
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1198
1229
|
// ════════════════════════════════════════════════════════════════════════════
|
|
1199
1230
|
// API: log
|
|
1200
1231
|
// ════════════════════════════════════════════════════════════════════════════
|
|
@@ -1212,6 +1243,13 @@ export const log = {
|
|
|
1212
1243
|
},
|
|
1213
1244
|
};
|
|
1214
1245
|
|
|
1246
|
+
export const revList = {
|
|
1247
|
+
/** Commits in `base..head`, oldest first. */
|
|
1248
|
+
async range(cwd: string, base: string, head: string, signal?: AbortSignal): Promise<string[]> {
|
|
1249
|
+
return splitLines(await runText(cwd, ["rev-list", "--reverse", `${base}..${head}`], { readOnly: true, signal }));
|
|
1250
|
+
},
|
|
1251
|
+
};
|
|
1252
|
+
|
|
1215
1253
|
// ════════════════════════════════════════════════════════════════════════════
|
|
1216
1254
|
// API: branch
|
|
1217
1255
|
// ════════════════════════════════════════════════════════════════════════════
|
package/src/web/search/index.ts
CHANGED
|
@@ -138,14 +138,20 @@ async function executeSearch(
|
|
|
138
138
|
options: ExecuteSearchOptions,
|
|
139
139
|
): Promise<{ content: Array<{ type: "text"; text: string }>; details: SearchRenderDetails }> {
|
|
140
140
|
const { authStorage, sessionId, signal } = options;
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
141
|
+
const explicitProvider = params.provider;
|
|
142
|
+
let providers: SearchProvider[];
|
|
143
|
+
if (explicitProvider && explicitProvider !== "auto") {
|
|
144
|
+
const provider = await getSearchProvider(explicitProvider);
|
|
145
|
+
providers = (await provider.isExplicitlyAvailable(authStorage))
|
|
146
|
+
? [provider]
|
|
147
|
+
: await resolveProviderChain(authStorage, "auto");
|
|
148
|
+
} else if (explicitProvider === "auto") {
|
|
149
|
+
// Explicit `--provider auto` bypasses the configured preferred provider
|
|
150
|
+
// for this invocation; exclusions still apply.
|
|
151
|
+
providers = await resolveProviderChain(authStorage, "auto");
|
|
152
|
+
} else {
|
|
153
|
+
providers = await resolveProviderChain(authStorage);
|
|
154
|
+
}
|
|
149
155
|
if (providers.length === 0) {
|
|
150
156
|
const message = "No web search provider configured.";
|
|
151
157
|
return {
|
|
@@ -6,122 +6,194 @@ import type { SearchParams } from "./base";
|
|
|
6
6
|
import { SearchProvider } from "./base";
|
|
7
7
|
import { classifyProviderHttpError, withHardTimeout } from "./utils";
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
/**
|
|
10
|
+
* DuckDuckGo's no-JS HTML search frontend. POST `q=…` to receive a static
|
|
11
|
+
* results page we can parse without a real browser. The Instant Answer API
|
|
12
|
+
* (`api.duckduckgo.com`) was tried first but it only returns content for
|
|
13
|
+
* Wikipedia/Wolfram-Alpha-style topics — empty for the vast majority of
|
|
14
|
+
* agent queries (see #3799).
|
|
15
|
+
*/
|
|
16
|
+
const DUCKDUCKGO_HTML_URL = "https://html.duckduckgo.com/html/";
|
|
10
17
|
const DEFAULT_NUM_RESULTS = 10;
|
|
11
18
|
const MAX_NUM_RESULTS = 20;
|
|
12
19
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Recency → DDG `df` form param. DDG accepts single letters for the time
|
|
22
|
+
* filter; queries without a `df` value return the unfiltered default.
|
|
23
|
+
*/
|
|
24
|
+
const RECENCY_TO_DDG_DF: Record<NonNullable<SearchParams["recency"]>, string> = {
|
|
25
|
+
day: "d",
|
|
26
|
+
week: "w",
|
|
27
|
+
month: "m",
|
|
28
|
+
year: "y",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Browser-like UA so DDG serves the standard results page instead of the
|
|
33
|
+
* mobile-only or noscript variants. DDG returns HTTP 202 plus an anomaly
|
|
34
|
+
* modal when it suspects automation; we surface that as a clear error so
|
|
35
|
+
* the orchestrator can fall through to the next provider with context.
|
|
36
|
+
*/
|
|
37
|
+
const BROWSER_USER_AGENT =
|
|
38
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
|
|
39
|
+
|
|
40
|
+
interface ParsedResult {
|
|
41
|
+
title: string;
|
|
42
|
+
url: string;
|
|
43
|
+
snippet?: string;
|
|
17
44
|
}
|
|
18
45
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
function cleanText(value: string | null | undefined): string | undefined {
|
|
31
|
-
const cleaned = value
|
|
32
|
-
?.replace(/<[^>]*>/g, " ")
|
|
46
|
+
/**
|
|
47
|
+
* Decode an HTML-encoded fragment lifted from DDG markup. Strips inline tags
|
|
48
|
+
* (the results page wraps query terms in `<b>`), unescapes the small set of
|
|
49
|
+
* named entities DDG emits, and normalises whitespace.
|
|
50
|
+
*/
|
|
51
|
+
function decodeHtmlText(value: string): string {
|
|
52
|
+
return value
|
|
53
|
+
.replace(/<[^>]*>/g, " ")
|
|
54
|
+
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code)))
|
|
55
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCharCode(Number.parseInt(code, 16)))
|
|
33
56
|
.replace(/ /gi, " ")
|
|
34
57
|
.replace(/&/gi, "&")
|
|
35
58
|
.replace(/</gi, "<")
|
|
36
59
|
.replace(/>/gi, ">")
|
|
37
60
|
.replace(/"/gi, '"')
|
|
38
|
-
.replace(/'/gi, "'")
|
|
61
|
+
.replace(/'|'/gi, "'")
|
|
39
62
|
.replace(/\s+/g, " ")
|
|
40
63
|
.trim();
|
|
41
|
-
return cleaned ? cleaned : undefined;
|
|
42
64
|
}
|
|
43
65
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
66
|
+
/**
|
|
67
|
+
* Resolve a DDG result href back to the underlying target URL.
|
|
68
|
+
*
|
|
69
|
+
* DDG routes outbound clicks through `//duckduckgo.com/l/?uddg=<encoded>` so
|
|
70
|
+
* it can record analytics; we want the unwrapped URL. Handles three shapes
|
|
71
|
+
* the page mixes in practice: redirect wrappers, protocol-relative links,
|
|
72
|
+
* and (rarely) absolute URLs on sponsored or instant answer rows.
|
|
73
|
+
*/
|
|
74
|
+
function unwrapResultUrl(href: string): string | undefined {
|
|
75
|
+
if (!href) return undefined;
|
|
76
|
+
const decoded = href.replace(/&/gi, "&");
|
|
77
|
+
const wrapMatch = decoded.match(/[?&]uddg=([^&]+)/);
|
|
78
|
+
if (wrapMatch) {
|
|
79
|
+
try {
|
|
80
|
+
return decodeURIComponent(wrapMatch[1]);
|
|
81
|
+
} catch {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (decoded.startsWith("//")) return `https:${decoded}`;
|
|
86
|
+
if (decoded.startsWith("http://") || decoded.startsWith("https://")) return decoded;
|
|
87
|
+
return undefined;
|
|
47
88
|
}
|
|
48
89
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
90
|
+
/**
|
|
91
|
+
* Walk the HTML page and pull out result blocks in document order.
|
|
92
|
+
*
|
|
93
|
+
* DDG renders each result inside a `<div class="result …">` container with
|
|
94
|
+
* `<a class="result__a" …>` for the title link and an optional sibling
|
|
95
|
+
* `<a class="result__snippet">` (or `<div class="result__snippet">` in some
|
|
96
|
+
* variants) for the preview text. Sponsored placements, missing snippets,
|
|
97
|
+
* and the trailing pagination row are tolerated.
|
|
98
|
+
*/
|
|
99
|
+
function parseHtmlResults(html: string): ParsedResult[] {
|
|
100
|
+
const results: ParsedResult[] = [];
|
|
101
|
+
const blockRe =
|
|
102
|
+
/<div\b[^>]*\bclass="[^"]*\bresult\b[^"]*"[^>]*>([\s\S]*?)(?=<div\b[^>]*\bclass="[^"]*\bresult\b|<div\b[^>]*\bclass="[^"]*\bnav-link\b|$)/g;
|
|
103
|
+
const titleRe = /<a\b[^>]*\bclass="[^"]*\bresult__a\b[^"]*"[^>]*\bhref="([^"]+)"[^>]*>([\s\S]*?)<\/a>/;
|
|
104
|
+
const snippetRe = /<(?:a|div|span)\b[^>]*\bclass="[^"]*\bresult__snippet\b[^"]*"[^>]*>([\s\S]*?)<\/(?:a|div|span)>/;
|
|
105
|
+
for (const match of html.matchAll(blockRe)) {
|
|
106
|
+
const block = match[1];
|
|
107
|
+
const title = titleRe.exec(block);
|
|
108
|
+
if (!title) continue;
|
|
109
|
+
const url = unwrapResultUrl(title[1]);
|
|
110
|
+
if (!url) continue;
|
|
111
|
+
const titleText = decodeHtmlText(title[2]);
|
|
112
|
+
if (!titleText) continue;
|
|
113
|
+
const snippet = snippetRe.exec(block);
|
|
114
|
+
const snippetText = snippet ? decodeHtmlText(snippet[1]) : undefined;
|
|
115
|
+
results.push({ title: titleText, url, snippet: snippetText || undefined });
|
|
116
|
+
}
|
|
117
|
+
return results;
|
|
58
118
|
}
|
|
59
119
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
120
|
+
/**
|
|
121
|
+
* `true` when the page DDG returned is the bot-challenge modal instead of
|
|
122
|
+
* real results. DDG mixes status codes (200 vs 202) on these so the body
|
|
123
|
+
* check is the reliable signal.
|
|
124
|
+
*/
|
|
125
|
+
function isAnomalyResponse(html: string): boolean {
|
|
126
|
+
return html.includes("anomaly-modal") || html.includes("anomaly.js");
|
|
66
127
|
}
|
|
67
128
|
|
|
68
|
-
async function
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
129
|
+
async function callDuckDuckGoHtml(params: SearchParams): Promise<string> {
|
|
130
|
+
const form = new URLSearchParams({ q: params.query, kl: "us-en" });
|
|
131
|
+
const df = params.recency ? RECENCY_TO_DDG_DF[params.recency] : undefined;
|
|
132
|
+
if (df) form.set("df", df);
|
|
133
|
+
// Add b: "" parameter as specified in the browser fetch template to match real browser form submission
|
|
134
|
+
form.set("b", "");
|
|
135
|
+
|
|
136
|
+
const response = await (params.fetch ?? fetch)(DUCKDUCKGO_HTML_URL, {
|
|
137
|
+
method: "POST",
|
|
138
|
+
body: form.toString(),
|
|
139
|
+
headers: {
|
|
140
|
+
Accept:
|
|
141
|
+
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
|
142
|
+
"Accept-Language": "en,en-US;q=0.9",
|
|
143
|
+
"Cache-Control": "max-age=0",
|
|
144
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
145
|
+
Priority: "u=0, i",
|
|
146
|
+
"Sec-Ch-Ua": '"Google Chrome";v="149", "Chromium";v="149", "Not)A;Brand";v="24"',
|
|
147
|
+
"Sec-Ch-Ua-Mobile": "?0",
|
|
148
|
+
"Sec-Ch-Ua-Platform": '"macOS"',
|
|
149
|
+
"Sec-Fetch-Dest": "document",
|
|
150
|
+
"Sec-Fetch-Mode": "navigate",
|
|
151
|
+
"Sec-Fetch-Site": "same-origin",
|
|
152
|
+
"Sec-Fetch-User": "?1",
|
|
153
|
+
"Upgrade-Insecure-Requests": "1",
|
|
154
|
+
"User-Agent": BROWSER_USER_AGENT,
|
|
155
|
+
Referer: "https://html.duckduckgo.com/",
|
|
156
|
+
},
|
|
81
157
|
signal: withHardTimeout(params.signal),
|
|
82
158
|
});
|
|
83
159
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const classified = classifyProviderHttpError("duckduckgo", response.status,
|
|
160
|
+
const body = await response.text();
|
|
161
|
+
if (!response.ok && response.status !== 202) {
|
|
162
|
+
const classified = classifyProviderHttpError("duckduckgo", response.status, body);
|
|
87
163
|
if (classified) throw classified;
|
|
164
|
+
throw new SearchProviderError("duckduckgo", `DuckDuckGo HTML error (${response.status})`, response.status);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (isAnomalyResponse(body)) {
|
|
88
168
|
throw new SearchProviderError(
|
|
89
169
|
"duckduckgo",
|
|
90
|
-
|
|
91
|
-
|
|
170
|
+
"DuckDuckGo blocked the request with a bot-detection challenge. DuckDuckGo throttles repeat searches from datacenter and shared-egress IPs; configure another provider (e.g. Brave, Tavily, SearXNG) or retry from a residential network.",
|
|
171
|
+
429,
|
|
92
172
|
);
|
|
93
173
|
}
|
|
94
174
|
|
|
95
|
-
return
|
|
175
|
+
return body;
|
|
96
176
|
}
|
|
97
177
|
|
|
98
|
-
/** Execute DuckDuckGo
|
|
178
|
+
/** Execute a DuckDuckGo web search via the no-JS HTML frontend. */
|
|
99
179
|
export async function searchDuckDuckGo(params: SearchParams): Promise<SearchResponse> {
|
|
100
180
|
const numResults = clampNumResults(params.numSearchResults ?? params.limit, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS);
|
|
101
|
-
const
|
|
102
|
-
const
|
|
103
|
-
const sources: SearchSource[] = [];
|
|
181
|
+
const html = await callDuckDuckGoHtml(params);
|
|
182
|
+
const parsed = parseHtmlResults(html);
|
|
104
183
|
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
184
|
+
const sources: SearchSource[] = [];
|
|
185
|
+
const seen = new Set<string>();
|
|
186
|
+
for (const result of parsed) {
|
|
187
|
+
if (seen.has(result.url)) continue;
|
|
188
|
+
seen.add(result.url);
|
|
189
|
+
sources.push({ title: result.title, url: result.url, snippet: result.snippet });
|
|
190
|
+
if (sources.length >= numResults) break;
|
|
112
191
|
}
|
|
113
192
|
|
|
114
|
-
|
|
115
|
-
collectTopicSources(sources, data.RelatedTopics);
|
|
116
|
-
|
|
117
|
-
return {
|
|
118
|
-
provider: "duckduckgo",
|
|
119
|
-
answer,
|
|
120
|
-
sources: sources.slice(0, numResults),
|
|
121
|
-
};
|
|
193
|
+
return { provider: "duckduckgo", sources };
|
|
122
194
|
}
|
|
123
195
|
|
|
124
|
-
/** Search provider for DuckDuckGo
|
|
196
|
+
/** Search provider for DuckDuckGo (no API key required). */
|
|
125
197
|
export class DuckDuckGoProvider extends SearchProvider {
|
|
126
198
|
readonly id = "duckduckgo";
|
|
127
199
|
readonly label = "DuckDuckGo";
|