@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.
Files changed (93) hide show
  1. package/CHANGELOG.md +41 -1
  2. package/dist/cli.js +3089 -3104
  3. package/dist/types/cli/bench-cli.d.ts +6 -1
  4. package/dist/types/commands/bench.d.ts +8 -0
  5. package/dist/types/config/model-discovery.d.ts +6 -1
  6. package/dist/types/config/service-tier.d.ts +39 -24
  7. package/dist/types/config/settings-schema.d.ts +37 -37
  8. package/dist/types/edit/index.d.ts +1 -0
  9. package/dist/types/edit/renderer.d.ts +4 -0
  10. package/dist/types/edit/snapshot-details.d.ts +33 -0
  11. package/dist/types/mcp/config-writer.d.ts +48 -0
  12. package/dist/types/mcp/oauth-flow.d.ts +4 -6
  13. package/dist/types/mcp/types.d.ts +3 -0
  14. package/dist/types/modes/controllers/tool-args-reveal.d.ts +9 -2
  15. package/dist/types/session/agent-session.d.ts +37 -13
  16. package/dist/types/session/messages.d.ts +1 -1
  17. package/dist/types/session/session-context.d.ts +2 -2
  18. package/dist/types/session/session-entries.d.ts +2 -2
  19. package/dist/types/session/session-manager.d.ts +5 -6
  20. package/dist/types/system-prompt.test.d.ts +1 -0
  21. package/dist/types/task/executor.d.ts +6 -6
  22. package/dist/types/task/types.d.ts +6 -0
  23. package/dist/types/task/worktree.d.ts +32 -6
  24. package/dist/types/tiny/title-client.d.ts +5 -1
  25. package/dist/types/tools/index.d.ts +3 -3
  26. package/dist/types/utils/git.d.ts +17 -0
  27. package/dist/types/web/search/providers/duckduckgo.d.ts +2 -2
  28. package/dist/types/web/search/types.d.ts +1 -1
  29. package/package.json +12 -12
  30. package/src/cli/args.ts +32 -1
  31. package/src/cli/bench-cli.ts +97 -22
  32. package/src/cli/tiny-models-cli.ts +18 -4
  33. package/src/cli/web-search-cli.ts +6 -1
  34. package/src/commands/bench.ts +10 -1
  35. package/src/config/mcp-schema.json +11 -2
  36. package/src/config/model-discovery.ts +66 -8
  37. package/src/config/model-registry.ts +13 -6
  38. package/src/config/service-tier.ts +85 -56
  39. package/src/config/settings-schema.ts +42 -36
  40. package/src/config/settings.ts +47 -0
  41. package/src/edit/hashline/execute.ts +15 -9
  42. package/src/edit/index.ts +19 -6
  43. package/src/edit/modes/patch.ts +3 -2
  44. package/src/edit/modes/replace.ts +3 -2
  45. package/src/edit/renderer.ts +4 -0
  46. package/src/edit/snapshot-details.ts +77 -0
  47. package/src/eval/agent-bridge.ts +4 -2
  48. package/src/extensibility/plugins/legacy-pi-compat.ts +2 -2
  49. package/src/internal-urls/docs-index.generated.txt +1 -1
  50. package/src/main.ts +1 -1
  51. package/src/mcp/config-writer.ts +121 -0
  52. package/src/mcp/config.ts +10 -6
  53. package/src/mcp/oauth-flow.ts +10 -8
  54. package/src/mcp/transports/stdio.ts +9 -17
  55. package/src/mcp/types.ts +3 -0
  56. package/src/modes/components/extensions/extension-dashboard.ts +46 -0
  57. package/src/modes/components/extensions/state-manager.ts +24 -3
  58. package/src/modes/controllers/event-controller.ts +24 -8
  59. package/src/modes/controllers/tool-args-reveal.ts +100 -22
  60. package/src/modes/utils/transcript-render-helpers.ts +2 -2
  61. package/src/prompts/bench.md +4 -10
  62. package/src/prompts/tools/irc.md +19 -29
  63. package/src/prompts/tools/job.md +8 -14
  64. package/src/prompts/tools/lsp.md +19 -30
  65. package/src/prompts/tools/task.md +42 -62
  66. package/src/sdk.ts +13 -11
  67. package/src/session/agent-session.ts +196 -76
  68. package/src/session/messages.ts +1 -1
  69. package/src/session/session-context.ts +4 -4
  70. package/src/session/session-entries.ts +2 -2
  71. package/src/session/session-listing.ts +9 -8
  72. package/src/session/session-loader.ts +98 -3
  73. package/src/session/session-manager.ts +43 -6
  74. package/src/slash-commands/builtin-registry.ts +2 -10
  75. package/src/subprocess/worker-client.ts +12 -4
  76. package/src/system-prompt.test.ts +158 -0
  77. package/src/system-prompt.ts +69 -26
  78. package/src/task/executor.ts +23 -16
  79. package/src/task/index.ts +7 -5
  80. package/src/task/isolation-runner.ts +15 -1
  81. package/src/task/types.ts +6 -0
  82. package/src/task/worktree.ts +219 -38
  83. package/src/tiny/title-client.ts +19 -13
  84. package/src/tools/index.ts +3 -3
  85. package/src/tools/irc.ts +9 -3
  86. package/src/tools/path-utils.ts +4 -2
  87. package/src/tools/read.ts +28 -28
  88. package/src/utils/file-mentions.ts +10 -1
  89. package/src/utils/git.ts +38 -0
  90. package/src/web/search/index.ts +14 -8
  91. package/src/web/search/providers/duckduckgo.ts +150 -78
  92. package/src/web/search/providers/gemini.ts +268 -185
  93. 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 { getRemoteDir, type ImageMetadata, logger, prompt, readImageMetadata, untilAborted } from "@oh-my-pi/pi-utils";
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
  // ════════════════════════════════════════════════════════════════════════════
