@oh-my-pi/pi-coding-agent 16.4.8 → 16.5.0

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 (134) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/dist/cli.js +3502 -3444
  3. package/dist/types/cli/args.d.ts +5 -0
  4. package/dist/types/cli/gallery-fixtures/shell.d.ts +1 -1
  5. package/dist/types/commands/launch.d.ts +15 -0
  6. package/dist/types/config/model-resolver.d.ts +4 -3
  7. package/dist/types/config/model-roles.d.ts +8 -0
  8. package/dist/types/config/settings-schema.d.ts +46 -14
  9. package/dist/types/extensibility/extensions/types.d.ts +1 -1
  10. package/dist/types/launch/broker.d.ts +2 -0
  11. package/dist/types/launch/client.d.ts +22 -0
  12. package/dist/types/launch/paths.d.ts +4 -0
  13. package/dist/types/launch/presence.d.ts +8 -0
  14. package/dist/types/launch/protocol.d.ts +170 -0
  15. package/dist/types/launch/terminal-output.d.ts +7 -0
  16. package/dist/types/modes/components/agent-hub.d.ts +1 -1
  17. package/dist/types/modes/components/index.d.ts +1 -0
  18. package/dist/types/modes/components/model-browser.d.ts +17 -2
  19. package/dist/types/modes/components/model-hub.d.ts +0 -10
  20. package/dist/types/modes/components/model-picker.d.ts +44 -0
  21. package/dist/types/modes/components/status-line/types.d.ts +3 -0
  22. package/dist/types/modes/print-mode.d.ts +14 -8
  23. package/dist/types/modes/print-mode.test.d.ts +1 -0
  24. package/dist/types/modes/theme/theme.d.ts +2 -1
  25. package/dist/types/sdk.d.ts +5 -1
  26. package/dist/types/session/agent-session.d.ts +42 -0
  27. package/dist/types/session/session-context.d.ts +9 -0
  28. package/dist/types/session/session-entries.d.ts +6 -0
  29. package/dist/types/thinking.d.ts +2 -2
  30. package/dist/types/tiny/models.d.ts +1 -1
  31. package/dist/types/tools/browser/launch.d.ts +1 -0
  32. package/dist/types/tools/browser/run-cancellation.d.ts +28 -2
  33. package/dist/types/tools/browser/tab-protocol.d.ts +6 -0
  34. package/dist/types/tools/browser/tab-worker.d.ts +6 -0
  35. package/dist/types/tools/builtin-names.d.ts +1 -1
  36. package/dist/types/tools/index.d.ts +1 -0
  37. package/dist/types/tools/launch.d.ts +121 -0
  38. package/dist/types/tools/render-utils.d.ts +2 -0
  39. package/dist/types/tools/terminal-output.d.ts +5 -0
  40. package/dist/types/vibe/runtime.d.ts +2 -2
  41. package/dist/types/web/search/types.d.ts +0 -8
  42. package/package.json +20 -20
  43. package/src/cli/args.ts +11 -0
  44. package/src/cli/flag-tables.ts +9 -0
  45. package/src/cli/gallery-fixtures/shell.ts +82 -1
  46. package/src/cli.ts +10 -0
  47. package/src/commands/launch.ts +17 -0
  48. package/src/config/model-resolver.ts +110 -31
  49. package/src/config/model-roles.ts +14 -0
  50. package/src/config/settings-schema.ts +71 -7
  51. package/src/edit/renderer.ts +13 -10
  52. package/src/eval/__tests__/agent-bridge.test.ts +2 -2
  53. package/src/eval/__tests__/completion-bridge.test.ts +1 -1
  54. package/src/eval/completion-bridge.ts +4 -4
  55. package/src/export/ttsr.ts +0 -3
  56. package/src/extensibility/extensions/model-api.ts +1 -1
  57. package/src/extensibility/extensions/types.ts +1 -1
  58. package/src/internal-urls/docs-index.ts +4 -4
  59. package/src/launch/broker.ts +1017 -0
  60. package/src/launch/client.ts +344 -0
  61. package/src/launch/paths.ts +17 -0
  62. package/src/launch/presence.ts +82 -0
  63. package/src/launch/protocol.ts +386 -0
  64. package/src/launch/terminal-output.ts +46 -0
  65. package/src/main.ts +50 -1
  66. package/src/modes/acp/acp-agent.ts +8 -1
  67. package/src/modes/components/agent-hub.ts +101 -31
  68. package/src/modes/components/compaction-summary-message.ts +8 -2
  69. package/src/modes/components/index.ts +1 -0
  70. package/src/modes/components/model-browser.ts +111 -21
  71. package/src/modes/components/model-hub.ts +23 -116
  72. package/src/modes/components/model-picker.ts +233 -0
  73. package/src/modes/components/snapcompact-shape-preview-doc.md +7 -11
  74. package/src/modes/components/status-line/component.test.ts +41 -2
  75. package/src/modes/components/status-line/component.ts +4 -0
  76. package/src/modes/components/status-line/segments.ts +6 -0
  77. package/src/modes/components/status-line/types.ts +3 -0
  78. package/src/modes/controllers/command-controller.ts +9 -1
  79. package/src/modes/controllers/event-controller.ts +31 -32
  80. package/src/modes/controllers/selector-controller.ts +96 -22
  81. package/src/modes/controllers/tan-command-controller.ts +40 -1
  82. package/src/modes/interactive-mode.ts +7 -3
  83. package/src/modes/print-mode.test.ts +71 -0
  84. package/src/modes/print-mode.ts +51 -2
  85. package/src/modes/theme/theme.ts +9 -0
  86. package/src/modes/utils/ui-helpers.ts +25 -4
  87. package/src/prompts/agents/designer.md +1 -1
  88. package/src/prompts/agents/librarian.md +1 -1
  89. package/src/prompts/agents/reviewer.md +1 -1
  90. package/src/prompts/agents/scout.md +1 -1
  91. package/src/prompts/system/plan-yolo-handoff.md +5 -0
  92. package/src/prompts/system/prewalk-checklist.md +7 -0
  93. package/src/prompts/system/prewalk-continue.md +1 -0
  94. package/src/prompts/system/prewalk-plan.md +13 -0
  95. package/src/prompts/system/system-prompt.md +9 -7
  96. package/src/prompts/system/tan-context-switch.md +17 -0
  97. package/src/prompts/tools/bash.md +6 -4
  98. package/src/prompts/tools/browser.md +4 -4
  99. package/src/prompts/tools/launch.md +25 -0
  100. package/src/sdk.ts +8 -2
  101. package/src/session/agent-session.ts +550 -87
  102. package/src/session/session-context.test.ts +10 -5
  103. package/src/session/session-context.ts +25 -8
  104. package/src/session/session-entries.ts +6 -0
  105. package/src/slash-commands/builtin-registry.ts +25 -0
  106. package/src/task/agents.ts +2 -2
  107. package/src/thinking.ts +10 -3
  108. package/src/tiny/models.ts +7 -7
  109. package/src/tools/bash-interactive.ts +5 -8
  110. package/src/tools/bash.ts +38 -24
  111. package/src/tools/browser/cmux/cmux-tab.ts +17 -2
  112. package/src/tools/browser/launch.ts +8 -4
  113. package/src/tools/browser/run-cancellation.ts +66 -6
  114. package/src/tools/browser/tab-protocol.ts +6 -0
  115. package/src/tools/browser/tab-supervisor.ts +17 -1
  116. package/src/tools/browser/tab-worker.ts +140 -21
  117. package/src/tools/builtin-names.ts +1 -0
  118. package/src/tools/eval-render.ts +16 -14
  119. package/src/tools/index.ts +5 -0
  120. package/src/tools/inspect-image.ts +2 -2
  121. package/src/tools/launch.ts +643 -0
  122. package/src/tools/render-utils.ts +3 -0
  123. package/src/tools/renderers.ts +2 -0
  124. package/src/tools/terminal-output.ts +141 -0
  125. package/src/tts/speech-enhancer.ts +2 -2
  126. package/src/utils/image-vision-fallback.ts +3 -3
  127. package/src/vibe/runtime.ts +2 -2
  128. package/src/web/search/provider.ts +0 -10
  129. package/src/web/search/providers/public.ts +2 -4
  130. package/src/web/search/types.ts +0 -10
  131. package/dist/types/web/search/providers/bing.d.ts +0 -14
  132. package/dist/types/web/search/providers/yahoo.d.ts +0 -14
  133. package/src/web/search/providers/bing.ts +0 -197
  134. package/src/web/search/providers/yahoo.ts +0 -179
