@oh-my-pi/pi-coding-agent 16.4.2 → 16.4.3

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 (122) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/cli.js +17514 -17799
  3. package/dist/types/advisor/config.d.ts +4 -3
  4. package/dist/types/commit/agentic/agent.d.ts +1 -0
  5. package/dist/types/config/settings-schema.d.ts +28 -0
  6. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +10 -22
  7. package/dist/types/lsp/deferred-diagnostics.d.ts +11 -0
  8. package/dist/types/modes/components/move-overlay.d.ts +1 -1
  9. package/dist/types/modes/components/status-line/component.d.ts +3 -0
  10. package/dist/types/modes/components/status-line/types.d.ts +3 -0
  11. package/dist/types/modes/interactive-mode.d.ts +8 -0
  12. package/dist/types/modes/types.d.ts +2 -0
  13. package/dist/types/session/agent-session.d.ts +16 -0
  14. package/dist/types/task/executor.d.ts +31 -0
  15. package/dist/types/tools/__tests__/vibe-render.test.d.ts +1 -0
  16. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +3 -1
  17. package/dist/types/tools/browser/cmux/rpc.d.ts +20 -0
  18. package/dist/types/tools/browser/run-output.d.ts +25 -0
  19. package/dist/types/tools/browser/tab-worker.d.ts +16 -0
  20. package/dist/types/tools/index.d.ts +1 -0
  21. package/dist/types/tools/vibe.d.ts +161 -0
  22. package/dist/types/utils/changelog.d.ts +35 -1
  23. package/dist/types/vibe/runtime.d.ts +124 -0
  24. package/dist/types/vibe/state.d.ts +4 -0
  25. package/dist/types/web/search/provider.d.ts +2 -0
  26. package/dist/types/web/search/providers/bing.d.ts +14 -0
  27. package/dist/types/web/search/providers/browser-headers.d.ts +9 -0
  28. package/dist/types/web/search/providers/browser-page.d.ts +32 -0
  29. package/dist/types/web/search/providers/ecosia.d.ts +14 -0
  30. package/dist/types/web/search/providers/google.d.ts +13 -0
  31. package/dist/types/web/search/providers/mojeek.d.ts +14 -0
  32. package/dist/types/web/search/providers/public.d.ts +37 -0
  33. package/dist/types/web/search/providers/startpage.d.ts +14 -0
  34. package/dist/types/web/search/providers/yahoo.d.ts +14 -0
  35. package/dist/types/web/search/types.d.ts +28 -0
  36. package/package.json +15 -18
  37. package/scripts/build-binary.ts +56 -73
  38. package/scripts/bundle-dist.ts +36 -40
  39. package/scripts/compile-binary.ts +68 -0
  40. package/scripts/generate-docs-index.ts +3 -93
  41. package/scripts/legacy-pi-virtual-module.ts +192 -0
  42. package/src/advisor/__tests__/advisor.test.ts +13 -0
  43. package/src/advisor/__tests__/config.test.ts +36 -0
  44. package/src/advisor/config.ts +11 -8
  45. package/src/commit/agentic/agent.ts +4 -0
  46. package/src/commit/agentic/index.ts +46 -21
  47. package/src/edit/index.ts +10 -76
  48. package/src/exec/non-interactive-env.ts +0 -1
  49. package/src/extensibility/plugins/legacy-pi-compat.ts +328 -162
  50. package/src/extensibility/plugins/legacy-pi-virtual-modules.d.ts +4 -0
  51. package/src/internal-urls/docs-index.ts +2 -1
  52. package/src/internal-urls/skill-protocol.ts +1 -1
  53. package/src/lsp/deferred-diagnostics.ts +66 -0
  54. package/src/main.ts +13 -13
  55. package/src/mcp/transports/stdio.test.ts +45 -1
  56. package/src/mcp/transports/stdio.ts +6 -3
  57. package/src/modes/acp/acp-agent.ts +65 -1
  58. package/src/modes/acp/acp-event-mapper.ts +5 -0
  59. package/src/modes/acp/acp-mode.ts +11 -0
  60. package/src/modes/components/__tests__/move-overlay.test.ts +16 -1
  61. package/src/modes/components/advisor-config.ts +15 -7
  62. package/src/modes/components/move-overlay.ts +2 -3
  63. package/src/modes/components/status-line/component.ts +6 -0
  64. package/src/modes/components/status-line/segments.ts +6 -0
  65. package/src/modes/components/status-line/types.ts +3 -0
  66. package/src/modes/controllers/command-controller.ts +8 -10
  67. package/src/modes/interactive-mode.ts +117 -1
  68. package/src/modes/types.ts +2 -0
  69. package/src/prompts/system/eager-task.md +2 -2
  70. package/src/prompts/system/system-prompt.md +9 -2
  71. package/src/prompts/system/vibe-mode-active.md +23 -0
  72. package/src/prompts/tools/browser.md +3 -3
  73. package/src/prompts/tools/grep.md +1 -1
  74. package/src/prompts/tools/vibe-kill.md +3 -0
  75. package/src/prompts/tools/vibe-list.md +3 -0
  76. package/src/prompts/tools/vibe-send.md +9 -0
  77. package/src/prompts/tools/vibe-spawn.md +10 -0
  78. package/src/prompts/tools/vibe-turn-result.md +19 -0
  79. package/src/prompts/tools/vibe-wait.md +8 -0
  80. package/src/sdk.ts +5 -0
  81. package/src/session/agent-session.ts +103 -13
  82. package/src/session/snapcompact-inline.ts +3 -19
  83. package/src/slash-commands/builtin-registry.ts +24 -8
  84. package/src/task/agents.ts +0 -2
  85. package/src/task/executor.ts +105 -0
  86. package/src/tools/__tests__/vibe-render.test.ts +210 -0
  87. package/src/tools/bash-skill-urls.ts +1 -1
  88. package/src/tools/browser/cmux/cmux-tab.ts +46 -48
  89. package/src/tools/browser/cmux/rpc.ts +50 -0
  90. package/src/tools/browser/run-output.ts +76 -0
  91. package/src/tools/browser/tab-worker.ts +264 -129
  92. package/src/tools/glob.ts +20 -6
  93. package/src/tools/index.ts +1 -0
  94. package/src/tools/read.ts +17 -9
  95. package/src/tools/renderers.ts +6 -0
  96. package/src/tools/vibe.ts +608 -0
  97. package/src/tools/write.ts +15 -2
  98. package/src/utils/changelog.ts +106 -9
  99. package/src/utils/git.ts +0 -1
  100. package/src/utils/title-generator.ts +70 -7
  101. package/src/vibe/runtime.ts +710 -0
  102. package/src/vibe/state.ts +4 -0
  103. package/src/web/search/index.ts +14 -6
  104. package/src/web/search/provider.ts +37 -1
  105. package/src/web/search/providers/bing.ts +197 -0
  106. package/src/web/search/providers/browser-headers.ts +92 -0
  107. package/src/web/search/providers/browser-page.ts +123 -0
  108. package/src/web/search/providers/duckduckgo.ts +13 -34
  109. package/src/web/search/providers/ecosia.ts +178 -0
  110. package/src/web/search/providers/google.ts +193 -0
  111. package/src/web/search/providers/mojeek.ts +206 -0
  112. package/src/web/search/providers/public.ts +201 -0
  113. package/src/web/search/providers/startpage.ts +213 -0
  114. package/src/web/search/providers/yahoo.ts +179 -0
  115. package/src/web/search/types.ts +35 -0
  116. package/dist/types/extensibility/plugins/legacy-pi-bundled-keys.d.ts +0 -10
  117. package/dist/types/extensibility/plugins/legacy-pi-bundled-registry.d.ts +0 -10
  118. package/scripts/generate-legacy-pi-bundled-registry.ts +0 -420
  119. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +0 -1011
  120. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +0 -3430
  121. package/src/internal-urls/docs-index.generated.txt +0 -2
  122. package/src/prompts/agents/plan.md +0 -47
