@pi-unipi/web-api 2.1.2 → 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 +28 -11
- package/package.json +8 -2
- package/skills/web/SKILL.md +36 -9
- package/src/index.ts +15 -0
- package/src/providers/duckduckgo.ts +71 -27
- package/src/providers/firecrawl.ts +1 -1
- package/src/providers/jina-reader.ts +1 -1
- package/src/providers/jina-search.ts +1 -1
- package/src/providers/perplexity.ts +2 -2
- package/src/providers/serpapi.ts +1 -1
- package/src/providers/tavily.ts +1 -1
- package/src/providers/wigolo-client.ts +196 -0
- package/src/providers/wigolo.ts +117 -0
- package/src/settings.ts +1 -0
- package/src/tools.ts +93 -36
package/README.md
CHANGED
|
@@ -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
|
|
package/package.json
CHANGED
|
@@ -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": [],
|
package/skills/web/SKILL.md
CHANGED
|
@@ -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)
|
package/src/index.ts
CHANGED
|
@@ -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
|
},
|
package/src/providers/serpapi.ts
CHANGED
package/src/providers/tavily.ts
CHANGED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @unipi/web-api — wigolo daemon client lifecycle
|
|
3
|
+
*
|
|
4
|
+
* wigolo (https://github.com/KnockOutEZ/wigolo) is a local-first web engine:
|
|
5
|
+
* multi-engine search, tiered fetch, on-device reranking. It needs no API key
|
|
6
|
+
* and nothing leaves the machine.
|
|
7
|
+
*
|
|
8
|
+
* `wigolo-sdk` is AGPL-3.0-only while UniPi is MIT, so it is an *optional*
|
|
9
|
+
* dependency loaded through a dynamic `import()`. UniPi therefore ships no
|
|
10
|
+
* AGPL code; wigolo is used only if the user has installed it. When it is
|
|
11
|
+
* absent (or not initialized) the provider reports an actionable error and
|
|
12
|
+
* auto-selection falls through to the next-ranked provider.
|
|
13
|
+
*
|
|
14
|
+
* The daemon is expensive to start, so the client is a lazily-created
|
|
15
|
+
* module-level singleton, closed on session shutdown.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Minimal structural types — avoids a type-level dependency on the AGPL SDK. */
|
|
19
|
+
interface WigoloSearchResponse {
|
|
20
|
+
results?: unknown[];
|
|
21
|
+
answer?: string;
|
|
22
|
+
error?: string;
|
|
23
|
+
warning?: string;
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface WigoloFetchResponse {
|
|
28
|
+
url?: string;
|
|
29
|
+
title?: string;
|
|
30
|
+
markdown?: string;
|
|
31
|
+
error?: string;
|
|
32
|
+
[key: string]: unknown;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface WigoloHealthResponse {
|
|
36
|
+
status?: string;
|
|
37
|
+
[key: string]: unknown;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface WigoloClientLike {
|
|
41
|
+
search(params: Record<string, unknown>): Promise<WigoloSearchResponse>;
|
|
42
|
+
fetch(params: Record<string, unknown>): Promise<WigoloFetchResponse>;
|
|
43
|
+
health(): Promise<WigoloHealthResponse>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface WigoloLocalClient {
|
|
47
|
+
client: WigoloClientLike;
|
|
48
|
+
owned: boolean;
|
|
49
|
+
close(): Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Raised when wigolo is unavailable, with an actionable remedy. */
|
|
53
|
+
export class WigoloUnavailableError extends Error {
|
|
54
|
+
constructor(message: string) {
|
|
55
|
+
super(message);
|
|
56
|
+
this.name = "WigoloUnavailableError";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const NOT_INSTALLED_MESSAGE =
|
|
61
|
+
"wigolo is not installed.\n" +
|
|
62
|
+
"→ Install it: npm install -g wigolo && npx wigolo init\n" +
|
|
63
|
+
"→ wigolo is a separate AGPL-licensed project and is not bundled with UniPi.\n" +
|
|
64
|
+
"→ Disable it in /unipi:web-settings to silence this.";
|
|
65
|
+
|
|
66
|
+
const NOT_RUNNING_MESSAGE =
|
|
67
|
+
"wigolo is installed but the local daemon could not be reached.\n" +
|
|
68
|
+
"→ Initialize it: npx wigolo init\n" +
|
|
69
|
+
"→ Check health: npx wigolo doctor\n" +
|
|
70
|
+
"→ Disable it in /unipi:web-settings to silence this.";
|
|
71
|
+
|
|
72
|
+
/** Cached singleton — the daemon is far too expensive to start per call. */
|
|
73
|
+
let clientPromise: Promise<WigoloLocalClient> | null = null;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Test seam. ESM bindings are read-only, so tests cannot monkey-patch the
|
|
77
|
+
* exported `getWigoloClient`; they inject a stub daemon here instead.
|
|
78
|
+
*/
|
|
79
|
+
let clientOverride: WigoloClientLike | null = null;
|
|
80
|
+
|
|
81
|
+
/** Last known availability, for the settings TUI and info screen. */
|
|
82
|
+
let lastError: string | null = null;
|
|
83
|
+
|
|
84
|
+
/** Load the optional SDK. Returns null when it is not installed. */
|
|
85
|
+
async function loadSdk(): Promise<
|
|
86
|
+
{ createLocalClient: (opts?: unknown) => Promise<WigoloLocalClient> } | null
|
|
87
|
+
> {
|
|
88
|
+
try {
|
|
89
|
+
// Non-literal specifier keeps bundlers from trying to resolve the optional
|
|
90
|
+
// dependency at build time.
|
|
91
|
+
const specifier = "wigolo-sdk/local";
|
|
92
|
+
return (await import(/* @vite-ignore */ specifier)) as {
|
|
93
|
+
createLocalClient: (opts?: unknown) => Promise<WigoloLocalClient>;
|
|
94
|
+
};
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Get the shared wigolo client, starting the daemon if needed.
|
|
102
|
+
* @throws {WigoloUnavailableError} when wigolo is not installed or unreachable.
|
|
103
|
+
*/
|
|
104
|
+
export async function getWigoloClient(): Promise<WigoloClientLike> {
|
|
105
|
+
if (clientOverride) return clientOverride;
|
|
106
|
+
|
|
107
|
+
if (!clientPromise) {
|
|
108
|
+
clientPromise = (async () => {
|
|
109
|
+
const sdk = await loadSdk();
|
|
110
|
+
if (!sdk) {
|
|
111
|
+
throw new WigoloUnavailableError(NOT_INSTALLED_MESSAGE);
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
return await sdk.createLocalClient();
|
|
115
|
+
} catch (error) {
|
|
116
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
117
|
+
throw new WigoloUnavailableError(`${NOT_RUNNING_MESSAGE}\n→ Cause: ${detail}`);
|
|
118
|
+
}
|
|
119
|
+
})();
|
|
120
|
+
|
|
121
|
+
// A failed attempt must not be cached forever — the user may run
|
|
122
|
+
// `wigolo init` mid-session.
|
|
123
|
+
clientPromise.catch(() => {
|
|
124
|
+
clientPromise = null;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const local = await clientPromise;
|
|
130
|
+
lastError = null;
|
|
131
|
+
return local.client;
|
|
132
|
+
} catch (error) {
|
|
133
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Close the daemon if this process started it. Safe to call repeatedly. */
|
|
139
|
+
export async function closeWigoloClient(): Promise<void> {
|
|
140
|
+
const pending = clientPromise;
|
|
141
|
+
clientPromise = null;
|
|
142
|
+
if (!pending) return;
|
|
143
|
+
try {
|
|
144
|
+
const local = await pending;
|
|
145
|
+
await local.close();
|
|
146
|
+
} catch {
|
|
147
|
+
// Never let shutdown cleanup throw.
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Availability for the settings TUI / info screen. Never throws. */
|
|
152
|
+
export async function checkWigoloHealth(): Promise<{
|
|
153
|
+
available: boolean;
|
|
154
|
+
status: string;
|
|
155
|
+
detail?: string;
|
|
156
|
+
}> {
|
|
157
|
+
const sdk = await loadSdk();
|
|
158
|
+
if (!sdk) {
|
|
159
|
+
return { available: false, status: "not installed", detail: NOT_INSTALLED_MESSAGE };
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
const client = await getWigoloClient();
|
|
163
|
+
const health = await client.health();
|
|
164
|
+
const status = typeof health?.status === "string" ? health.status : "unknown";
|
|
165
|
+
// wigolo reports 200 "ok" when up and 503 with a body when degraded.
|
|
166
|
+
return { available: status === "ok" || status === "healthy", status };
|
|
167
|
+
} catch (error) {
|
|
168
|
+
return {
|
|
169
|
+
available: false,
|
|
170
|
+
status: "unreachable",
|
|
171
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Whether the SDK is importable, without starting a daemon. Never throws. */
|
|
177
|
+
export async function isWigoloInstalled(): Promise<boolean> {
|
|
178
|
+
return (await loadSdk()) !== null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Last recorded failure, for diagnostics. */
|
|
182
|
+
export function getWigoloLastError(): string | null {
|
|
183
|
+
return lastError;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Inject a stub daemon client. Test-only. */
|
|
187
|
+
export function __setWigoloClientForTests(client: WigoloClientLike | null): void {
|
|
188
|
+
clientOverride = client;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Reset module state. Test-only. */
|
|
192
|
+
export function __resetWigoloClientForTests(): void {
|
|
193
|
+
clientPromise = null;
|
|
194
|
+
clientOverride = null;
|
|
195
|
+
lastError = null;
|
|
196
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @unipi/web-api — wigolo provider
|
|
3
|
+
*
|
|
4
|
+
* Local-first search and fetch via the wigolo engine. No API key, no cloud,
|
|
5
|
+
* $0 per query — results are produced by 18 direct search-engine adapters with
|
|
6
|
+
* rank fusion and on-device reranking, and pages are fetched through a tiered
|
|
7
|
+
* router that escalates to a headless browser on anti-bot challenges.
|
|
8
|
+
*
|
|
9
|
+
* Ranked 1 for both search and read: it is the preferred default when
|
|
10
|
+
* available. When wigolo is not installed or not initialized, the calls throw
|
|
11
|
+
* a {@link WigoloUnavailableError} and auto-selection falls through to the
|
|
12
|
+
* next-ranked provider (see `selectProvider` in ../tools.ts).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { WebProvider, SearchResult, ReadResult, ProviderConfig } from "./base.js";
|
|
16
|
+
import { registry } from "./registry.js";
|
|
17
|
+
import { getWigoloClient } from "./wigolo-client.js";
|
|
18
|
+
|
|
19
|
+
/** Default number of results requested from wigolo. */
|
|
20
|
+
const DEFAULT_MAX_RESULTS = 10;
|
|
21
|
+
|
|
22
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
23
|
+
return value !== null && typeof value === "object";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function readString(value: unknown): string {
|
|
27
|
+
return typeof value === "string" ? value : "";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Normalize one wigolo search result.
|
|
32
|
+
*
|
|
33
|
+
* The REST contract types `results` as `unknown[]` because the shape is
|
|
34
|
+
* extensible, so every field is read defensively. wigolo returns an `excerpt`
|
|
35
|
+
* pinned to a byte-exact source span; older/alternate builds use `snippet` or
|
|
36
|
+
* `content`.
|
|
37
|
+
*/
|
|
38
|
+
function toSearchResult(raw: unknown): SearchResult | null {
|
|
39
|
+
if (!isRecord(raw)) return null;
|
|
40
|
+
|
|
41
|
+
const url = readString(raw.url);
|
|
42
|
+
if (!url) return null;
|
|
43
|
+
|
|
44
|
+
const snippet =
|
|
45
|
+
readString(raw.excerpt) ||
|
|
46
|
+
readString(raw.snippet) ||
|
|
47
|
+
readString(raw.content) ||
|
|
48
|
+
readString(raw.description);
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
title: readString(raw.title) || url,
|
|
52
|
+
url,
|
|
53
|
+
snippet,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Surface an in-body error field — a 200 response can still carry one. */
|
|
58
|
+
function assertNoBodyError(body: { error?: string }, action: string): void {
|
|
59
|
+
if (body?.error) {
|
|
60
|
+
throw new Error(`wigolo ${action} failed: ${body.error}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const wigoloProvider: WebProvider = {
|
|
65
|
+
id: "wigolo",
|
|
66
|
+
name: "wigolo (local)",
|
|
67
|
+
capabilities: ["search", "read"],
|
|
68
|
+
requiresApiKey: false,
|
|
69
|
+
ranking: {
|
|
70
|
+
search: 1,
|
|
71
|
+
read: 1,
|
|
72
|
+
summarize: 0,
|
|
73
|
+
},
|
|
74
|
+
config: {},
|
|
75
|
+
|
|
76
|
+
async search(query: string, config?: ProviderConfig): Promise<SearchResult[]> {
|
|
77
|
+
const client = await getWigoloClient();
|
|
78
|
+
|
|
79
|
+
const maxResults =
|
|
80
|
+
typeof config?.maxResults === "number" ? config.maxResults : DEFAULT_MAX_RESULTS;
|
|
81
|
+
|
|
82
|
+
const response = await client.search({
|
|
83
|
+
query,
|
|
84
|
+
max_results: maxResults,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
assertNoBodyError(response, "search");
|
|
88
|
+
|
|
89
|
+
const results = Array.isArray(response.results) ? response.results : [];
|
|
90
|
+
return results
|
|
91
|
+
.map(toSearchResult)
|
|
92
|
+
.filter((result): result is SearchResult => result !== null);
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
async read(url: string, _config?: ProviderConfig): Promise<ReadResult> {
|
|
96
|
+
const client = await getWigoloClient();
|
|
97
|
+
|
|
98
|
+
const response = await client.fetch({ url });
|
|
99
|
+
|
|
100
|
+
assertNoBodyError(response, "fetch");
|
|
101
|
+
|
|
102
|
+
const content = readString(response.markdown);
|
|
103
|
+
if (!content) {
|
|
104
|
+
throw new Error(`wigolo returned no content for ${url}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
url: readString(response.url) || url,
|
|
109
|
+
content,
|
|
110
|
+
contentType: "markdown",
|
|
111
|
+
};
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
registry.register(wigoloProvider);
|
|
116
|
+
|
|
117
|
+
export { wigoloProvider };
|
package/src/settings.ts
CHANGED
|
@@ -66,6 +66,7 @@ const DEFAULT_SMART_FETCH_SETTINGS: SmartFetchSettings = {
|
|
|
66
66
|
/** Default configuration */
|
|
67
67
|
const DEFAULT_CONFIG: WebApiConfig = {
|
|
68
68
|
providers: {
|
|
69
|
+
wigolo: { enabled: true },
|
|
69
70
|
duckduckgo: { enabled: true },
|
|
70
71
|
"jina-search": { enabled: true },
|
|
71
72
|
"jina-reader": { enabled: true },
|
package/src/tools.ts
CHANGED
|
@@ -71,6 +71,26 @@ function selectProvider(
|
|
|
71
71
|
capability: WebCapability,
|
|
72
72
|
sourceRank?: number
|
|
73
73
|
): WebProvider {
|
|
74
|
+
const candidates = selectProviderChain(capability, sourceRank);
|
|
75
|
+
return candidates[0];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Build the ordered list of providers to try for a capability.
|
|
80
|
+
*
|
|
81
|
+
* With an explicit `sourceRank` the user named a provider, so exactly that one
|
|
82
|
+
* is returned — their intent is respected and a failure is reported rather
|
|
83
|
+
* than silently served by someone else.
|
|
84
|
+
*
|
|
85
|
+
* Without one, the full ranked list is returned so a failing provider can fall
|
|
86
|
+
* through to the next. This matters because the rank-1 provider (wigolo) is a
|
|
87
|
+
* local engine that requires a separate `npx wigolo init`: without
|
|
88
|
+
* fallthrough, an enabled-but-uninitialized wigolo would break every web call.
|
|
89
|
+
*/
|
|
90
|
+
export function selectProviderChain(
|
|
91
|
+
capability: WebCapability,
|
|
92
|
+
sourceRank?: number
|
|
93
|
+
): WebProvider[] {
|
|
74
94
|
const available = getAvailableProviders(capability);
|
|
75
95
|
|
|
76
96
|
if (available.length === 0) {
|
|
@@ -79,7 +99,7 @@ function selectProvider(
|
|
|
79
99
|
throw new Error(
|
|
80
100
|
`No ${capability} provider configured.\n` +
|
|
81
101
|
`→ Run /unipi:web-settings to enable providers and add API keys.\n` +
|
|
82
|
-
`→ Free options: DuckDuckGo (search), Jina Reader (read).\n` +
|
|
102
|
+
`→ Free options: wigolo (search + read, local), DuckDuckGo (search), Jina Reader (read).\n` +
|
|
83
103
|
`→ Available providers: ${providerNames}`
|
|
84
104
|
);
|
|
85
105
|
}
|
|
@@ -94,11 +114,42 @@ function selectProvider(
|
|
|
94
114
|
`Available ranks: ${availableRanks}`
|
|
95
115
|
);
|
|
96
116
|
}
|
|
97
|
-
return provider;
|
|
117
|
+
return [provider];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Ranked cheapest/simplest first
|
|
121
|
+
return available;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Run `attempt` against each candidate provider in turn, returning the first
|
|
126
|
+
* success. If every candidate fails, the first error is rethrown with the
|
|
127
|
+
* later failures appended, so the message explains the whole chain rather
|
|
128
|
+
* than only the last hop.
|
|
129
|
+
*/
|
|
130
|
+
export async function withProviderFallthrough<T>(
|
|
131
|
+
providers: WebProvider[],
|
|
132
|
+
attempt: (provider: WebProvider) => Promise<T>
|
|
133
|
+
): Promise<T> {
|
|
134
|
+
const failures: string[] = [];
|
|
135
|
+
|
|
136
|
+
for (const provider of providers) {
|
|
137
|
+
try {
|
|
138
|
+
return await attempt(provider);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
141
|
+
failures.push(`${provider.name}: ${message}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (failures.length === 1) {
|
|
146
|
+
throw new Error(failures[0]);
|
|
98
147
|
}
|
|
99
148
|
|
|
100
|
-
|
|
101
|
-
|
|
149
|
+
throw new Error(
|
|
150
|
+
`All ${failures.length} providers failed:\n` +
|
|
151
|
+
failures.map((f) => `→ ${f}`).join("\n")
|
|
152
|
+
);
|
|
102
153
|
}
|
|
103
154
|
|
|
104
155
|
/**
|
|
@@ -108,16 +159,18 @@ async function executeSearch(
|
|
|
108
159
|
query: string,
|
|
109
160
|
sourceRank?: number
|
|
110
161
|
): Promise<SearchResult[]> {
|
|
111
|
-
const
|
|
162
|
+
const candidates = selectProviderChain("search", sourceRank);
|
|
112
163
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
164
|
+
return withProviderFallthrough(candidates, async (provider) => {
|
|
165
|
+
if (!provider.search) {
|
|
166
|
+
throw new Error(`Provider "${provider.name}" does not support search`);
|
|
167
|
+
}
|
|
116
168
|
|
|
117
|
-
|
|
118
|
-
|
|
169
|
+
const apiKey = provider.requiresApiKey ? getApiKey(provider.id) : undefined;
|
|
170
|
+
const config = { enabled: true, apiKey };
|
|
119
171
|
|
|
120
|
-
|
|
172
|
+
return provider.search(query, config);
|
|
173
|
+
});
|
|
121
174
|
}
|
|
122
175
|
|
|
123
176
|
/**
|
|
@@ -127,16 +180,18 @@ async function executeProviderRead(
|
|
|
127
180
|
url: string,
|
|
128
181
|
sourceRank?: number
|
|
129
182
|
): Promise<ReadResult> {
|
|
130
|
-
const
|
|
183
|
+
const candidates = selectProviderChain("read", sourceRank);
|
|
131
184
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
185
|
+
return withProviderFallthrough(candidates, async (provider) => {
|
|
186
|
+
if (!provider.read) {
|
|
187
|
+
throw new Error(`Provider "${provider.name}" does not support read`);
|
|
188
|
+
}
|
|
135
189
|
|
|
136
|
-
|
|
137
|
-
|
|
190
|
+
const apiKey = provider.requiresApiKey ? getApiKey(provider.id) : undefined;
|
|
191
|
+
const config = { enabled: true, apiKey };
|
|
138
192
|
|
|
139
|
-
|
|
193
|
+
return provider.read(url, config);
|
|
194
|
+
});
|
|
140
195
|
}
|
|
141
196
|
|
|
142
197
|
/**
|
|
@@ -147,16 +202,18 @@ async function executeSummarize(
|
|
|
147
202
|
prompt?: string,
|
|
148
203
|
sourceRank?: number
|
|
149
204
|
): Promise<SummarizeResult> {
|
|
150
|
-
const
|
|
205
|
+
const candidates = selectProviderChain("summarize", sourceRank);
|
|
151
206
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
207
|
+
return withProviderFallthrough(candidates, async (provider) => {
|
|
208
|
+
if (!provider.summarize) {
|
|
209
|
+
throw new Error(`Provider "${provider.name}" does not support summarize`);
|
|
210
|
+
}
|
|
155
211
|
|
|
156
|
-
|
|
157
|
-
|
|
212
|
+
const apiKey = provider.requiresApiKey ? getApiKey(provider.id) : undefined;
|
|
213
|
+
const config = { enabled: true, apiKey };
|
|
158
214
|
|
|
159
|
-
|
|
215
|
+
return provider.summarize(url, prompt, config);
|
|
216
|
+
});
|
|
160
217
|
}
|
|
161
218
|
|
|
162
219
|
/**
|
|
@@ -248,24 +305,24 @@ export function registerWebTools(pi: ExtensionAPI): void {
|
|
|
248
305
|
label: "Web Search",
|
|
249
306
|
description:
|
|
250
307
|
"Search the web for information using various providers. " +
|
|
251
|
-
"Lower source = simpler/cheaper providers (DuckDuckGo, Jina Search). " +
|
|
308
|
+
"Lower source = simpler/cheaper providers (wigolo, DuckDuckGo, Jina Search). " +
|
|
252
309
|
"Higher source = more capable providers (SerpAPI, Tavily, Perplexity).",
|
|
253
310
|
promptSnippet: "Search the web for information.",
|
|
254
311
|
promptGuidelines: [
|
|
255
312
|
"Use web_search to find information on the web.",
|
|
256
|
-
"Omit source for auto-selection (cheapest available).",
|
|
257
|
-
"Specify source number for specific provider (1=
|
|
258
|
-
"Quick facts: source 1-
|
|
313
|
+
"Omit source for auto-selection (cheapest available, falling through on failure).",
|
|
314
|
+
"Specify source number for specific provider (1=wigolo, 2=DuckDuckGo, 3=Jina, 4=SerpAPI, 5=Tavily, 6=Perplexity).",
|
|
315
|
+
"Quick facts: source 1-3. Research: source 4-6.",
|
|
259
316
|
],
|
|
260
317
|
parameters: Type.Object({
|
|
261
318
|
query: Type.String({ description: "Search query string" }),
|
|
262
319
|
source: Type.Optional(
|
|
263
320
|
Type.Number({
|
|
264
321
|
description:
|
|
265
|
-
"Provider selection (1=
|
|
322
|
+
"Provider selection (1=wigolo, 2=DuckDuckGo, 3=Jina Search, 4=SerpAPI, 5=Tavily, 6=Perplexity). " +
|
|
266
323
|
"Omit for auto-selection.",
|
|
267
324
|
minimum: 1,
|
|
268
|
-
maximum:
|
|
325
|
+
maximum: 6,
|
|
269
326
|
})
|
|
270
327
|
),
|
|
271
328
|
}),
|
|
@@ -321,7 +378,7 @@ export function registerWebTools(pi: ExtensionAPI): void {
|
|
|
321
378
|
"Use multi_web_content_read to extract content from web pages.",
|
|
322
379
|
"Pass a single URL string or an array of URLs for batch reading.",
|
|
323
380
|
"Default source (0 or omitted) uses the local smart-fetch engine — free, no API key.",
|
|
324
|
-
"source 1-
|
|
381
|
+
"source 1-4 uses provider fallbacks: wigolo, Jina Reader, Firecrawl, Perplexity.",
|
|
325
382
|
"Batch mode: pass an array of URLs, returns results for each.",
|
|
326
383
|
],
|
|
327
384
|
parameters: Type.Object({
|
|
@@ -332,10 +389,10 @@ export function registerWebTools(pi: ExtensionAPI): void {
|
|
|
332
389
|
source: Type.Optional(
|
|
333
390
|
Type.Number({
|
|
334
391
|
description:
|
|
335
|
-
"Provider selection (0=smart-fetch engine, 1=Jina Reader,
|
|
392
|
+
"Provider selection (0=smart-fetch engine, 1=wigolo, 2=Jina Reader, 3=Firecrawl, 4=Perplexity). " +
|
|
336
393
|
"Default is 0 (smart-fetch).",
|
|
337
394
|
minimum: 0,
|
|
338
|
-
maximum:
|
|
395
|
+
maximum: 4,
|
|
339
396
|
})
|
|
340
397
|
),
|
|
341
398
|
browser: Type.Optional(
|
|
@@ -556,10 +613,10 @@ export function registerWebTools(pi: ExtensionAPI): void {
|
|
|
556
613
|
source: Type.Optional(
|
|
557
614
|
Type.Number({
|
|
558
615
|
description:
|
|
559
|
-
"Provider selection for content fetch (1=
|
|
616
|
+
"Provider selection for content fetch (1=Perplexity, 2=LLM summarize). " +
|
|
560
617
|
"Omit for auto-selection.",
|
|
561
618
|
minimum: 1,
|
|
562
|
-
maximum:
|
|
619
|
+
maximum: 2,
|
|
563
620
|
})
|
|
564
621
|
),
|
|
565
622
|
}),
|