@@ -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 providers =
142
- params.provider && params.provider !== "auto"
143
- ? await getSearchProvider(params.provider).then(async provider =>
144
- (await provider.isExplicitlyAvailable(authStorage))
145
- ? [provider]
146
- : resolveProviderChain(authStorage, "auto"),
147
- )
148
- : await resolveProviderChain(authStorage);
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
- const DUCKDUCKGO_SEARCH_URL = "https://api.duckduckgo.com/";
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
- interface DuckDuckGoTopic {
14
- FirstURL?: string | null;
15
- Text?: string | null;
16
- Topics?: DuckDuckGoTopic[] | null;
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
- interface DuckDuckGoResponse {
20
- AbstractText?: string | null;
21
- AbstractURL?: string | null;
22
- AbstractSource?: string | null;
23
- Answer?: string | null;
24
- Definition?: string | null;
25
- Heading?: string | null;
26
- Results?: DuckDuckGoTopic[] | null;
27
- RelatedTopics?: DuckDuckGoTopic[] | null;
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(/&nbsp;/gi, " ")
34
57
  .replace(/&amp;/gi, "&")
35
58
  .replace(/&lt;/gi, "<")
36
59
  .replace(/&gt;/gi, ">")
37
60
  .replace(/&quot;/gi, '"')
38
- .replace(/&#39;/gi, "'")
61
+ .replace(/&#39;|&apos;/gi, "'")
39
62
  .replace(/\s+/g, " ")
40
63
  .trim();
41
- return cleaned ? cleaned : undefined;
42
64
  }
43
65
 
44
- function addSource(sources: SearchSource[], source: SearchSource): void {
45
- if (!source.url || sources.some(existing => existing.url === source.url)) return;
46
- sources.push(source);
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(/&amp;/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
- function addTopicSource(sources: SearchSource[], topic: DuckDuckGoTopic): void {
50
- const url = topic.FirstURL?.trim();
51
- if (!url) return;
52
- const text = cleanText(topic.Text);
53
- addSource(sources, {
54
- title: text ?? url,
55
- url,
56
- snippet: text,
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
- function collectTopicSources(sources: SearchSource[], topics: readonly DuckDuckGoTopic[] | null | undefined): void {
61
- if (!topics) return;
62
- for (const topic of topics) {
63
- addTopicSource(sources, topic);
64
- collectTopicSources(sources, topic.Topics);
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 callDuckDuckGoSearch(params: SearchParams): Promise<DuckDuckGoResponse> {
69
- const queryString = [
70
- ["q", params.query],
71
- ["format", "json"],
72
- ["no_redirect", "1"],
73
- ["no_html", "1"],
74
- ["skip_disambig", "1"],
75
- ["t", "oh-my-pi"],
76
- ]
77
- .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
78
- .join("&");
79
- const response = await (params.fetch ?? fetch)(`${DUCKDUCKGO_SEARCH_URL}?${queryString}`, {
80
- method: "GET",
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
- if (!response.ok) {
85
- const errorText = await response.text();
86
- const classified = classifyProviderHttpError("duckduckgo", response.status, errorText);
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
- `DuckDuckGo API error (${response.status}): ${errorText}`,
91
- response.status,
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 (await response.json()) as DuckDuckGoResponse;
175
+ return body;
96
176
  }
97
177
 
98
- /** Execute DuckDuckGo Instant Answer API search. */
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 data = await callDuckDuckGoSearch(params);
102
- const answer = cleanText(data.AbstractText) ?? cleanText(data.Answer) ?? cleanText(data.Definition);
103
- const sources: SearchSource[] = [];
181
+ const html = await callDuckDuckGoHtml(params);
182
+ const parsed = parseHtmlResults(html);
104
183
 
105
- const abstractUrl = data.AbstractURL?.trim();
106
- if (abstractUrl) {
107
- addSource(sources, {
108
- title: cleanText(data.AbstractSource) ?? cleanText(data.Heading) ?? abstractUrl,
109
- url: abstractUrl,
110
- snippet: cleanText(data.AbstractText),
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
- collectTopicSources(sources, data.Results);
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 Instant Answer API. */
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";