@pi-unipi/unipi 2.1.3 → 2.2.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 (90) hide show
  1. package/README.md +6 -2
  2. package/package.json +23 -22
  3. package/packages/ask-user/ask-ui.ts +45 -31
  4. package/packages/ask-user/launcher-ui.ts +26 -22
  5. package/packages/ask-user/package.json +2 -2
  6. package/packages/ask-user/settings-tui.ts +22 -9
  7. package/packages/autocomplete/package.json +1 -1
  8. package/packages/autocomplete/src/constants.ts +5 -0
  9. package/packages/btw/package.json +2 -2
  10. package/packages/cocoindex/package.json +2 -2
  11. package/packages/compactor/package.json +3 -3
  12. package/packages/compactor/src/tui/settings-overlay.ts +2 -1
  13. package/packages/core/constants.ts +18 -0
  14. package/packages/core/index.ts +1 -0
  15. package/packages/core/package.json +1 -1
  16. package/packages/core/tui-width.ts +127 -0
  17. package/packages/footer/package.json +2 -2
  18. package/packages/footer/src/tui/settings-tui.ts +2 -1
  19. package/packages/image/README.md +82 -0
  20. package/packages/image/index.ts +1 -0
  21. package/packages/image/package.json +57 -0
  22. package/packages/image/skills/image/SKILL.md +73 -0
  23. package/packages/image/src/commands.ts +17 -0
  24. package/packages/image/src/generate.ts +201 -0
  25. package/packages/image/src/image-source.ts +204 -0
  26. package/packages/image/src/index.ts +99 -0
  27. package/packages/image/src/models.ts +290 -0
  28. package/packages/image/src/recognize.ts +223 -0
  29. package/packages/image/src/settings.ts +149 -0
  30. package/packages/image/src/tools.ts +296 -0
  31. package/packages/image/src/tui/model-selector.ts +279 -0
  32. package/packages/image/src/tui/settings-dialog.ts +236 -0
  33. package/packages/info-screen/package.json +2 -2
  34. package/packages/info-screen/tui/info-overlay.ts +505 -0
  35. package/packages/input-shortcuts/package.json +2 -2
  36. package/packages/kanboard/package.json +2 -2
  37. package/packages/kanboard/tui/kanboard-overlay.ts +300 -0
  38. package/packages/mcp/package.json +2 -2
  39. package/packages/mcp/src/tui/add-overlay.ts +10 -7
  40. package/packages/mcp/src/tui/settings-overlay.ts +10 -7
  41. package/packages/memory/package.json +3 -3
  42. package/packages/memory/tui/settings-tui.ts +281 -0
  43. package/packages/milestone/package.json +2 -2
  44. package/packages/notify/README.md +2 -0
  45. package/packages/notify/events.ts +11 -0
  46. package/packages/notify/package.json +2 -2
  47. package/packages/notify/permission-prompt-message.ts +95 -0
  48. package/packages/notify/platforms/focus-win.ts +123 -0
  49. package/packages/notify/platforms/focus.ts +33 -0
  50. package/packages/notify/platforms/gotify.ts +36 -0
  51. package/packages/notify/platforms/native.ts +79 -0
  52. package/packages/notify/platforms/node-notifier.d.ts +20 -0
  53. package/packages/notify/platforms/ntfy.ts +48 -0
  54. package/packages/notify/platforms/telegram.ts +77 -0
  55. package/packages/notify/settings.ts +1 -0
  56. package/packages/notify/skills/configure-notify/SKILL.md +28 -1
  57. package/packages/notify/src/__tests__/permission-prompt-message.test.ts +172 -0
  58. package/packages/notify/tui/gotify-setup.ts +529 -0
  59. package/packages/notify/tui/ntfy-setup.ts +653 -0
  60. package/packages/notify/tui/recap-model-selector.ts +301 -0
  61. package/packages/notify/tui/settings-overlay.ts +353 -0
  62. package/packages/notify/tui/telegram-setup.ts +302 -0
  63. package/packages/ralph/package.json +3 -3
  64. package/packages/subagents/package.json +4 -4
  65. package/packages/subagents/src/__tests__/shutdown-stale-ctx.test.ts +185 -0
  66. package/packages/subagents/src/index.ts +52 -10
  67. package/packages/unipi/index.ts +2 -0
  68. package/packages/updater/package.json +2 -2
  69. package/packages/updater/src/tui/changelog-overlay.ts +2 -2
  70. package/packages/updater/src/tui/readme-overlay.ts +2 -1
  71. package/packages/updater/src/tui/update-overlay.ts +2 -1
  72. package/packages/utility/package.json +2 -2
  73. package/packages/utility/src/tui/badge-settings-tui.ts +2 -2
  74. package/packages/utility/src/tui/util-settings-tui.ts +2 -2
  75. package/packages/web-api/README.md +28 -11
  76. package/packages/web-api/package.json +8 -2
  77. package/packages/web-api/skills/web/SKILL.md +36 -9
  78. package/packages/web-api/src/index.ts +15 -0
  79. package/packages/web-api/src/providers/duckduckgo.ts +71 -27
  80. package/packages/web-api/src/providers/firecrawl.ts +1 -1
  81. package/packages/web-api/src/providers/jina-reader.ts +1 -1
  82. package/packages/web-api/src/providers/jina-search.ts +1 -1
  83. package/packages/web-api/src/providers/perplexity.ts +2 -2
  84. package/packages/web-api/src/providers/serpapi.ts +1 -1
  85. package/packages/web-api/src/providers/tavily.ts +1 -1
  86. package/packages/web-api/src/providers/wigolo-client.ts +196 -0
  87. package/packages/web-api/src/providers/wigolo.ts +117 -0
  88. package/packages/web-api/src/settings.ts +1 -0
  89. package/packages/web-api/src/tools.ts +93 -36
  90. package/packages/workflow/package.json +2 -2
