@pi-unipi/unipi 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.
Files changed (82) hide show
  1. package/README.md +6 -2
  2. package/package.json +24 -23
  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 +3 -3
  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 +4 -4
  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 +3 -3
  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 +3 -2
  35. package/packages/input-shortcuts/package.json +3 -3
  36. package/packages/kanboard/package.json +2 -2
  37. package/packages/mcp/package.json +2 -2
  38. package/packages/mcp/src/tui/add-overlay.ts +10 -7
  39. package/packages/mcp/src/tui/settings-overlay.ts +10 -7
  40. package/packages/memory/package.json +3 -3
  41. package/packages/milestone/package.json +2 -2
  42. package/packages/notify/README.md +4 -1
  43. package/packages/notify/events.ts +130 -81
  44. package/packages/notify/package.json +3 -3
  45. package/packages/notify/permission-prompt-message.ts +95 -0
  46. package/packages/notify/settings.ts +2 -0
  47. package/packages/notify/skills/configure-notify/SKILL.md +31 -2
  48. package/packages/notify/src/__tests__/event-bus.test.ts +3 -2
  49. package/packages/notify/src/__tests__/permission-prompt-message.test.ts +172 -0
  50. package/packages/notify/tui/gotify-setup.ts +2 -1
  51. package/packages/notify/tui/ntfy-setup.ts +2 -1
  52. package/packages/notify/tui/recap-model-selector.ts +2 -2
  53. package/packages/notify/tui/settings-overlay.ts +2 -1
  54. package/packages/notify/tui/telegram-setup.ts +2 -1
  55. package/packages/ralph/package.json +3 -3
  56. package/packages/subagents/package.json +5 -5
  57. package/packages/subagents/src/__tests__/shutdown-stale-ctx.test.ts +185 -0
  58. package/packages/subagents/src/index.ts +52 -10
  59. package/packages/unipi/index.ts +2 -0
  60. package/packages/updater/package.json +3 -3
  61. package/packages/updater/src/tui/changelog-overlay.ts +2 -2
  62. package/packages/updater/src/tui/readme-overlay.ts +2 -1
  63. package/packages/updater/src/tui/update-overlay.ts +2 -1
  64. package/packages/utility/package.json +2 -2
  65. package/packages/utility/src/tui/badge-settings-tui.ts +2 -2
  66. package/packages/utility/src/tui/util-settings-tui.ts +2 -2
  67. package/packages/web-api/README.md +28 -11
  68. package/packages/web-api/package.json +8 -2
  69. package/packages/web-api/skills/web/SKILL.md +36 -9
  70. package/packages/web-api/src/index.ts +15 -0
  71. package/packages/web-api/src/providers/duckduckgo.ts +71 -27
  72. package/packages/web-api/src/providers/firecrawl.ts +1 -1
  73. package/packages/web-api/src/providers/jina-reader.ts +1 -1
  74. package/packages/web-api/src/providers/jina-search.ts +1 -1
  75. package/packages/web-api/src/providers/perplexity.ts +2 -2
  76. package/packages/web-api/src/providers/serpapi.ts +1 -1
  77. package/packages/web-api/src/providers/tavily.ts +1 -1
  78. package/packages/web-api/src/providers/wigolo-client.ts +196 -0
  79. package/packages/web-api/src/providers/wigolo.ts +117 -0
  80. package/packages/web-api/src/settings.ts +1 -0
  81. package/packages/web-api/src/tools.ts +93 -36
  82. package/packages/workflow/package.json +2 -2
@@ -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 };
@@ -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 },
@@ -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
- // Return lowest-ranked (cheapest/simplest) available provider
101
- return available[0];
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 provider = selectProvider("search", sourceRank);
162
+ const candidates = selectProviderChain("search", sourceRank);
112
163
 
113
- if (!provider.search) {
114
- throw new Error(`Provider "${provider.name}" does not support search`);
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
- const apiKey = provider.requiresApiKey ? getApiKey(provider.id) : undefined;
118
- const config = { enabled: true, apiKey };
169
+ const apiKey = provider.requiresApiKey ? getApiKey(provider.id) : undefined;
170
+ const config = { enabled: true, apiKey };
119
171
 
120
- return provider.search(query, config);
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 provider = selectProvider("read", sourceRank);
183
+ const candidates = selectProviderChain("read", sourceRank);
131
184
 
132
- if (!provider.read) {
133
- throw new Error(`Provider "${provider.name}" does not support read`);
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
- const apiKey = provider.requiresApiKey ? getApiKey(provider.id) : undefined;
137
- const config = { enabled: true, apiKey };
190
+ const apiKey = provider.requiresApiKey ? getApiKey(provider.id) : undefined;
191
+ const config = { enabled: true, apiKey };
138
192
 
139
- return provider.read(url, config);
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 provider = selectProvider("summarize", sourceRank);
205
+ const candidates = selectProviderChain("summarize", sourceRank);
151
206
 
152
- if (!provider.summarize) {
153
- throw new Error(`Provider "${provider.name}" does not support summarize`);
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
- const apiKey = provider.requiresApiKey ? getApiKey(provider.id) : undefined;
157
- const config = { enabled: true, apiKey };
212
+ const apiKey = provider.requiresApiKey ? getApiKey(provider.id) : undefined;
213
+ const config = { enabled: true, apiKey };
158
214
 
159
- return provider.summarize(url, prompt, config);
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=DuckDuckGo, 2=Jina, 3=SerpAPI, 4=Tavily, 5=Perplexity).",
258
- "Quick facts: source 1-2. Research: source 3-5.",
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=DuckDuckGo, 2=Jina Search, 3=SerpAPI, 4=Tavily, 5=Perplexity). " +
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: 5,
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-3 uses provider fallbacks: Jina Reader, Firecrawl, Perplexity.",
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, 2=Firecrawl, 3=Perplexity). " +
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: 3,
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=Jina Reader, 2=Firecrawl, 3=Perplexity). " +
616
+ "Provider selection for content fetch (1=Perplexity, 2=LLM summarize). " +
560
617
  "Omit for auto-selection.",
561
618
  minimum: 1,
562
- maximum: 3,
619
+ maximum: 2,
563
620
  })
564
621
  ),
565
622
  }),
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/workflow",
3
- "version": "2.1.2",
3
+ "version": "2.2.0",
4
4
  "description": "Structured development workflow commands for Pi coding agent",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -26,7 +26,7 @@
26
26
  "access": "public"
27
27
  },
28
28
  "dependencies": {
29
- "@pi-unipi/core": "2.1.2"
29
+ "@pi-unipi/core": "2.2.0"
30
30
  },
31
31
  "peerDependencies": {
32
32
  "@earendil-works/pi-ai": "^0.80.0",