@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.
- package/README.md +6 -2
- package/package.json +23 -22
- package/packages/ask-user/ask-ui.ts +45 -31
- package/packages/ask-user/launcher-ui.ts +26 -22
- package/packages/ask-user/package.json +2 -2
- package/packages/ask-user/settings-tui.ts +22 -9
- package/packages/autocomplete/package.json +1 -1
- package/packages/autocomplete/src/constants.ts +5 -0
- package/packages/btw/package.json +2 -2
- package/packages/cocoindex/package.json +2 -2
- package/packages/compactor/package.json +3 -3
- package/packages/compactor/src/tui/settings-overlay.ts +2 -1
- package/packages/core/constants.ts +18 -0
- package/packages/core/index.ts +1 -0
- package/packages/core/package.json +1 -1
- package/packages/core/tui-width.ts +127 -0
- package/packages/footer/package.json +2 -2
- package/packages/footer/src/tui/settings-tui.ts +2 -1
- package/packages/image/README.md +82 -0
- package/packages/image/index.ts +1 -0
- package/packages/image/package.json +57 -0
- package/packages/image/skills/image/SKILL.md +73 -0
- package/packages/image/src/commands.ts +17 -0
- package/packages/image/src/generate.ts +201 -0
- package/packages/image/src/image-source.ts +204 -0
- package/packages/image/src/index.ts +99 -0
- package/packages/image/src/models.ts +290 -0
- package/packages/image/src/recognize.ts +223 -0
- package/packages/image/src/settings.ts +149 -0
- package/packages/image/src/tools.ts +296 -0
- package/packages/image/src/tui/model-selector.ts +279 -0
- package/packages/image/src/tui/settings-dialog.ts +236 -0
- package/packages/info-screen/package.json +2 -2
- package/packages/info-screen/tui/info-overlay.ts +505 -0
- package/packages/input-shortcuts/package.json +2 -2
- package/packages/kanboard/package.json +2 -2
- package/packages/kanboard/tui/kanboard-overlay.ts +300 -0
- package/packages/mcp/package.json +2 -2
- package/packages/mcp/src/tui/add-overlay.ts +10 -7
- package/packages/mcp/src/tui/settings-overlay.ts +10 -7
- package/packages/memory/package.json +3 -3
- package/packages/memory/tui/settings-tui.ts +281 -0
- package/packages/milestone/package.json +2 -2
- package/packages/notify/README.md +2 -0
- package/packages/notify/events.ts +11 -0
- package/packages/notify/package.json +2 -2
- package/packages/notify/permission-prompt-message.ts +95 -0
- package/packages/notify/platforms/focus-win.ts +123 -0
- package/packages/notify/platforms/focus.ts +33 -0
- package/packages/notify/platforms/gotify.ts +36 -0
- package/packages/notify/platforms/native.ts +79 -0
- package/packages/notify/platforms/node-notifier.d.ts +20 -0
- package/packages/notify/platforms/ntfy.ts +48 -0
- package/packages/notify/platforms/telegram.ts +77 -0
- package/packages/notify/settings.ts +1 -0
- package/packages/notify/skills/configure-notify/SKILL.md +28 -1
- package/packages/notify/src/__tests__/permission-prompt-message.test.ts +172 -0
- package/packages/notify/tui/gotify-setup.ts +529 -0
- package/packages/notify/tui/ntfy-setup.ts +653 -0
- package/packages/notify/tui/recap-model-selector.ts +301 -0
- package/packages/notify/tui/settings-overlay.ts +353 -0
- package/packages/notify/tui/telegram-setup.ts +302 -0
- package/packages/ralph/package.json +3 -3
- package/packages/subagents/package.json +4 -4
- package/packages/subagents/src/__tests__/shutdown-stale-ctx.test.ts +185 -0
- package/packages/subagents/src/index.ts +52 -10
- package/packages/unipi/index.ts +2 -0
- package/packages/updater/package.json +2 -2
- package/packages/updater/src/tui/changelog-overlay.ts +2 -2
- package/packages/updater/src/tui/readme-overlay.ts +2 -1
- package/packages/updater/src/tui/update-overlay.ts +2 -1
- package/packages/utility/package.json +2 -2
- package/packages/utility/src/tui/badge-settings-tui.ts +2 -2
- package/packages/utility/src/tui/util-settings-tui.ts +2 -2
- package/packages/web-api/README.md +28 -11
- package/packages/web-api/package.json +8 -2
- package/packages/web-api/skills/web/SKILL.md +36 -9
- package/packages/web-api/src/index.ts +15 -0
- package/packages/web-api/src/providers/duckduckgo.ts +71 -27
- package/packages/web-api/src/providers/firecrawl.ts +1 -1
- package/packages/web-api/src/providers/jina-reader.ts +1 -1
- package/packages/web-api/src/providers/jina-search.ts +1 -1
- package/packages/web-api/src/providers/perplexity.ts +2 -2
- package/packages/web-api/src/providers/serpapi.ts +1 -1
- package/packages/web-api/src/providers/tavily.ts +1 -1
- package/packages/web-api/src/providers/wigolo-client.ts +196 -0
- package/packages/web-api/src/providers/wigolo.ts +117 -0
- package/packages/web-api/src/settings.ts +1 -0
- package/packages/web-api/src/tools.ts +93 -36
- 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 =
|
|
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 =
|
|
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.
|
|
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.
|
|
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 =
|
|
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 =
|
|
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
|
|
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:
|
|
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 (
|
|
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
|
-
|
|
|
87
|
-
|
|
|
88
|
-
|
|
|
89
|
-
|
|
|
90
|
-
|
|
|
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
|
-
|
|
|
98
|
-
|
|
|
99
|
-
|
|
|
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.
|
|
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.
|
|
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:
|
|
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.
|
|
94
|
-
2.
|
|
95
|
-
3.
|
|
96
|
-
4.
|
|
97
|
-
5.
|
|
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.
|
|
102
|
-
2.
|
|
103
|
-
3.
|
|
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(/</g, "<")
|
|
27
|
+
.replace(/>/g, ">")
|
|
28
|
+
.replace(/"/g, '"')
|
|
29
|
+
.replace(/'|'/g, "'")
|
|
30
|
+
.replace(/ /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
|
-
*
|
|
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
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
81
|
+
const snippets: Array<{ index: number; text: string }> = [];
|
|
82
|
+
let match: RegExpExecArray | null;
|
|
38
83
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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:
|
|
145
|
+
search: 2,
|
|
102
146
|
read: 0,
|
|
103
147
|
summarize: 0,
|
|
104
148
|
},
|