@@ -0,0 +1,141 @@
1
+ import { sanitizeText } from "@oh-my-pi/pi-utils";
2
+ import type { Terminal as XtermTerminal } from "@xterm/headless";
3
+
4
+ const RESET = "\x1b[0m";
5
+ const SGR = /\x1b\[([0-9;]*)m/g;
6
+
7
+ interface TerminalCell {
8
+ getChars(): string;
9
+ getWidth(): number;
10
+ getFgColor(): number;
11
+ getBgColor(): number;
12
+ isBold(): number;
13
+ isDim(): number;
14
+ isItalic(): number;
15
+ isUnderline(): number;
16
+ isInverse(): number;
17
+ isStrikethrough(): number;
18
+ isOverline(): number;
19
+ isFgRGB(): boolean;
20
+ isBgRGB(): boolean;
21
+ isFgPalette(): boolean;
22
+ isBgPalette(): boolean;
23
+ }
24
+
25
+ function addColor(codes: number[], cell: TerminalCell, foreground: boolean): void {
26
+ const rgb = foreground ? cell.isFgRGB() : cell.isBgRGB();
27
+ const palette = foreground ? cell.isFgPalette() : cell.isBgPalette();
28
+ if (!rgb && !palette) return;
29
+
30
+ const color = foreground ? cell.getFgColor() : cell.getBgColor();
31
+ codes.push(foreground ? 38 : 48);
32
+ if (rgb) {
33
+ codes.push(2, (color >> 16) & 0xff, (color >> 8) & 0xff, color & 0xff);
34
+ } else {
35
+ codes.push(5, color);
36
+ }
37
+ }
38
+
39
+ function cellStyle(cell: TerminalCell): string {
40
+ const codes: number[] = [];
41
+ if (cell.isBold() !== 0) codes.push(1);
42
+ if (cell.isDim() !== 0) codes.push(2);
43
+ if (cell.isItalic() !== 0) codes.push(3);
44
+ if (cell.isUnderline() !== 0) codes.push(4);
45
+ if (cell.isInverse() !== 0) codes.push(7);
46
+ if (cell.isStrikethrough() !== 0) codes.push(9);
47
+ if (cell.isOverline() !== 0) codes.push(53);
48
+ addColor(codes, cell, true);
49
+ addColor(codes, cell, false);
50
+ return codes.length > 0 ? `\x1b[${codes.join(";")}m` : "";
51
+ }
52
+
53
+ function isSafeStyle(codes: readonly number[]): boolean {
54
+ let index = 0;
55
+ while (index < codes.length) {
56
+ const code = codes[index++];
57
+ if (code === 1 || code === 2 || code === 3 || code === 4 || code === 7 || code === 9 || code === 53) continue;
58
+ if (code !== 38 && code !== 48) return false;
59
+ const mode = codes[index++];
60
+ if (mode === 5) {
61
+ const color = codes[index++];
62
+ if (color === undefined || color < 0 || color > 255) return false;
63
+ continue;
64
+ }
65
+ if (mode !== 2) return false;
66
+ for (let channel = 0; channel < 3; channel++) {
67
+ const color = codes[index++];
68
+ if (color === undefined || color < 0 || color > 255) return false;
69
+ }
70
+ }
71
+ return true;
72
+ }
73
+
74
+ /** Applies the active tool-output color while preserving safe styles from a virtual terminal row. */
75
+ export function styleTerminalRow(row: string, baseForeground: string): string {
76
+ let output = baseForeground;
77
+ let offset = 0;
78
+ let hasText = false;
79
+ for (const match of row.matchAll(SGR)) {
80
+ const index = match.index ?? 0;
81
+ const text = sanitizeText(row.slice(offset, index));
82
+ output += text;
83
+ hasText ||= text.length > 0;
84
+
85
+ const codes = match[1].split(";").map(Number);
86
+ if (match[1] === "0") output += `${RESET}${baseForeground}`;
87
+ else if (codes.length > 0 && codes.every(Number.isInteger) && isSafeStyle(codes)) output += match[0];
88
+ offset = index + match[0].length;
89
+ }
90
+ const text = sanitizeText(row.slice(offset));
91
+ output += text;
92
+ hasText ||= text.length > 0;
93
+ return hasText ? `${output}${RESET}` : "";
94
+ }
95
+
96
+ /** Reads terminal screen rows as sanitized text plus only the styles the TUI may replay. */
97
+ export function readTerminalRows(terminal: XtermTerminal, startRow: number, rowCount: number): string[] {
98
+ const buffer = terminal.buffer.active;
99
+ const reusableCell = buffer.getNullCell();
100
+ const rows: string[] = [];
101
+ const endRow = Math.min(buffer.length, Math.max(0, startRow) + Math.max(0, rowCount));
102
+
103
+ for (let row = Math.max(0, startRow); row < endRow; row++) {
104
+ const line = buffer.getLine(row);
105
+ if (!line) {
106
+ rows.push("");
107
+ continue;
108
+ }
109
+
110
+ const cells: Array<{ chars: string; style: string }> = [];
111
+ let lastContent = -1;
112
+ for (let column = 0; column < line.length; ) {
113
+ const cell = line.getCell(column, reusableCell);
114
+ if (!cell) break;
115
+ const chars = cell.getChars();
116
+ const width = Math.max(1, cell.getWidth());
117
+ cells.push({ chars: chars || " ", style: cellStyle(cell) });
118
+ if (chars && chars !== " ") lastContent = cells.length - 1;
119
+ column += width;
120
+ }
121
+
122
+ if (lastContent < 0) {
123
+ rows.push("");
124
+ continue;
125
+ }
126
+
127
+ let rendered = "";
128
+ let previousStyle: string | undefined;
129
+ for (let index = 0; index <= lastContent; index++) {
130
+ const cell = cells[index]!;
131
+ if (cell.style !== previousStyle) {
132
+ rendered += `${RESET}${cell.style}`;
133
+ previousStyle = cell.style;
134
+ }
135
+ rendered += cell.chars;
136
+ }
137
+ rows.push(rendered);
138
+ }
139
+
140
+ return rows;
141
+ }
@@ -70,10 +70,10 @@ export class SpeechEnhancer {
70
70
  async rewrite(block: string, signal?: AbortSignal): Promise<string | null> {
71
71
  try {
72
72
  const { settings, registry, sessionId } = this.#deps;
73
- // `pi/tiny` expands a configured `modelRoles.tiny` and otherwise falls
73
+ // `@tiny` expands a configured `modelRoles.tiny` and otherwise falls
74
74
  // through tiny's alias to the smol priority chain — unlike bare role
75
75
  // lookup, this resolves even with no roles configured.
76
- const model = resolveModelRoleValue("pi/tiny", registry.getAvailable(), {
76
+ const model = resolveModelRoleValue("@tiny", registry.getAvailable(), {
77
77
  settings,
78
78
  matchPreferences: getModelMatchPreferences(settings),
79
79
  }).model;
@@ -97,7 +97,7 @@ function formatImageBlock(localUrl: string, description: string): string {
97
97
 
98
98
  /**
99
99
  * Resolve a vision-capable model, mirroring the inspect_image priority
100
- * (`pi/vision` → `pi/default` → active → first image-capable available), but
100
+ * (`@vision` → `@default` → active → first image-capable available), but
101
101
  * never returning a text-only model.
102
102
  */
103
103
  function resolveVisionModel(deps: DescribeAttachedImagesDeps): Model<Api> | undefined {
@@ -111,8 +111,8 @@ function resolveVisionModel(deps: DescribeAttachedImagesDeps): Model<Api> | unde
111
111
  return model?.input.includes("image") ? model : undefined;
112
112
  };
113
113
  return (
114
- resolvePattern("pi/vision") ??
115
- resolvePattern("pi/default") ??
114
+ resolvePattern("@vision") ??
115
+ resolvePattern("@default") ??
116
116
  resolvePattern(deps.activeModelString) ??
117
117
  available.find(model => model.input.includes("image"))
118
118
  );
@@ -39,8 +39,8 @@ export type VibeCli = "fast" | "good";
39
39
 
40
40
  /**
41
41
  * CLI flavor → bundled agent type. This IS the model-tier mapping: `sonic`
42
- * carries `model: "pi/smol"` (the configured fast/low-latency role) and `task`
43
- * carries `model: "pi/task"` (inherits the session's strong model).
42
+ * carries `model: "@smol"` (the configured fast/low-latency role) and `task`
43
+ * carries `model: "@task"` (inherits the session's strong model).
44
44
  * Resolution goes through {@link resolveAgentModelPatterns} exactly like a
45
45
  * `task` spawn, so `task.agentModelOverrides` and model-role settings apply.
46
46
  */
@@ -119,16 +119,6 @@ const PROVIDER_META: Record<SearchProviderId, ProviderMeta> = {
119
119
  label: SEARCH_PROVIDER_LABELS.google,
120
120
  load: async () => new (await import("./providers/google")).GoogleProvider(),
121
121
  },
122
- bing: {
123
- id: "bing",
124
- label: SEARCH_PROVIDER_LABELS.bing,
125
- load: async () => new (await import("./providers/bing")).BingProvider(),
126
- },
127
- yahoo: {
128
- id: "yahoo",
129
- label: SEARCH_PROVIDER_LABELS.yahoo,
130
- load: async () => new (await import("./providers/yahoo")).YahooProvider(),
131
- },
132
122
  ecosia: {
133
123
  id: "ecosia",
134
124
  label: SEARCH_PROVIDER_LABELS.ecosia,
@@ -11,15 +11,13 @@ import { withHardTimeout } from "./utils";
11
11
  * Credential-free engines the Public Web aggregate fans out to. Order is the
12
12
  * tiebreak for merged ranking (earlier engines win equal consensus/rank), so
13
13
  * engines with the best ranking quality when they answer come first:
14
- * Google-index engines (startpage, google) lead, Bing-backed scrapers follow,
15
- * and Mojeek's independent index breaks remaining ties (measured 2026-07).
14
+ * Google-index engines (startpage, google) lead, and Mojeek's independent
15
+ * index breaks remaining ties (measured 2026-07).
16
16
  */
17
17
  const PUBLIC_ENGINE_IDS = [
18
18
  "startpage",
19
19
  "google",
20
20
  "duckduckgo",
21
- "bing",
22
- "yahoo",
23
21
  "ecosia",
24
22
  "mojeek",
25
23
  ] as const satisfies readonly SearchProviderId[];
@@ -53,16 +53,6 @@ export const SEARCH_PROVIDER_OPTIONS = [
53
53
  label: "DuckDuckGo",
54
54
  description: "Credential-free best-effort fallback; may be bot-challenged on datacenter/shared-egress IPs",
55
55
  },
56
- {
57
- value: "bing",
58
- label: "Bing",
59
- description: "Credential-free HTML scrape of Bing results; may be bot-challenged",
60
- },
61
- {
62
- value: "yahoo",
63
- label: "Yahoo",
64
- description: "Credential-free HTML scrape of Yahoo (Bing-backed) results",
65
- },
66
56
  {
67
57
  value: "ecosia",
68
58
  label: "Ecosia",
@@ -1,14 +0,0 @@
1
- import type { AuthStorage } from "@oh-my-pi/pi-ai";
2
- import type { SearchResponse } from "../../../web/search/types.js";
3
- import type { SearchParams } from "./base.js";
4
- import { SearchProvider } from "./base.js";
5
- /** Execute a Bing web search via the server-rendered HTML results page. */
6
- export declare function searchBing(params: SearchParams): Promise<SearchResponse>;
7
- /** Search provider for Bing (no API key required). */
8
- export declare class BingProvider extends SearchProvider {
9
- readonly id = "bing";
10
- readonly label = "Bing";
11
- isAvailable(_authStorage: AuthStorage): boolean;
12
- isExplicitlyAvailable(_authStorage: AuthStorage): boolean;
13
- search(params: SearchParams): Promise<SearchResponse>;
14
- }
@@ -1,14 +0,0 @@
1
- import type { AuthStorage } from "@oh-my-pi/pi-ai";
2
- import type { SearchResponse } from "../../../web/search/types.js";
3
- import type { SearchParams } from "./base.js";
4
- import { SearchProvider } from "./base.js";
5
- /** Execute a Yahoo web search via the server-rendered HTML results page. */
6
- export declare function searchYahoo(params: SearchParams): Promise<SearchResponse>;
7
- /** Search provider for Yahoo (no API key required). */
8
- export declare class YahooProvider extends SearchProvider {
9
- readonly id = "yahoo";
10
- readonly label = "Yahoo";
11
- isAvailable(_authStorage: AuthStorage): boolean;
12
- isExplicitlyAvailable(_authStorage: AuthStorage): boolean;
13
- search(params: SearchParams): Promise<SearchResponse>;
14
- }
@@ -1,197 +0,0 @@
1
- import type { AuthStorage } from "@oh-my-pi/pi-ai";
2
- import { parseHTML } from "linkedom";
3
- import type { SearchResponse, SearchSource } from "../../../web/search/types";
4
- import { SearchProviderError } from "../../../web/search/types";
5
- import { clampNumResults } from "../utils";
6
- import type { SearchParams } from "./base";
7
- import { SearchProvider } from "./base";
8
- import { browserFetch } from "./browser-page";
9
- import { classifyProviderHttpError, withHardTimeout } from "./utils";
10
-
11
- /**
12
- * Bing's HTML search frontend. A plain GET with browser navigation headers
13
- * returns a fully server-rendered results page — no JavaScript challenge on
14
- * the organic path — so we parse it directly without a real browser.
15
- */
16
- const BING_HOME_URL = "https://www.bing.com/";
17
- const BING_SEARCH_URL = "https://www.bing.com/search";
18
- const DEFAULT_NUM_RESULTS = 10;
19
- const MAX_NUM_RESULTS = 20;
20
- const MS_PER_DAY = 86_400_000;
21
-
22
- /**
23
- * Recency → Bing `filters=ex1:"…"` freshness codes, as emitted by Bing's own
24
- * "Any time" dropdown. `year` has no fixed code; the dropdown emits a custom
25
- * epoch-day range (`ez5_<start>_<end>`, days since 1970-01-01) which
26
- * {@link recencyToFilters} computes. Bing parses the parameter (the SERP
27
- * filter UI reflects it) but enforcement is server-side and vantage-dependent.
28
- */
29
- const RECENCY_TO_BING_EZ: Record<Exclude<NonNullable<SearchParams["recency"]>, "year">, string> = {
30
- day: "ez1",
31
- week: "ez2",
32
- month: "ez3",
33
- };
34
-
35
- /** Snippet containers observed on Bing result blocks, in preference order. */
36
- const BING_SNIPPET_SELECTORS: readonly string[] = [".b_caption p", "p[class*='b_lineclamp']", ".b_algoSlug"];
37
-
38
- interface ParsedResult {
39
- title: string;
40
- url: string;
41
- snippet?: string;
42
- }
43
-
44
- /** Build the `filters` value for a recency window, mirroring Bing's dropdown URLs. */
45
- function recencyToFilters(recency: NonNullable<SearchParams["recency"]>): string {
46
- if (recency === "year") {
47
- const epochDay = Math.floor(Date.now() / MS_PER_DAY);
48
- return `ex1:"ez5_${epochDay - 365}_${epochDay}"`;
49
- }
50
- return `ex1:"${RECENCY_TO_BING_EZ[recency]}"`;
51
- }
52
-
53
- /**
54
- * Resolve a Bing result href to the underlying target URL.
55
- *
56
- * Organic hrefs are usually wrapped as `https://www.bing.com/ck/a?…&u=a1<payload>`
57
- * where the payload after the literal `a1` prefix is the unpadded base64url
58
- * encoding of the target URL. Direct external hrefs also occur; Bing-internal
59
- * links (vertical tabs, ads plumbing) and non-http(s) schemes are rejected.
60
- */
61
- function unwrapResultUrl(href: string): string | undefined {
62
- let url: URL;
63
- try {
64
- url = new URL(href, BING_HOME_URL);
65
- } catch {
66
- return undefined;
67
- }
68
-
69
- if (url.hostname === "bing.com" || url.hostname.endsWith(".bing.com")) {
70
- if (url.pathname !== "/ck/a") return undefined;
71
- const wrapped = url.searchParams.get("u");
72
- if (!wrapped?.startsWith("a1")) return undefined;
73
- try {
74
- url = new URL(Buffer.from(wrapped.slice(2), "base64url").toString("utf-8"));
75
- } catch {
76
- return undefined;
77
- }
78
- }
79
-
80
- if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
81
- return url.href;
82
- }
83
-
84
- function findSnippet(item: Element): string | undefined {
85
- for (const selector of BING_SNIPPET_SELECTORS) {
86
- const text = (item.querySelector(selector)?.textContent ?? "").replace(/\s+/g, " ").trim();
87
- if (text) return text;
88
- }
89
- return undefined;
90
- }
91
-
92
- /**
93
- * Pull organic result blocks out of the page in document order.
94
- *
95
- * Each organic hit is an `<li class="b_algo">` with the title link in
96
- * `h2 > a[href]` (sitelink/attribution anchors live outside the `h2`) and the
97
- * preview text in one of {@link BING_SNIPPET_SELECTORS}. Ads, answer cards,
98
- * and the "no results" row use other classes and fall out naturally.
99
- */
100
- function parseHtmlResults(html: string): ParsedResult[] {
101
- const { document } = parseHTML(html);
102
- const results: ParsedResult[] = [];
103
- for (const item of document.querySelectorAll("li.b_algo")) {
104
- const anchor = item.querySelector("h2 a[href]");
105
- const href = anchor?.getAttribute("href");
106
- if (!href) continue;
107
- const url = unwrapResultUrl(href);
108
- if (!url) continue;
109
- const title = (anchor?.textContent ?? "").replace(/\s+/g, " ").trim();
110
- if (!title) continue;
111
- results.push({ title, url, snippet: findSnippet(item) });
112
- }
113
- return results;
114
- }
115
-
116
- /**
117
- * `true` when Bing answered with its CAPTCHA/consent interstitial instead of
118
- * a results page. The challenge redirects to `/turing/captcha/…`; body
119
- * markers are only trusted when no organic result block is present so a
120
- * search *about* CAPTCHAs never trips the detector.
121
- */
122
- function isChallengeResponse(html: string, finalUrl: string): boolean {
123
- if (finalUrl.includes("/turing/captcha")) return true;
124
- if (html.includes('class="b_algo"')) return false;
125
- return /turing\/captcha|b_captcha|px-captcha|verify (?:that )?you are (?:a )?human/i.test(html);
126
- }
127
-
128
- function buildSearchUrl(params: SearchParams, numResults: number): string {
129
- const url = new URL(BING_SEARCH_URL);
130
- url.searchParams.set("q", params.query);
131
- url.searchParams.set("count", String(numResults));
132
- url.searchParams.set("mkt", "en-US");
133
- url.searchParams.set("setlang", "en");
134
- if (params.recency) url.searchParams.set("filters", recencyToFilters(params.recency));
135
- return url.href;
136
- }
137
-
138
- async function callBingHtml(params: SearchParams, numResults: number): Promise<string> {
139
- const url = buildSearchUrl(params, numResults);
140
- const page = await browserFetch(url, {
141
- fetch: params.fetch ?? fetch,
142
- signal: withHardTimeout(params.signal),
143
- referer: BING_HOME_URL,
144
- });
145
-
146
- const body = page.html;
147
- if (isChallengeResponse(body, page.url)) {
148
- throw new SearchProviderError(
149
- "bing",
150
- "Bing blocked the request with a CAPTCHA challenge. Bing throttles automated searches from datacenter/shared-egress IPs; try the duckduckgo or mojeek provider, or configure a credentialed provider such as Brave, Tavily, Exa, or Kagi.",
151
- 429,
152
- );
153
- }
154
- if (page.status < 200 || page.status >= 300) {
155
- const classified = classifyProviderHttpError("bing", page.status, body);
156
- if (classified) throw classified;
157
- throw new SearchProviderError("bing", `Bing HTML error (${page.status})`, page.status);
158
- }
159
-
160
- return body;
161
- }
162
-
163
- /** Execute a Bing web search via the server-rendered HTML results page. */
164
- export async function searchBing(params: SearchParams): Promise<SearchResponse> {
165
- const numResults = clampNumResults(params.numSearchResults ?? params.limit, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS);
166
- const html = await callBingHtml(params, numResults);
167
- const parsed = parseHtmlResults(html);
168
-
169
- const sources: SearchSource[] = [];
170
- const seen = new Set<string>();
171
- for (const result of parsed) {
172
- if (seen.has(result.url)) continue;
173
- seen.add(result.url);
174
- sources.push({ title: result.title, url: result.url, snippet: result.snippet });
175
- if (sources.length >= numResults) break;
176
- }
177
-
178
- return { provider: "bing", sources };
179
- }
180
-
181
- /** Search provider for Bing (no API key required). */
182
- export class BingProvider extends SearchProvider {
183
- readonly id = "bing";
184
- readonly label = "Bing";
185
-
186
- isAvailable(_authStorage: AuthStorage): boolean {
187
- return true;
188
- }
189
-
190
- isExplicitlyAvailable(_authStorage: AuthStorage): boolean {
191
- return true;
192
- }
193
-
194
- search(params: SearchParams): Promise<SearchResponse> {
195
- return searchBing(params);
196
- }
197
- }
@@ -1,179 +0,0 @@
1
- import type { AuthStorage } from "@oh-my-pi/pi-ai";
2
- import { parseHTML } from "linkedom";
3
- import type { SearchResponse, SearchSource } from "../../../web/search/types";
4
- import { SearchProviderError } from "../../../web/search/types";
5
- import { clampNumResults } from "../utils";
6
- import type { SearchParams } from "./base";
7
- import { SearchProvider } from "./base";
8
- import { browserFetch } from "./browser-page";
9
- import { classifyProviderHttpError, withHardTimeout } from "./utils";
10
-
11
- /**
12
- * Yahoo Search's server-rendered results page. A plain GET with browser
13
- * navigation headers returns the full SERP without any JavaScript challenge,
14
- * so no headless-browser fallback is needed (verified live 2026-07).
15
- */
16
- const YAHOO_HOME_URL = "https://search.yahoo.com/";
17
- const YAHOO_SEARCH_URL = "https://search.yahoo.com/search";
18
- const DEFAULT_NUM_RESULTS = 10;
19
- const MAX_NUM_RESULTS = 20;
20
-
21
- /**
22
- * Recency → Yahoo `btf` query param. Yahoo's time filter only offers
23
- * day/week/month; `year` has no equivalent and is silently dropped per the
24
- * {@link SearchParams.recency} contract.
25
- */
26
- const RECENCY_TO_YAHOO_BTF: Partial<Record<NonNullable<SearchParams["recency"]>, string>> = {
27
- day: "d",
28
- week: "w",
29
- month: "m",
30
- };
31
-
32
- interface ParsedResult {
33
- title: string;
34
- url: string;
35
- snippet?: string;
36
- }
37
-
38
- /**
39
- * Resolve a Yahoo result href back to the underlying target URL.
40
- *
41
- * Organic hrefs are wrapped through the click tracker
42
- * `https://r.search.yahoo.com/_ylt=…/RU=<percent-encoded-target>/RK=…/RS=…`;
43
- * the `/RU=` path segment carries the destination. Older layouts emit plain
44
- * absolute hrefs, so both shapes are handled. Tracker links without a
45
- * recoverable target and Yahoo-internal navigation are rejected.
46
- */
47
- function unwrapResultUrl(href: string): string | undefined {
48
- let url: URL;
49
- try {
50
- url = new URL(href, YAHOO_HOME_URL);
51
- } catch {
52
- return undefined;
53
- }
54
- if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
55
-
56
- const wrapped = /\/RU=([^/]+)/.exec(url.pathname);
57
- if (wrapped) {
58
- let target: string;
59
- try {
60
- target = decodeURIComponent(wrapped[1]);
61
- } catch {
62
- return undefined;
63
- }
64
- return target.startsWith("http://") || target.startsWith("https://") ? target : undefined;
65
- }
66
- // A tracker link without an RU segment has no recoverable destination.
67
- if (url.hostname === "r.search.yahoo.com") return undefined;
68
- // Relative hrefs resolve against the search host: internal navigation.
69
- if (url.hostname === "search.yahoo.com") return undefined;
70
- return url.href;
71
- }
72
-
73
- /**
74
- * Walk the SERP and pull organic result blocks in document order.
75
- *
76
- * Organics render as `<div class="… algo …">` blocks (inside `#web`'s
77
- * `<ol>`): the title `<h3>` sits inside the tracker `<a>` in the current
78
- * layout, while legacy layouts nested the `<a>` inside `<h3 class="title">`
79
- * — both are handled. The preview text lives in a sibling
80
- * `<div class="compText">`. Module headers ("Videos", "People also ask")
81
- * carry `<h3>`s outside `.algo` blocks and are excluded by construction.
82
- */
83
- function parseHtmlResults(html: string): ParsedResult[] {
84
- const { document } = parseHTML(html);
85
- const results: ParsedResult[] = [];
86
- for (const block of document.querySelectorAll("div.algo")) {
87
- const heading = block.querySelector("h3");
88
- if (!heading) continue;
89
- const anchor = heading.querySelector("a") ?? heading.closest("a");
90
- const href = anchor?.getAttribute("href");
91
- if (!href) continue;
92
- const url = unwrapResultUrl(href);
93
- if (!url) continue;
94
- const title = (heading.textContent ?? "").replace(/\s+/g, " ").trim();
95
- if (!title) continue;
96
- const snippet = (block.querySelector(".compText")?.textContent ?? "").replace(/\s+/g, " ").trim() || undefined;
97
- results.push({ title, url, snippet });
98
- }
99
- return results;
100
- }
101
-
102
- /**
103
- * `true` when Yahoo answered with its EU consent interstitial instead of
104
- * results: either the request was redirected to consent.yahoo.com /
105
- * guce.yahoo.com, or the body carries the consent form. The normal SERP
106
- * mentions guce.yahoo.com only in a meta tag, so detection keys on the
107
- * consent-host redirect and the `collectConsent` form action.
108
- */
109
- function isConsentInterstitial(finalUrl: string, html: string): boolean {
110
- if (/^https?:\/\/(?:[^/]*\.)?(?:consent|guce)\.yahoo\.com\//i.test(finalUrl)) return true;
111
- return html.includes("consent.yahoo.com") || html.includes("collectConsent");
112
- }
113
-
114
- async function callYahooHtml(params: SearchParams, numResults: number): Promise<string> {
115
- const url = new URL(YAHOO_SEARCH_URL);
116
- url.searchParams.set("p", params.query);
117
- url.searchParams.set("n", String(numResults));
118
- const btf = params.recency ? RECENCY_TO_YAHOO_BTF[params.recency] : undefined;
119
- if (btf) url.searchParams.set("btf", btf);
120
-
121
- const page = await browserFetch(url.href, {
122
- fetch: params.fetch ?? fetch,
123
- signal: withHardTimeout(params.signal),
124
- referer: YAHOO_HOME_URL,
125
- });
126
-
127
- const body = page.html;
128
- if (page.status < 200 || page.status >= 300) {
129
- const classified = classifyProviderHttpError("yahoo", page.status, body);
130
- if (classified) throw classified;
131
- throw new SearchProviderError("yahoo", `Yahoo HTML error (${page.status})`, page.status);
132
- }
133
-
134
- if (isConsentInterstitial(page.url, body)) {
135
- throw new SearchProviderError(
136
- "yahoo",
137
- "Yahoo served its GDPR consent interstitial instead of search results. This typically affects EU egress IPs; use another web search provider such as DuckDuckGo, Brave, or Mojeek.",
138
- 429,
139
- );
140
- }
141
-
142
- return body;
143
- }
144
-
145
- /** Execute a Yahoo web search via the server-rendered HTML results page. */
146
- export async function searchYahoo(params: SearchParams): Promise<SearchResponse> {
147
- const numResults = clampNumResults(params.numSearchResults ?? params.limit, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS);
148
- const html = await callYahooHtml(params, numResults);
149
- const parsed = parseHtmlResults(html);
150
-
151
- const sources: SearchSource[] = [];
152
- const seen = new Set<string>();
153
- for (const result of parsed) {
154
- if (seen.has(result.url)) continue;
155
- seen.add(result.url);
156
- sources.push({ title: result.title, url: result.url, snippet: result.snippet });
157
- if (sources.length >= numResults) break;
158
- }
159
-
160
- return { provider: "yahoo", sources };
161
- }
162
-
163
- /** Search provider for Yahoo (no API key required). */
164
- export class YahooProvider extends SearchProvider {
165
- readonly id = "yahoo";
166
- readonly label = "Yahoo";
167
-
168
- isAvailable(_authStorage: AuthStorage): boolean {
169
- return true;
170
- }
171
-
172
- isExplicitlyAvailable(_authStorage: AuthStorage): boolean {
173
- return true;
174
- }
175
-
176
- search(params: SearchParams): Promise<SearchResponse> {
177
- return searchYahoo(params);
178
- }
179
- }