@@ -0,0 +1,178 @@
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 type { LoadedHtmlPage } from "./browser-page";
9
+ import { browserFetch } from "./browser-page";
10
+ import { classifyProviderHttpError, withHardTimeout } from "./utils";
11
+
12
+ /**
13
+ * Ecosia serves a server-rendered Vue/Nuxt results page (no `__NUXT_DATA__`
14
+ * JSON island — probed 2026-07), so both load paths parse the same markup:
15
+ * `<article data-test-id="organic-result">` blocks whose title anchor carries
16
+ * the final target URL directly (no redirect wrapper). The site fronts search
17
+ * with Cloudflare. Requests start with a browser-profiled fetch and escalate
18
+ * to the shared stealth browser only when the response is blocked or fails.
19
+ *
20
+ * Recency is ignored: Ecosia's web results expose no date filter in the UI
21
+ * and the legacy Bing-era `freshness` param is a server-side no-op (verified
22
+ * live), so per the {@link SearchParams.recency} contract the field must not
23
+ * be approximated.
24
+ */
25
+ const ECOSIA_HOME_URL = "https://www.ecosia.org/";
26
+ const ECOSIA_SEARCH_URL = "https://www.ecosia.org/search";
27
+ const DEFAULT_NUM_RESULTS = 10;
28
+ const MAX_NUM_RESULTS = 20;
29
+ const RESULT_RENDER_TIMEOUT_MS = 10_000;
30
+
31
+ interface ParsedResult {
32
+ title: string;
33
+ url: string;
34
+ snippet?: string;
35
+ }
36
+
37
+ /**
38
+ * Validate a result anchor href. Organic anchors carry the target URL
39
+ * directly; anything non-http(s) or pointing back at ecosia.org (internal
40
+ * navigation such as the images/news verticals) is rejected.
41
+ */
42
+ function resolveResultUrl(href: string): string | undefined {
43
+ let url: URL;
44
+ try {
45
+ url = new URL(href, ECOSIA_HOME_URL);
46
+ } catch {
47
+ return undefined;
48
+ }
49
+ if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
50
+ if (url.hostname === "ecosia.org" || url.hostname === "www.ecosia.org") return undefined;
51
+ return url.href;
52
+ }
53
+
54
+ /**
55
+ * Pull organic results out of the page in document order. Each result is an
56
+ * `<article data-test-id="organic-result">` with the title inside
57
+ * `<h2 data-test-id="result-title">` wrapped by the target link, and the
58
+ * preview text inside `<p data-test-id="web-result-description">`. The inner
59
+ * paragraph is preferred over its `result-description` container because the
60
+ * container also holds screen-reader-only thumbnail captions on video rows.
61
+ * Ad slots (`data-test-id="ad-google"`) and entity/infobox cards use
62
+ * different test-ids and never match.
63
+ */
64
+ function parseHtmlResults(html: string): ParsedResult[] {
65
+ const { document } = parseHTML(html);
66
+ const results: ParsedResult[] = [];
67
+ for (const article of document.querySelectorAll('article[data-test-id="organic-result"]')) {
68
+ const heading = article.querySelector('[data-test-id="result-title"]');
69
+ const href = heading?.closest("a")?.getAttribute("href");
70
+ if (!heading || !href) continue;
71
+ const url = resolveResultUrl(href);
72
+ if (!url) continue;
73
+ const title = (heading.textContent ?? "").replace(/\s+/g, " ").trim();
74
+ if (!title) continue;
75
+ const description =
76
+ article.querySelector('[data-test-id="web-result-description"]') ??
77
+ article.querySelector('[data-test-id="result-description"]');
78
+ const snippet = (description?.textContent ?? "").replace(/\s+/g, " ").trim();
79
+ results.push({ title, url, snippet: snippet || undefined });
80
+ }
81
+ return results;
82
+ }
83
+
84
+ /**
85
+ * `true` when Ecosia's Cloudflare front answered with the managed challenge
86
+ * instead of results. The observed page is a 403 titled "Ecosia Firewall"
87
+ * carrying the `_cf_chl_opt` bootstrap and the challenge-platform loader.
88
+ */
89
+ function isBlockedPage(page: LoadedHtmlPage): boolean {
90
+ return (
91
+ page.status === 403 ||
92
+ page.status === 429 ||
93
+ page.html.includes("Ecosia Firewall") ||
94
+ page.html.includes("_cf_chl_opt") ||
95
+ page.html.includes("/cdn-cgi/challenge-platform/") ||
96
+ /confirm you.{0,3}re not a robot/i.test(page.html)
97
+ );
98
+ }
99
+
100
+ async function callEcosiaHtml(params: SearchParams): Promise<string> {
101
+ const signal = withHardTimeout(params.signal);
102
+ const url = new URL(ECOSIA_SEARCH_URL);
103
+ url.searchParams.set("q", params.query);
104
+
105
+ let page: LoadedHtmlPage;
106
+ try {
107
+ page = await browserFetch(url.href, {
108
+ fetch: params.fetch,
109
+ signal,
110
+ referer: ECOSIA_HOME_URL,
111
+ browser: {
112
+ homeUrl: ECOSIA_HOME_URL,
113
+ ready: {
114
+ selector: 'article[data-test-id="organic-result"]',
115
+ timeoutMs: RESULT_RENDER_TIMEOUT_MS,
116
+ },
117
+ shouldFallback: isBlockedPage,
118
+ },
119
+ });
120
+ } catch (error) {
121
+ if (error instanceof SearchProviderError || params.signal?.aborted) throw error;
122
+ if (signal.aborted) {
123
+ throw new SearchProviderError("ecosia", "Ecosia search timed out.", 504);
124
+ }
125
+ const message = error instanceof Error ? error.message : String(error);
126
+ throw new SearchProviderError("ecosia", `Ecosia search failed: ${message}`, 503);
127
+ }
128
+
129
+ if (isBlockedPage(page)) {
130
+ throw new SearchProviderError(
131
+ "ecosia",
132
+ "Ecosia blocked the request with a Cloudflare bot challenge. Ecosia's firewall throttles automated searches from datacenter/shared-egress IPs; try another web search provider such as DuckDuckGo, Brave, or Tavily.",
133
+ 429,
134
+ );
135
+ }
136
+ if (page.status < 200 || page.status >= 300) {
137
+ const classified = classifyProviderHttpError("ecosia", page.status, page.html);
138
+ if (classified) throw classified;
139
+ throw new SearchProviderError("ecosia", `Ecosia HTML error (${page.status})`, page.status);
140
+ }
141
+ return page.html;
142
+ }
143
+
144
+ /** Execute an Ecosia web search and parse the server-rendered result page. */
145
+ export async function searchEcosia(params: SearchParams): Promise<SearchResponse> {
146
+ const numResults = clampNumResults(params.numSearchResults ?? params.limit, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS);
147
+ const html = await callEcosiaHtml(params);
148
+ const parsed = parseHtmlResults(html);
149
+
150
+ const sources: SearchSource[] = [];
151
+ const seen = new Set<string>();
152
+ for (const result of parsed) {
153
+ if (seen.has(result.url)) continue;
154
+ seen.add(result.url);
155
+ sources.push({ title: result.title, url: result.url, snippet: result.snippet });
156
+ if (sources.length >= numResults) break;
157
+ }
158
+
159
+ return { provider: "ecosia", sources };
160
+ }
161
+
162
+ /** Search provider for Ecosia (no API key required). */
163
+ export class EcosiaProvider extends SearchProvider {
164
+ readonly id = "ecosia";
165
+ readonly label = "Ecosia";
166
+
167
+ isAvailable(_authStorage: AuthStorage): boolean {
168
+ return true;
169
+ }
170
+
171
+ isExplicitlyAvailable(_authStorage: AuthStorage): boolean {
172
+ return true;
173
+ }
174
+
175
+ search(params: SearchParams): Promise<SearchResponse> {
176
+ return searchEcosia(params);
177
+ }
178
+ }
@@ -0,0 +1,193 @@
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 type { LoadedHtmlPage } from "./browser-page";
9
+ import { browserFetch } from "./browser-page";
10
+ import { withHardTimeout } from "./utils";
11
+
12
+ const GOOGLE_HOME_URL = "https://www.google.com/";
13
+ const GOOGLE_SEARCH_URL = "https://www.google.com/search";
14
+ const DEFAULT_NUM_RESULTS = 10;
15
+ const MAX_NUM_RESULTS = 20;
16
+ const RESULT_RENDER_TIMEOUT_MS = 10_000;
17
+
18
+ const RECENCY_TO_GOOGLE_TBS: Record<NonNullable<SearchParams["recency"]>, string> = {
19
+ day: "qdr:d",
20
+ week: "qdr:w",
21
+ month: "qdr:m",
22
+ year: "qdr:y",
23
+ };
24
+ const GOOGLE_SNIPPET_SELECTORS: readonly string[] = [
25
+ "[data-sncf='1'] .VwiC3b",
26
+ ".VwiC3b",
27
+ ".IsZvec",
28
+ ".BNeawe.s3v9rd",
29
+ "[data-sncf='1']",
30
+ ];
31
+
32
+ interface ParsedResult {
33
+ title: string;
34
+ url: string;
35
+ snippet?: string;
36
+ }
37
+
38
+ function normalizeText(value: string | null | undefined): string {
39
+ return (value ?? "").replace(/\s+/g, " ").trim();
40
+ }
41
+
42
+ function unwrapResultUrl(href: string): string | undefined {
43
+ let url: URL;
44
+ try {
45
+ url = new URL(href, GOOGLE_HOME_URL);
46
+ } catch {
47
+ return undefined;
48
+ }
49
+
50
+ if ((url.hostname === "google.com" || url.hostname === "www.google.com") && url.pathname === "/url") {
51
+ const target = url.searchParams.get("q") || url.searchParams.get("url");
52
+ if (!target) return undefined;
53
+ try {
54
+ url = new URL(target);
55
+ } catch {
56
+ return undefined;
57
+ }
58
+ }
59
+
60
+ if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
61
+ if (url.hostname === "google.com" || url.hostname === "www.google.com") return undefined;
62
+ return url.href;
63
+ }
64
+
65
+ function findSnippet(heading: Element): string | undefined {
66
+ const container = heading.closest(".tF2Cxc, .MjjYud, .Gx5Zad") ?? heading.parentElement?.parentElement;
67
+ if (!container) return undefined;
68
+
69
+ for (const selector of GOOGLE_SNIPPET_SELECTORS) {
70
+ const text = normalizeText(container.querySelector(selector)?.textContent).replace(/\s*Read more$/i, "");
71
+ if (text) return text;
72
+ }
73
+ return undefined;
74
+ }
75
+
76
+ function parseHtmlResults(html: string): ParsedResult[] {
77
+ const { document } = parseHTML(html);
78
+ const results: ParsedResult[] = [];
79
+ for (const heading of document.querySelectorAll("h3")) {
80
+ const anchor = heading.closest("a");
81
+ const href = anchor?.getAttribute("href");
82
+ if (!href) continue;
83
+ const url = unwrapResultUrl(href);
84
+ if (!url) continue;
85
+ const title = normalizeText(heading.textContent);
86
+ if (!title) continue;
87
+ results.push({ title, url, snippet: findSnippet(heading) });
88
+ }
89
+ return results;
90
+ }
91
+
92
+ function buildSearchUrl(params: SearchParams, numResults: number): string {
93
+ const url = new URL(GOOGLE_SEARCH_URL);
94
+ url.searchParams.set("q", params.query);
95
+ url.searchParams.set("num", String(numResults));
96
+ url.searchParams.set("hl", "en");
97
+ url.searchParams.set("gl", "us");
98
+ url.searchParams.set("udm", "14");
99
+ url.searchParams.set("pws", "0");
100
+ const tbs = params.recency ? RECENCY_TO_GOOGLE_TBS[params.recency] : undefined;
101
+ if (tbs) url.searchParams.set("tbs", tbs);
102
+ return url.href;
103
+ }
104
+
105
+ function blockReason(page: LoadedHtmlPage): "javascript" | "traffic" | undefined {
106
+ if (page.html.includes("/httpservice/retry/enablejs") && !/<h3\b/i.test(page.html)) return "javascript";
107
+ if (
108
+ page.status === 403 ||
109
+ page.status === 429 ||
110
+ page.url.includes("/sorry/") ||
111
+ /unusual traffic|detected unusual traffic|g-recaptcha/i.test(page.html)
112
+ ) {
113
+ return "traffic";
114
+ }
115
+ return undefined;
116
+ }
117
+
118
+ async function callGoogleHtml(params: SearchParams, numResults: number): Promise<string> {
119
+ const signal = withHardTimeout(params.signal);
120
+ const url = buildSearchUrl(params, numResults);
121
+ let page: LoadedHtmlPage;
122
+ try {
123
+ page = await browserFetch(url, {
124
+ fetch: params.fetch,
125
+ signal,
126
+ referer: GOOGLE_HOME_URL,
127
+ browser: {
128
+ homeUrl: GOOGLE_HOME_URL,
129
+ ready: { selector: "a h3", timeoutMs: RESULT_RENDER_TIMEOUT_MS },
130
+ shouldFallback: candidate => blockReason(candidate) !== undefined,
131
+ },
132
+ });
133
+ } catch (error) {
134
+ if (error instanceof SearchProviderError || params.signal?.aborted) throw error;
135
+ if (signal.aborted) {
136
+ throw new SearchProviderError("google", "Google browser search timed out.", 504);
137
+ }
138
+ const message = error instanceof Error ? error.message : String(error);
139
+ throw new SearchProviderError("google", `Google browser search failed: ${message}`, 503);
140
+ }
141
+
142
+ const blocked = blockReason(page);
143
+ if (blocked === "traffic") {
144
+ throw new SearchProviderError(
145
+ "google",
146
+ "Google blocked the browser search with an automated-traffic challenge. Try another web search provider or retry later.",
147
+ 429,
148
+ );
149
+ }
150
+ if (page.status < 200 || page.status >= 300) {
151
+ throw new SearchProviderError("google", `Google HTML error (${page.status})`, page.status);
152
+ }
153
+ if (blocked === "javascript") {
154
+ throw new SearchProviderError(
155
+ "google",
156
+ "Google returned its JavaScript challenge instead of rendered search results.",
157
+ 429,
158
+ );
159
+ }
160
+ return page.html;
161
+ }
162
+
163
+ /** Execute a Google web search with fetch-first loading and a headless-browser fallback. */
164
+ export async function searchGoogle(params: SearchParams): Promise<SearchResponse> {
165
+ const numResults = clampNumResults(params.numSearchResults ?? params.limit, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS);
166
+ const html = await callGoogleHtml(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: "google", sources };
179
+ }
180
+
181
+ /** Fetch-first Google Search provider with a headless-browser fallback; no API key is required. */
182
+ export class GoogleProvider extends SearchProvider {
183
+ readonly id = "google";
184
+ readonly label = "Google";
185
+
186
+ isAvailable(_authStorage: AuthStorage): boolean {
187
+ return true;
188
+ }
189
+
190
+ search(params: SearchParams): Promise<SearchResponse> {
191
+ return searchGoogle(params);
192
+ }
193
+ }
@@ -0,0 +1,206 @@
1
+ import type { AuthStorage } from "@oh-my-pi/pi-ai";
2
+ import { untilAborted } from "@oh-my-pi/pi-utils";
3
+ import { parseHTML } from "linkedom";
4
+ import type { Page } from "puppeteer-core";
5
+ import type { SearchResponse, SearchSource } from "../../../web/search/types";
6
+ import { SearchProviderError } from "../../../web/search/types";
7
+ import { clampNumResults } from "../utils";
8
+ import type { SearchParams } from "./base";
9
+ import { SearchProvider } from "./base";
10
+ import type { LoadedHtmlPage } from "./browser-page";
11
+ import { browserFetch } from "./browser-page";
12
+ import { classifyProviderHttpError, withHardTimeout } from "./utils";
13
+
14
+ const MOJEEK_ORIGIN = "https://www.mojeek.de";
15
+ const MOJEEK_HOME_URL = `${MOJEEK_ORIGIN}/?arc=none&lang=en&lb=en&theme=dark`;
16
+ const MOJEEK_SEARCH_URL = `${MOJEEK_ORIGIN}/search`;
17
+ const DEFAULT_NUM_RESULTS = 10;
18
+ const MAX_NUM_RESULTS = 20;
19
+ /**
20
+ * ALTCHA can complete quickly, but its verified redirect is occasionally
21
+ * delayed by queueing on the challenge backend.
22
+ */
23
+ const CAPTCHA_SOLVE_TIMEOUT_MS = 45_000;
24
+
25
+ interface ParsedResult {
26
+ title: string;
27
+ url: string;
28
+ snippet?: string;
29
+ }
30
+
31
+ /**
32
+ * Validate a result href. Mojeek links results directly to the target site
33
+ * (no redirect wrapper), so this only filters out non-HTTP schemes and
34
+ * intra-Mojeek navigation rows (verticals, paging) that share the markup.
35
+ */
36
+ function normalizeResultUrl(href: string): string | undefined {
37
+ let url: URL;
38
+ try {
39
+ url = new URL(href, MOJEEK_HOME_URL);
40
+ } catch {
41
+ return undefined;
42
+ }
43
+ if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
44
+ if (
45
+ url.hostname === "mojeek.com" ||
46
+ url.hostname.endsWith(".mojeek.com") ||
47
+ url.hostname === "mojeek.co.uk" ||
48
+ url.hostname.endsWith(".mojeek.co.uk") ||
49
+ url.hostname === "mojeek.fr" ||
50
+ url.hostname.endsWith(".mojeek.fr") ||
51
+ url.hostname === "mojeek.de" ||
52
+ url.hostname.endsWith(".mojeek.de")
53
+ ) {
54
+ return undefined;
55
+ }
56
+ return url.href;
57
+ }
58
+
59
+ /**
60
+ * Pull result blocks out of a Mojeek results page in document order.
61
+ *
62
+ * Each organic result renders as `ul.results-standard > li` with the title in
63
+ * `h2 > a.title` (href is the direct target URL) and the preview text in
64
+ * `p.s`. Clustered sub-results (`li.clu-result`) share the same shape; rows
65
+ * without a title anchor (infoboxes, spelling suggestions) are skipped.
66
+ */
67
+ function parseHtmlResults(html: string): ParsedResult[] {
68
+ const { document } = parseHTML(html);
69
+ const results: ParsedResult[] = [];
70
+ for (const item of document.querySelectorAll("ul.results-standard > li")) {
71
+ const anchor = item.querySelector("h2 a.title") ?? item.querySelector("a.title");
72
+ const href = anchor?.getAttribute("href");
73
+ if (!href) continue;
74
+ const url = normalizeResultUrl(href);
75
+ if (!url) continue;
76
+ const title = (anchor?.textContent ?? "").replace(/\s+/g, " ").trim();
77
+ if (!title) continue;
78
+ const snippet = (item.querySelector("p.s")?.textContent ?? "").replace(/\s+/g, " ").trim();
79
+ results.push({ title, url, snippet: snippet || undefined });
80
+ }
81
+ return results;
82
+ }
83
+
84
+ function buildSearchUrl(params: SearchParams, numResults: number): string {
85
+ const url = new URL(MOJEEK_SEARCH_URL);
86
+ url.searchParams.set("q", params.query);
87
+ url.searchParams.set("t", String(numResults));
88
+ url.searchParams.set("arc", "none");
89
+ url.searchParams.set("lang", "en");
90
+ url.searchParams.set("lb", "en");
91
+ url.searchParams.set("theme", "dark");
92
+ // Mojeek's `since` filter accepts the relative tokens day/week/month/year
93
+ // verbatim — the same vocabulary as `recency` (verified live: each window
94
+ // returns a near-disjoint, fresher result set). Dates reflect crawl or
95
+ // last-modification time per Mojeek's operator docs.
96
+ if (params.recency) url.searchParams.set("since", params.recency);
97
+ return url.href;
98
+ }
99
+
100
+ /** Solve Mojeek's ALTCHA interstitial and wait for its verified redirect to populate results. */
101
+ async function solveCaptcha(page: Page, signal: AbortSignal): Promise<void> {
102
+ if (await untilAborted(signal, () => page.$("ul.results-standard li"))) return;
103
+
104
+ const checkbox = await untilAborted(signal, () => page.$("altcha-widget input[type=checkbox]"));
105
+ if (!checkbox) return;
106
+
107
+ const navigation = page
108
+ .waitForNavigation({ waitUntil: "domcontentloaded", timeout: CAPTCHA_SOLVE_TIMEOUT_MS })
109
+ .catch(() => null);
110
+ await untilAborted(signal, () => checkbox.click());
111
+ await untilAborted(signal, () => navigation);
112
+ await untilAborted(signal, () =>
113
+ page.waitForSelector("ul.results-standard li", { timeout: CAPTCHA_SOLVE_TIMEOUT_MS }).catch(() => null),
114
+ );
115
+ }
116
+
117
+ function isRobotPage(page: LoadedHtmlPage): boolean {
118
+ return (
119
+ (page.html.includes("altcha-widget") ||
120
+ page.html.includes("captcha-wrap") ||
121
+ /sending automated queries/i.test(page.html)) &&
122
+ !page.html.includes("results-standard")
123
+ );
124
+ }
125
+
126
+ async function callMojeekHtml(params: SearchParams, numResults: number): Promise<string> {
127
+ const signal = withHardTimeout(params.signal);
128
+ const url = buildSearchUrl(params, numResults);
129
+ let page: LoadedHtmlPage;
130
+ try {
131
+ page = await browserFetch(url, {
132
+ fetch: params.fetch,
133
+ signal,
134
+ randomizeHeaders: false,
135
+ referer: MOJEEK_HOME_URL,
136
+ browser: {
137
+ homeUrl: MOJEEK_HOME_URL,
138
+ afterNavigation: solveCaptcha,
139
+ shouldFallback: isRobotPage,
140
+ attempts: 2,
141
+ retryDelayMs: 1_000,
142
+ },
143
+ });
144
+ } catch (error) {
145
+ if (error instanceof SearchProviderError || params.signal?.aborted) throw error;
146
+ if (signal.aborted) {
147
+ throw new SearchProviderError("mojeek", "Mojeek search timed out.", 504);
148
+ }
149
+ const message = error instanceof Error ? error.message : String(error);
150
+ throw new SearchProviderError("mojeek", `Mojeek search failed: ${message}`, 503);
151
+ }
152
+
153
+ // Robot walls: the ALTCHA proof-of-work captcha page arrives as HTTP 200
154
+ // (`<title>Captcha</title>`, `altcha-widget`) and the "automated queries"
155
+ // refusal as HTTP 403. Both bodies are more actionable than their raw
156
+ // statuses, so check them before the generic status handling.
157
+ if (isRobotPage(page)) {
158
+ throw new SearchProviderError(
159
+ "mojeek",
160
+ "Mojeek blocked the request with its automated-queries wall. Mojeek rate-limits scripted searches from datacenter/shared-egress IPs; retry later or configure another provider such as Brave, Tavily, Exa, or Kagi.",
161
+ 429,
162
+ );
163
+ }
164
+ if (page.status < 200 || page.status >= 300) {
165
+ const classified = classifyProviderHttpError("mojeek", page.status, page.html);
166
+ if (classified) throw classified;
167
+ throw new SearchProviderError("mojeek", `Mojeek HTML error (${page.status})`, page.status);
168
+ }
169
+ return page.html;
170
+ }
171
+
172
+ /** Execute a Mojeek web search against the standard HTML results page. */
173
+ export async function searchMojeek(params: SearchParams): Promise<SearchResponse> {
174
+ const numResults = clampNumResults(params.numSearchResults ?? params.limit, DEFAULT_NUM_RESULTS, MAX_NUM_RESULTS);
175
+ const html = await callMojeekHtml(params, numResults);
176
+ const parsed = parseHtmlResults(html);
177
+
178
+ const sources: SearchSource[] = [];
179
+ const seen = new Set<string>();
180
+ for (const result of parsed) {
181
+ if (seen.has(result.url)) continue;
182
+ seen.add(result.url);
183
+ sources.push({ title: result.title, url: result.url, snippet: result.snippet });
184
+ if (sources.length >= numResults) break;
185
+ }
186
+
187
+ return { provider: "mojeek", sources };
188
+ }
189
+
190
+ /** Search provider for Mojeek (independent index, no API key required). */
191
+ export class MojeekProvider extends SearchProvider {
192
+ readonly id = "mojeek";
193
+ readonly label = "Mojeek";
194
+
195
+ isAvailable(_authStorage: AuthStorage): boolean {
196
+ return true;
197
+ }
198
+
199
+ isExplicitlyAvailable(_authStorage: AuthStorage): boolean {
200
+ return true;
201
+ }
202
+
203
+ search(params: SearchParams): Promise<SearchResponse> {
204
+ return searchMojeek(params);
205
+ }
206
+ }