@@ -12,6 +12,7 @@ import type { Theme } from "@earendil-works/pi-coding-agent";
12
12
  import { discoverReadmes, resolveReadmePath } from "../readme.js";
13
13
  import { renderMarkdown } from "../markdown.js";
14
14
  import type { ReadmeEntry } from "../../types.js";
15
+ import { boxInnerWidth } from "@pi-unipi/core";
15
16
 
16
17
  type View = "list" | "content";
17
18
 
@@ -71,7 +72,7 @@ export function renderReadmeOverlay(params?: { openDirect?: string }) {
71
72
  const render = (width: number): string[] => {
72
73
  ensureLoaded();
73
74
 
74
- const innerWidth = Math.max(22, width - 2);
75
+ const innerWidth = boxInnerWidth(width);
75
76
  const lines: string[] = [];
76
77
 
77
78
  // ── Header ──────────────────────────────────────────────────────
@@ -15,6 +15,7 @@ import { installUpdate } from "../installer.js";
15
15
  import { writeSkippedVersion } from "../cache.js";
16
16
  import { loadConfig } from "../settings.js";
17
17
  import type { ChangelogEntry, UpdateCheckResult } from "../../types.js";
18
+ import { boxInnerWidth } from "@pi-unipi/core";
18
19
 
19
20
  /**
20
21
  * Pad content to exact visible width.
@@ -119,7 +120,7 @@ export function renderUpdateOverlay(checkResult: UpdateCheckResult) {
119
120
  };
120
121
 
121
122
  const render = (width: number): string[] => {
122
- const innerWidth = Math.max(22, width - 2);
123
+ const innerWidth = boxInnerWidth(width);
123
124
  const lines: string[] = [];
124
125
 
125
126
  // ── Header ──────────────────────────────────────────────────────
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/utility",
3
- "version": "2.1.3",
3
+ "version": "2.2.0",
4
4
  "description": "Utility commands and tools for Pi coding agent — lifecycle, diagnostics, cache, analytics, display, batch execution",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -34,7 +34,7 @@
34
34
  "access": "public"
35
35
  },
36
36
  "dependencies": {
37
- "@pi-unipi/core": "2.1.3",
37
+ "@pi-unipi/core": "2.2.0",
38
38
  "diff": "^7.0.0",
39
39
  "@shikijs/cli": "^4.0.2"
40
40
  },
@@ -9,7 +9,7 @@
9
9
  import type { Component } from "@earendil-works/pi-tui";
10
10
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
11
11
  import type { CachedModel } from "@pi-unipi/core";
12
- import { readModelCache } from "@pi-unipi/core";
12
+ import { readModelCache, boxInnerWidth } from "@pi-unipi/core";
13
13
  import {
14
14
  readBadgeSettings,
15
15
  writeBadgeSettings,
@@ -275,7 +275,7 @@ export class BadgeSettingsTui implements Component {
275
275
  */
276
276
  render(width: number): string[] {
277
277
  const lines: string[] = [];
278
- const innerWidth = Math.max(44, width - 2);
278
+ const innerWidth = boxInnerWidth(width);
279
279
 
280
280
  const padVisible = (content: string, targetWidth: number): string => {
281
281
  const vw = visibleWidth(content);
@@ -11,7 +11,7 @@
11
11
  import type { Component } from "@earendil-works/pi-tui";
12
12
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
13
13
  import type { CachedModel } from "@pi-unipi/core";
14
- import { readModelCache } from "@pi-unipi/core";
14
+ import { readModelCache, boxInnerWidth } from "@pi-unipi/core";
15
15
  import {
16
16
  readUtilSettings,
17
17
  writeUtilSettings,
@@ -370,7 +370,7 @@ export class UtilSettingsTui implements Component {
370
370
  */
371
371
  render(width: number): string[] {
372
372
  const lines: string[] = [];
373
- const innerWidth = Math.max(52, width - 2);
373
+ const innerWidth = boxInnerWidth(width);
374
374
 
375
375
  const padVisible = (content: string, targetWidth: number): string => {
376
376
  const vw = visibleWidth(content);
@@ -2,7 +2,22 @@
2
2
 
3
3
  Web search, page reading, and content summarization for the agent. The read path uses a local smart-fetch engine by default — free, no API key, browser-grade TLS fingerprinting that bypasses Cloudflare.
4
4
 
5
- Paid providers (SerpAPI, Tavily, Firecrawl, Perplexity) are available as fallbacks. DuckDuckGo and Jina work out of the box for search.
5
+ [wigolo](https://github.com/KnockOutEZ/wigolo) is the default search and read provider when installed — a local-first engine with multi-engine search, rank fusion and on-device reranking, at $0/query with no API key. Paid providers (SerpAPI, Tavily, Firecrawl, Perplexity) are available as fallbacks, and DuckDuckGo and Jina work out of the box.
6
+
7
+ ### wigolo (optional, recommended)
8
+
9
+ wigolo is ranked first for both search and read but is **not bundled** — it is an
10
+ AGPL-licensed project while UniPi is MIT, so it is an optional dependency loaded
11
+ at runtime only if you installed it. Set it up once:
12
+
13
+ ```bash
14
+ npm install -g wigolo
15
+ npx wigolo init # downloads the browser engine + on-device models (~1.5 GB)
16
+ npx wigolo doctor # verify
17
+ ```
18
+
19
+ Until then, web calls fall through automatically to the next-ranked provider, so
20
+ nothing breaks if you skip it. Disable it entirely in `/unipi:web-settings`.
6
21
 
7
22
  ## Commands
8
23
 
@@ -39,7 +54,7 @@ The footer and info-screen don't display web-api data — it's a tool package, n
39
54
  web_search(query: "TypeScript generics")
40
55
 
41
56
  # Use specific provider
42
- web_search(query: "latest AI research", source: 4) # Tavily
57
+ web_search(query: "latest AI research", source: 5) # Tavily
43
58
  ```
44
59
 
45
60
  ### multi_web_content_read
@@ -51,7 +66,7 @@ multi_web_content_read(url: "https://example.com/article")
51
66
  # Batch URLs
52
67
  multi_web_content_read(url: ["https://example.com/a", "https://example.com/b"])
53
68
 
54
- # Provider fallback (Jina Reader)
69
+ # Provider fallback (wigolo)
55
70
  multi_web_content_read(url: "https://example.com/article", source: 1)
56
71
 
57
72
  # Custom options
@@ -83,20 +98,22 @@ Outputs clean markdown with metadata (title, author, site, word count). Supports
83
98
 
84
99
  | Provider | Rank | Cost | API Key |
85
100
  |----------|------|------|---------|
86
- | DuckDuckGo | 1 | Free | No |
87
- | Jina AI Search | 2 | Freemium | Optional |
88
- | SerpAPI | 3 | Paid | Required |
89
- | Tavily | 4 | Paid | Required |
90
- | Perplexity | 5 | Paid | Required |
101
+ | wigolo (local) | 1 | Free | No |
102
+ | DuckDuckGo | 2 | Free | No |
103
+ | Jina AI Search | 3 | Freemium | Optional |
104
+ | SerpAPI | 4 | Paid | Required |
105
+ | Tavily | 5 | Paid | Required |
106
+ | Perplexity | 6 | Paid | Required |
91
107
 
92
108
  ### Read
93
109
 
94
110
  | Provider | Rank | Cost | API Key |
95
111
  |----------|------|------|---------|
96
112
  | Smart-Fetch Engine | 0 | Free | No |
97
- | Jina AI Reader | 1 | Freemium | Optional |
98
- | Firecrawl | 2 | Paid | Required |
99
- | Perplexity | 3 | Paid | Required |
113
+ | wigolo (local) | 1 | Free | No |
114
+ | Jina AI Reader | 2 | Freemium | Optional |
115
+ | Firecrawl | 3 | Paid | Required |
116
+ | Perplexity | 4 | Paid | Required |
100
117
 
101
118
  ### Summarize
102
119
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/web-api",
3
- "version": "2.1.3",
3
+ "version": "2.2.0",
4
4
  "description": "Web search, read, and summarize tools with provider-based backend selection for Pi coding agent",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -31,13 +31,16 @@
31
31
  "README.md"
32
32
  ],
33
33
  "dependencies": {
34
- "@pi-unipi/core": "2.1.3",
34
+ "@pi-unipi/core": "2.2.0",
35
35
  "defuddle": "^0.18.1",
36
36
  "linkedom": "^0.18.12",
37
37
  "lodash": "^4.17.21",
38
38
  "mime-types": "^2.1.35",
39
39
  "wreq-js": "^2.3.0"
40
40
  },
41
+ "optionalDependencies": {
42
+ "wigolo-sdk": "^0.2.1"
43
+ },
41
44
  "peerDependencies": {
42
45
  "@earendil-works/pi-coding-agent": "^0.80.0",
43
46
  "@earendil-works/pi-tui": "^0.80.0",
@@ -48,6 +51,9 @@
48
51
  "@types/mime-types": "^3.0.1",
49
52
  "@types/node": "^25.6.0"
50
53
  },
54
+ "scripts": {
55
+ "test": "npx tsx --test tests/**/*.test.ts"
56
+ },
51
57
  "pi": {
52
58
  "extensions": [],
53
59
  "skills": [],
@@ -21,7 +21,7 @@ Search the web for information. Lower `source` = simpler/cheaper providers.
21
21
  **Examples:**
22
22
  ```
23
23
  web_search(query: "TypeScript generics tutorial")
24
- web_search(query: "latest AI research", source: 4) # Use Tavily
24
+ web_search(query: "latest AI research", source: 5) # Use Tavily
25
25
  ```
26
26
 
27
27
  ## multi_web_content_read
@@ -87,25 +87,51 @@ web_llm_summarize(url: "https://example.com/research", prompt: "Extract key find
87
87
  - Specify `source` number for specific provider
88
88
  - If provider unavailable, tool throws descriptive error
89
89
 
90
+ **Auto-selection falls through on failure.** With `source` omitted, a failing
91
+ provider is skipped and the next-ranked one is tried, so an uninitialized
92
+ wigolo never blocks a search. With an explicit `source`, the choice is
93
+ respected and the error is reported instead.
94
+
90
95
  ### Provider Rankings
91
96
 
92
97
  **Search providers:**
93
- 1. DuckDuckGo (free)
94
- 2. Jina AI Search (freemium)
95
- 3. SerpAPI (paid)
96
- 4. Tavily (paid)
97
- 5. Perplexity (paid)
98
+ 1. wigolo (free, local) — default
99
+ 2. DuckDuckGo (free)
100
+ 3. Jina AI Search (freemium)
101
+ 4. SerpAPI (paid)
102
+ 5. Tavily (paid)
103
+ 6. Perplexity (paid)
98
104
 
99
105
  **Read providers:**
100
106
  0. **Smart-Fetch Engine** (free, local) — default
101
- 1. Jina AI Reader (freemium)
102
- 2. Firecrawl (paid)
103
- 3. Perplexity (paid)
107
+ 1. wigolo (free, local)
108
+ 2. Jina AI Reader (freemium)
109
+ 3. Firecrawl (paid)
110
+ 4. Perplexity (paid)
104
111
 
105
112
  **Summarize providers:**
106
113
  1. Perplexity (paid)
107
114
  2. LLM Summarize (uses pi's LLM)
108
115
 
116
+ ## wigolo
117
+
118
+ [wigolo](https://github.com/KnockOutEZ/wigolo) is a local-first web engine:
119
+ multi-engine search (18 direct adapters) with rank fusion and on-device
120
+ reranking, plus a tiered fetch router that escalates to a headless browser on
121
+ anti-bot challenges. No API key, nothing leaves the machine, $0 per query.
122
+
123
+ It is **not bundled** — wigolo is AGPL-licensed and UniPi is MIT, so it is an
124
+ optional dependency loaded at runtime only when the user installed it:
125
+
126
+ ```bash
127
+ npm install -g wigolo && npx wigolo init
128
+ ```
129
+
130
+ If it is missing or uninitialized, the provider raises an actionable error and
131
+ auto-selection falls through to the next provider. Never tell the user wigolo
132
+ is broken — tell them to run `npx wigolo init`, or to disable it in
133
+ `/unipi:web-settings`.
134
+
109
135
  ## Smart-Fetch Engine
110
136
 
111
137
  The smart-fetch engine is a local content extraction pipeline:
@@ -127,6 +153,7 @@ The smart-fetch engine is a local content extraction pipeline:
127
153
  ## Cost Awareness
128
154
 
129
155
  - **Smart-Fetch Engine:** Free (read only, no API key)
156
+ - **wigolo:** Free (search + read, local, no API key) — prefer this
130
157
  - **DuckDuckGo:** Free (search only)
131
158
  - **Jina:** Freemium (search + read)
132
159
  - **SerpAPI/Tavily:** Paid (search)
@@ -19,6 +19,8 @@ import { registerWebCommands, WEB_COMMANDS } from "./commands.js";
19
19
  import { webCache } from "./cache.js";
20
20
  import { loadConfig, loadSmartFetchSettings } from "./settings.js";
21
21
  import { checkDependencies } from "./engine/dependencies.js";
22
+ import { closeWigoloClient, isWigoloInstalled } from "./providers/wigolo-client.js";
23
+ import "./providers/wigolo.js";
22
24
  import "./providers/duckduckgo.js";
23
25
  import "./providers/jina-search.js";
24
26
  import "./providers/jina-reader.js";
@@ -74,6 +76,7 @@ export default function (pi: ExtensionAPI) {
74
76
  showByDefault: true,
75
77
  stats: [
76
78
  { id: "providers", label: "Enabled Providers", show: true },
79
+ { id: "wigolo", label: "wigolo", show: true },
77
80
  { id: "smartFetch", label: "Smart-Fetch", show: true },
78
81
  { id: "cacheEntries", label: "Cache Entries", show: true },
79
82
  { id: "cacheSize", label: "Cache Size", show: true },
@@ -91,8 +94,18 @@ export default function (pi: ExtensionAPI) {
91
94
  const deps = await checkDependencies();
92
95
  const smartFetchStatus = deps.available ? "✓ Ready" : `Missing: ${deps.missing.join(", ")}`;
93
96
 
97
+ // wigolo status — only probe when the user has it enabled, so a
98
+ // disabled provider never pays the daemon-startup cost.
99
+ let wigoloStatus = "Disabled";
100
+ if (config.providers.wigolo?.enabled !== false) {
101
+ wigoloStatus = (await isWigoloInstalled())
102
+ ? "✓ Installed"
103
+ : "Not installed (npx wigolo init)";
104
+ }
105
+
94
106
  return {
95
107
  providers: { value: String(enabledCount) },
108
+ wigolo: { value: wigoloStatus },
96
109
  smartFetch: { value: smartFetchStatus },
97
110
  cacheEntries: { value: String(stats.totalEntries) },
98
111
  cacheSize: { value: `${(stats.totalSizeBytes / 1024).toFixed(1)} KB` },
@@ -106,5 +119,7 @@ export default function (pi: ExtensionAPI) {
106
119
  pi.on("session_shutdown", async (_event, _ctx) => {
107
120
  // Cleanup: clear expired cache entries
108
121
  webCache.clearExpired();
122
+ // Stop the wigolo daemon if this session started it.
123
+ await closeWigoloClient();
109
124
  });
110
125
  }
@@ -19,43 +19,87 @@ interface DDGResult {
19
19
  snippet: string;
20
20
  }
21
21
 
22
+ /** Decode the handful of HTML entities DuckDuckGo emits. */
23
+ export function decodeEntities(text: string): string {
24
+ return text
25
+ .replace(/&/g, "&")
26
+ .replace(/&lt;/g, "<")
27
+ .replace(/&gt;/g, ">")
28
+ .replace(/&quot;/g, '"')
29
+ .replace(/&#x27;|&#39;/g, "'")
30
+ .replace(/&nbsp;/g, " ")
31
+ .replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)));
32
+ }
33
+
34
+ /** Strip inline markup (DuckDuckGo bolds query terms with <b> inside snippets). */
35
+ function stripTags(html: string): string {
36
+ return html.replace(/<[^>]*>/g, "");
37
+ }
38
+
39
+ /**
40
+ * Unwrap DuckDuckGo's redirect links.
41
+ *
42
+ * Result hrefs look like `//duckduckgo.com/l/?uddg=<encoded>&rut=<hash>`; the
43
+ * real destination is the `uddg` parameter. Returning the wrapper would give
44
+ * the agent a URL that is useless to read or cite.
45
+ */
46
+ export function unwrapRedirect(href: string): string {
47
+ const decoded = decodeEntities(href);
48
+
49
+ const match = decoded.match(/[?&]uddg=([^&]+)/);
50
+ if (match) {
51
+ try {
52
+ return decodeURIComponent(match[1]);
53
+ } catch {
54
+ // Fall through to the protocol-relative fix below.
55
+ }
56
+ }
57
+
58
+ // Protocol-relative links would otherwise be unusable.
59
+ if (decoded.startsWith("//")) return `https:${decoded}`;
60
+
61
+ return decoded;
62
+ }
63
+
22
64
  /**
23
65
  * Parse DuckDuckGo HTML search results.
24
- * Extracts result titles, URLs, and snippets from the HTML.
66
+ *
67
+ * Titles and snippets are parsed per result block rather than as two
68
+ * independent streams: a result without a snippet used to shift every
69
+ * subsequent snippet onto the wrong title.
25
70
  */
26
- function parseDDGResults(html: string): DDGResult[] {
71
+ export function parseDDGResults(html: string): DDGResult[] {
27
72
  const results: DDGResult[] = [];
28
73
 
29
- // Match result links and snippets
30
- // DuckDuckGo results are in <a class="result__a"> tags
31
- const linkRegex = /<a[^>]*class="result__a"[^>]*href="([^"]*)"[^>]*>([^<]*)<\/a>/g;
32
- const snippetRegex = /<a[^>]*class="result__snippet"[^>]*>([^<]*)<\/a>/g;
33
-
34
- const links: { url: string; title: string }[] = [];
35
- const snippets: string[] = [];
74
+ // `[\s\S]*?` (not `.`) so blocks spanning newlines are matched.
75
+ const linkRegex =
76
+ /<a[^>]*class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/g;
77
+ // Snippets contain <b> highlights, so the body cannot be `[^<]*`.
78
+ const snippetRegex =
79
+ /<a[^>]*class="result__snippet"[^>]*>([\s\S]*?)<\/a>/g;
36
80
 
37
- let match;
81
+ const snippets: Array<{ index: number; text: string }> = [];
82
+ let match: RegExpExecArray | null;
38
83
 
39
- // Extract links
40
- while ((match = linkRegex.exec(html)) !== null) {
41
- links.push({
42
- url: match[1],
43
- title: match[2].trim(),
84
+ while ((match = snippetRegex.exec(html)) !== null) {
85
+ snippets.push({
86
+ index: match.index,
87
+ text: decodeEntities(stripTags(match[1])).replace(/\s+/g, " ").trim(),
44
88
  });
45
89
  }
46
90
 
47
- // Extract snippets
48
- while ((match = snippetRegex.exec(html)) !== null) {
49
- snippets.push(match[1].trim());
50
- }
91
+ while ((match = linkRegex.exec(html)) !== null) {
92
+ const url = unwrapRedirect(match[1]);
93
+ const title = decodeEntities(stripTags(match[2])).replace(/\s+/g, " ").trim();
94
+ if (!url || !title) continue;
51
95
 
52
- // Combine results
53
- for (let i = 0; i < Math.min(links.length, snippets.length); i++) {
54
- results.push({
55
- title: links[i].title,
56
- url: links[i].url,
57
- snippet: snippets[i],
58
- });
96
+ // The snippet belongs to this result if it appears before the next one.
97
+ const linkEnd = match.index + match[0].length;
98
+ const nextLink = html.indexOf('class="result__a"', linkEnd);
99
+ const boundary = nextLink === -1 ? html.length : nextLink;
100
+ const snippet = snippets.find((s) => s.index > match!.index && s.index < boundary);
101
+
102
+ results.push({ title, url, snippet: snippet?.text ?? "" });
59
103
  }
60
104
 
61
105
  return results;
@@ -98,7 +142,7 @@ const duckduckgoProvider: WebProvider = {
98
142
  capabilities: ["search"],
99
143
  requiresApiKey: false,
100
144
  ranking: {
101
- search: 1,
145
+ search: 2,
102
146
  read: 0,
103
147
  summarize: 0,
104
148
  },
@@ -76,7 +76,7 @@ const firecrawlProvider: WebProvider = {
76
76
  apiKeyEnv: "FIRECRAWL_API_KEY",
77
77
  ranking: {
78
78
  search: 0,
79
- read: 2,
79
+ read: 3,
80
80
  summarize: 0,
81
81
  },
82
82
  config: {},
@@ -63,7 +63,7 @@ const jinaReaderProvider: WebProvider = {
63
63
  apiKeyEnv: "JINA_API_KEY",
64
64
  ranking: {
65
65
  search: 0,
66
- read: 1,
66
+ read: 2,
67
67
  summarize: 0,
68
68
  },
69
69
  config: {},
@@ -61,7 +61,7 @@ const jinaSearchProvider: WebProvider = {
61
61
  requiresApiKey: false,
62
62
  apiKeyEnv: "JINA_API_KEY",
63
63
  ranking: {
64
- search: 2,
64
+ search: 3,
65
65
  read: 0,
66
66
  summarize: 0,
67
67
  },
@@ -138,8 +138,8 @@ const perplexityProvider: WebProvider = {
138
138
  requiresApiKey: true,
139
139
  apiKeyEnv: "PERPLEXITY_API_KEY",
140
140
  ranking: {
141
- search: 5,
142
- read: 3,
141
+ search: 6,
142
+ read: 4,
143
143
  summarize: 1,
144
144
  },
145
145
  config: {},
@@ -56,7 +56,7 @@ const serpapiProvider: WebProvider = {
56
56
  requiresApiKey: true,
57
57
  apiKeyEnv: "SERPAPI_KEY",
58
58
  ranking: {
59
- search: 3,
59
+ search: 4,
60
60
  read: 0,
61
61
  summarize: 0,
62
62
  },
@@ -65,7 +65,7 @@ const tavilyProvider: WebProvider = {
65
65
  requiresApiKey: true,
66
66
  apiKeyEnv: "TAVILY_API_KEY",
67
67
  ranking: {
68
- search: 4,
68
+ search: 5,
69
69
  read: 0,
70
70
  summarize: 0,
71
71
  },