@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.
- package/CHANGELOG.md +63 -0
- package/dist/cli.js +3502 -3444
- package/dist/types/cli/args.d.ts +5 -0
- package/dist/types/cli/gallery-fixtures/shell.d.ts +1 -1
- package/dist/types/commands/launch.d.ts +15 -0
- package/dist/types/config/model-resolver.d.ts +4 -3
- package/dist/types/config/model-roles.d.ts +8 -0
- package/dist/types/config/settings-schema.d.ts +46 -14
- package/dist/types/extensibility/extensions/types.d.ts +1 -1
- package/dist/types/launch/broker.d.ts +2 -0
- package/dist/types/launch/client.d.ts +22 -0
- package/dist/types/launch/paths.d.ts +4 -0
- package/dist/types/launch/presence.d.ts +8 -0
- package/dist/types/launch/protocol.d.ts +170 -0
- package/dist/types/launch/terminal-output.d.ts +7 -0
- package/dist/types/modes/components/agent-hub.d.ts +1 -1
- package/dist/types/modes/components/index.d.ts +1 -0
- package/dist/types/modes/components/model-browser.d.ts +17 -2
- package/dist/types/modes/components/model-hub.d.ts +0 -10
- package/dist/types/modes/components/model-picker.d.ts +44 -0
- package/dist/types/modes/components/status-line/types.d.ts +3 -0
- package/dist/types/modes/print-mode.d.ts +14 -8
- package/dist/types/modes/print-mode.test.d.ts +1 -0
- package/dist/types/modes/theme/theme.d.ts +2 -1
- package/dist/types/sdk.d.ts +5 -1
- package/dist/types/session/agent-session.d.ts +42 -0
- package/dist/types/session/session-context.d.ts +9 -0
- package/dist/types/session/session-entries.d.ts +6 -0
- package/dist/types/thinking.d.ts +2 -2
- package/dist/types/tiny/models.d.ts +1 -1
- package/dist/types/tools/browser/launch.d.ts +1 -0
- package/dist/types/tools/browser/run-cancellation.d.ts +28 -2
- package/dist/types/tools/browser/tab-protocol.d.ts +6 -0
- package/dist/types/tools/browser/tab-worker.d.ts +6 -0
- package/dist/types/tools/builtin-names.d.ts +1 -1
- package/dist/types/tools/index.d.ts +1 -0
- package/dist/types/tools/launch.d.ts +121 -0
- package/dist/types/tools/render-utils.d.ts +2 -0
- package/dist/types/tools/terminal-output.d.ts +5 -0
- package/dist/types/vibe/runtime.d.ts +2 -2
- package/dist/types/web/search/types.d.ts +0 -8
- package/package.json +20 -20
- package/src/cli/args.ts +11 -0
- package/src/cli/flag-tables.ts +9 -0
- package/src/cli/gallery-fixtures/shell.ts +82 -1
- package/src/cli.ts +10 -0
- package/src/commands/launch.ts +17 -0
- package/src/config/model-resolver.ts +110 -31
- package/src/config/model-roles.ts +14 -0
- package/src/config/settings-schema.ts +71 -7
- package/src/edit/renderer.ts +13 -10
- package/src/eval/__tests__/agent-bridge.test.ts +2 -2
- package/src/eval/__tests__/completion-bridge.test.ts +1 -1
- package/src/eval/completion-bridge.ts +4 -4
- package/src/export/ttsr.ts +0 -3
- package/src/extensibility/extensions/model-api.ts +1 -1
- package/src/extensibility/extensions/types.ts +1 -1
- package/src/internal-urls/docs-index.ts +4 -4
- package/src/launch/broker.ts +1017 -0
- package/src/launch/client.ts +344 -0
- package/src/launch/paths.ts +17 -0
- package/src/launch/presence.ts +82 -0
- package/src/launch/protocol.ts +386 -0
- package/src/launch/terminal-output.ts +46 -0
- package/src/main.ts +50 -1
- package/src/modes/acp/acp-agent.ts +8 -1
- package/src/modes/components/agent-hub.ts +101 -31
- package/src/modes/components/compaction-summary-message.ts +8 -2
- package/src/modes/components/index.ts +1 -0
- package/src/modes/components/model-browser.ts +111 -21
- package/src/modes/components/model-hub.ts +23 -116
- package/src/modes/components/model-picker.ts +233 -0
- package/src/modes/components/snapcompact-shape-preview-doc.md +7 -11
- package/src/modes/components/status-line/component.test.ts +41 -2
- package/src/modes/components/status-line/component.ts +4 -0
- package/src/modes/components/status-line/segments.ts +6 -0
- package/src/modes/components/status-line/types.ts +3 -0
- package/src/modes/controllers/command-controller.ts +9 -1
- package/src/modes/controllers/event-controller.ts +31 -32
- package/src/modes/controllers/selector-controller.ts +96 -22
- package/src/modes/controllers/tan-command-controller.ts +40 -1
- package/src/modes/interactive-mode.ts +7 -3
- package/src/modes/print-mode.test.ts +71 -0
- package/src/modes/print-mode.ts +51 -2
- package/src/modes/theme/theme.ts +9 -0
- package/src/modes/utils/ui-helpers.ts +25 -4
- package/src/prompts/agents/designer.md +1 -1
- package/src/prompts/agents/librarian.md +1 -1
- package/src/prompts/agents/reviewer.md +1 -1
- package/src/prompts/agents/scout.md +1 -1
- package/src/prompts/system/plan-yolo-handoff.md +5 -0
- package/src/prompts/system/prewalk-checklist.md +7 -0
- package/src/prompts/system/prewalk-continue.md +1 -0
- package/src/prompts/system/prewalk-plan.md +13 -0
- package/src/prompts/system/system-prompt.md +9 -7
- package/src/prompts/system/tan-context-switch.md +17 -0
- package/src/prompts/tools/bash.md +6 -4
- package/src/prompts/tools/browser.md +4 -4
- package/src/prompts/tools/launch.md +25 -0
- package/src/sdk.ts +8 -2
- package/src/session/agent-session.ts +550 -87
- package/src/session/session-context.test.ts +10 -5
- package/src/session/session-context.ts +25 -8
- package/src/session/session-entries.ts +6 -0
- package/src/slash-commands/builtin-registry.ts +25 -0
- package/src/task/agents.ts +2 -2
- package/src/thinking.ts +10 -3
- package/src/tiny/models.ts +7 -7
- package/src/tools/bash-interactive.ts +5 -8
- package/src/tools/bash.ts +38 -24
- package/src/tools/browser/cmux/cmux-tab.ts +17 -2
- package/src/tools/browser/launch.ts +8 -4
- package/src/tools/browser/run-cancellation.ts +66 -6
- package/src/tools/browser/tab-protocol.ts +6 -0
- package/src/tools/browser/tab-supervisor.ts +17 -1
- package/src/tools/browser/tab-worker.ts +140 -21
- package/src/tools/builtin-names.ts +1 -0
- package/src/tools/eval-render.ts +16 -14
- package/src/tools/index.ts +5 -0
- package/src/tools/inspect-image.ts +2 -2
- package/src/tools/launch.ts +643 -0
- package/src/tools/render-utils.ts +3 -0
- package/src/tools/renderers.ts +2 -0
- package/src/tools/terminal-output.ts +141 -0
- package/src/tts/speech-enhancer.ts +2 -2
- package/src/utils/image-vision-fallback.ts +3 -3
- package/src/vibe/runtime.ts +2 -2
- package/src/web/search/provider.ts +0 -10
- package/src/web/search/providers/public.ts +2 -4
- package/src/web/search/types.ts +0 -10
- package/dist/types/web/search/providers/bing.d.ts +0 -14
- package/dist/types/web/search/providers/yahoo.d.ts +0 -14
- package/src/web/search/providers/bing.ts +0 -197
- 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
|
-
//
|
|
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("
|
|
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
|
-
* (
|
|
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("
|
|
115
|
-
resolvePattern("
|
|
114
|
+
resolvePattern("@vision") ??
|
|
115
|
+
resolvePattern("@default") ??
|
|
116
116
|
resolvePattern(deps.activeModelString) ??
|
|
117
117
|
available.find(model => model.input.includes("image"))
|
|
118
118
|
);
|
package/src/vibe/runtime.ts
CHANGED
|
@@ -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: "
|
|
43
|
-
* carries `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,
|
|
15
|
-
*
|
|
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[];
|
package/src/web/search/types.ts
CHANGED
|
@@ -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
|
-
}
|