pi-ui-extend 0.1.72 → 0.1.77
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 +45 -3
- package/dist/app/cli/install.d.ts +20 -2
- package/dist/app/cli/install.js +128 -13
- package/dist/app/cli/startup-checks.d.ts +1 -0
- package/dist/app/cli/startup-checks.js +16 -0
- package/dist/app/commands/command-model-actions.js +4 -4
- package/dist/app/input/autocomplete-controller.js +1 -1
- package/dist/app/input/prompt-enhancer-controller.js +1 -1
- package/dist/app/runtime.d.ts +3 -1
- package/dist/app/runtime.js +24 -7
- package/external/pi-tools-suite/README.md +41 -7
- package/external/pi-tools-suite/package.json +3 -3
- package/external/pi-tools-suite/src/antigravity-auth/auth-store.ts +20 -6
- package/external/pi-tools-suite/src/opencode-import/commands.ts +8 -5
- package/external/pi-tools-suite/src/opencode-import/importer.ts +63 -22
- package/external/pi-tools-suite/src/opencode-import/index.ts +14 -3
- package/external/pi-tools-suite/src/tool-descriptions.ts +4 -4
- package/external/pi-tools-suite/src/web-search/index.ts +394 -29
- package/package.json +4 -4
- package/skills/context7/SKILL.md +10 -0
- package/skills/context7/scripts/context7.sh +3 -2
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
2
5
|
|
|
3
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
4
7
|
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateHead } from "@earendil-works/pi-coding-agent";
|
|
5
8
|
import { Type } from "typebox";
|
|
6
9
|
|
|
@@ -25,6 +28,22 @@ interface FetchResponse {
|
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
type Operation = "Search" | "Fetch";
|
|
31
|
+
type Provider = "ollama" | "tavily";
|
|
32
|
+
|
|
33
|
+
interface ProviderResponse<T> {
|
|
34
|
+
data: T;
|
|
35
|
+
provider: Provider;
|
|
36
|
+
host: string;
|
|
37
|
+
fallbackFrom?: {
|
|
38
|
+
provider: "ollama";
|
|
39
|
+
error: string;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface OllamaTarget {
|
|
44
|
+
host: string;
|
|
45
|
+
apiKey?: string;
|
|
46
|
+
}
|
|
28
47
|
|
|
29
48
|
class OllamaEndpointUnavailableError extends Error {
|
|
30
49
|
constructor(message: string, readonly status: number) {
|
|
@@ -34,6 +53,14 @@ class OllamaEndpointUnavailableError extends Error {
|
|
|
34
53
|
}
|
|
35
54
|
|
|
36
55
|
const DEFAULT_OLLAMA_HOST = "http://localhost:11434";
|
|
56
|
+
const OLLAMA_CLOUD_HOST = "https://ollama.com";
|
|
57
|
+
const OLLAMA_API_KEY_ENV = "OLLAMA_API_KEY";
|
|
58
|
+
const OLLAMA_API_KEYS_URL = "https://ollama.com/settings/keys";
|
|
59
|
+
const TAVILY_API_HOST = "https://api.tavily.com";
|
|
60
|
+
const TAVILY_API_KEY_ENV = "TAVILY_API_KEY";
|
|
61
|
+
const TAVILY_API_KEYS_URL = "https://app.tavily.com/home";
|
|
62
|
+
const CREDENTIAL_PATH_ENV = "PI_TOOLS_SUITE_WEB_CREDENTIALS_PATH";
|
|
63
|
+
const TAVILY_MAX_SEARCH_RESULTS = 20;
|
|
37
64
|
const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
|
38
65
|
const MAX_REQUEST_TIMEOUT_MS = 120_000;
|
|
39
66
|
const REQUEST_TIMEOUT_ENV = "PI_WEB_SEARCH_TIMEOUT_MS";
|
|
@@ -48,8 +75,71 @@ function normalizeOllamaHost(host: string | undefined): string {
|
|
|
48
75
|
return /^https?:\/\//i.test(trimmed) ? trimmed.replace(/\/+$/, "") : `http://${trimmed.replace(/\/+$/, "")}`;
|
|
49
76
|
}
|
|
50
77
|
|
|
51
|
-
|
|
52
|
-
|
|
78
|
+
interface StoredCredentials {
|
|
79
|
+
ollama?: string;
|
|
80
|
+
tavily?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type StoredCredentialName = keyof StoredCredentials;
|
|
84
|
+
|
|
85
|
+
function credentialPath(): string {
|
|
86
|
+
return process.env[CREDENTIAL_PATH_ENV]?.trim() || join(homedir(), ".config", "pi", "pi-tools-suite-credentials.json");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function readStoredCredentials(): StoredCredentials {
|
|
90
|
+
try {
|
|
91
|
+
const value = JSON.parse(readFileSync(credentialPath(), "utf8")) as unknown;
|
|
92
|
+
if (!isRecord(value)) return {};
|
|
93
|
+
return {
|
|
94
|
+
ollama: optionalString(value.ollama)?.trim() || undefined,
|
|
95
|
+
tavily: optionalString(value.tavily)?.trim() || undefined,
|
|
96
|
+
};
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function writeStoredCredentials(credentials: StoredCredentials): void {
|
|
104
|
+
const path = credentialPath();
|
|
105
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
106
|
+
const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
writeFileSync(tempPath, `${JSON.stringify(credentials, null, 2)}\n`, { encoding: "utf8", flag: "wx", mode: 0o600 });
|
|
110
|
+
renameSync(tempPath, path);
|
|
111
|
+
chmodSync(path, 0o600);
|
|
112
|
+
} finally {
|
|
113
|
+
if (existsSync(tempPath)) unlinkSync(tempPath);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function updateStoredCredential(name: StoredCredentialName, apiKey: string | undefined): void {
|
|
118
|
+
const credentials = readStoredCredentials();
|
|
119
|
+
if (apiKey) credentials[name] = apiKey;
|
|
120
|
+
else delete credentials[name];
|
|
121
|
+
writeStoredCredentials(credentials);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function resolveApiKey(envName: string, credentialName: StoredCredentialName): string | undefined {
|
|
125
|
+
const envKey = process.env[envName]?.trim();
|
|
126
|
+
if (envKey) return envKey;
|
|
127
|
+
return readStoredCredentials()[credentialName];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function resolveOllamaTarget(): OllamaTarget {
|
|
131
|
+
const apiKey = resolveApiKey(OLLAMA_API_KEY_ENV, "ollama");
|
|
132
|
+
const configuredHost = process.env.OLLAMA_HOST?.trim();
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
host: normalizeOllamaHost(configuredHost || (apiKey ? OLLAMA_CLOUD_HOST : undefined)),
|
|
136
|
+
apiKey,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function ollamaRequestUrl(target: OllamaTarget, endpoint: "web_search" | "web_fetch"): string {
|
|
141
|
+
const apiPath = target.host === OLLAMA_CLOUD_HOST ? `/api/${endpoint}` : `/api/experimental/${endpoint}`;
|
|
142
|
+
return `${target.host}${apiPath}`;
|
|
53
143
|
}
|
|
54
144
|
|
|
55
145
|
function parseTimeoutMs(value: unknown, source: string): number {
|
|
@@ -225,6 +315,10 @@ function operationNoun(operation: Operation): "search" | "fetch" {
|
|
|
225
315
|
return operation === "Search" ? "search" : "fetch";
|
|
226
316
|
}
|
|
227
317
|
|
|
318
|
+
function tavilyEndpoint(operation: Operation): "search" | "extract" {
|
|
319
|
+
return operation === "Search" ? "search" : "extract";
|
|
320
|
+
}
|
|
321
|
+
|
|
228
322
|
function formatErrorBody(body: string): string {
|
|
229
323
|
const normalized = body.trim().replace(/\s+/g, " ");
|
|
230
324
|
if (!normalized) return "";
|
|
@@ -237,7 +331,10 @@ function createHttpError(response: Response, operation: Operation, host: string,
|
|
|
237
331
|
const withBody = bodySuffix ? ` Response: ${bodySuffix}` : "";
|
|
238
332
|
|
|
239
333
|
if (response.status === 401) {
|
|
240
|
-
return new Error(
|
|
334
|
+
return new Error(
|
|
335
|
+
`Unauthorized by Ollama ${apiName} API at ${host}. ` +
|
|
336
|
+
`Run \`ollama signin\` for local Ollama, or update ${OLLAMA_API_KEY_ENV} through /web-credentials or the launch environment.`,
|
|
337
|
+
);
|
|
241
338
|
}
|
|
242
339
|
|
|
243
340
|
if (response.status === 403) {
|
|
@@ -333,13 +430,17 @@ function normalizeOllamaError(error: unknown, operation: Operation, host: string
|
|
|
333
430
|
return error instanceof Error ? error : new Error(String(error));
|
|
334
431
|
}
|
|
335
432
|
|
|
336
|
-
async function postOllamaJson<T>(
|
|
433
|
+
async function postOllamaJson<T>(target: OllamaTarget, endpoint: "web_search" | "web_fetch", body: Record<string, unknown>, operation: Operation, signal: AbortSignal | undefined, timeoutMs: number, retryEndpointUnavailable = true): Promise<T> {
|
|
434
|
+
const { host } = target;
|
|
337
435
|
const requestSignal = createRequestSignal(signal, timeoutMs);
|
|
338
436
|
|
|
339
437
|
try {
|
|
340
|
-
const response = await fetch(
|
|
438
|
+
const response = await fetch(ollamaRequestUrl(target, endpoint), {
|
|
341
439
|
method: "POST",
|
|
342
|
-
headers: {
|
|
440
|
+
headers: {
|
|
441
|
+
...(target.apiKey ? { Authorization: `Bearer ${target.apiKey}` } : {}),
|
|
442
|
+
"Content-Type": "application/json",
|
|
443
|
+
},
|
|
343
444
|
body: JSON.stringify(body),
|
|
344
445
|
signal: requestSignal.signal,
|
|
345
446
|
});
|
|
@@ -349,12 +450,12 @@ async function postOllamaJson<T>(host: string, endpoint: "web_search" | "web_fet
|
|
|
349
450
|
if (isConnectionRefused(error) && isLoopbackHost(host)) {
|
|
350
451
|
requestSignal.cleanup();
|
|
351
452
|
await ensureOllamaRunning(host, timeoutMs, signal);
|
|
352
|
-
return waitForEndpointReady(() => postOllamaJson<T>(
|
|
453
|
+
return waitForEndpointReady(() => postOllamaJson<T>(target, endpoint, body, operation, signal, timeoutMs, false), host, operation, timeoutMs, signal);
|
|
353
454
|
}
|
|
354
455
|
|
|
355
456
|
if (retryEndpointUnavailable && isEndpointUnavailable(error) && isLoopbackHost(host)) {
|
|
356
457
|
requestSignal.cleanup();
|
|
357
|
-
return waitForEndpointReady(() => postOllamaJson<T>(
|
|
458
|
+
return waitForEndpointReady(() => postOllamaJson<T>(target, endpoint, body, operation, signal, timeoutMs, false), host, operation, timeoutMs, signal);
|
|
358
459
|
}
|
|
359
460
|
|
|
360
461
|
throw normalizeOllamaError(error, operation, host, timeoutMs, requestSignal.timedOut(), signal);
|
|
@@ -363,6 +464,85 @@ async function postOllamaJson<T>(host: string, endpoint: "web_search" | "web_fet
|
|
|
363
464
|
}
|
|
364
465
|
}
|
|
365
466
|
|
|
467
|
+
function createTavilyHttpError(response: Response, operation: Operation, body: string): Error {
|
|
468
|
+
const endpoint = tavilyEndpoint(operation);
|
|
469
|
+
const bodySuffix = formatErrorBody(body);
|
|
470
|
+
const withBody = bodySuffix ? ` Response: ${bodySuffix}` : "";
|
|
471
|
+
|
|
472
|
+
if (response.status === 401) {
|
|
473
|
+
return new Error(`Tavily ${endpoint} API rejected ${TAVILY_API_KEY_ENV} (HTTP 401). Check that the API key is valid.`);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (response.status === 429 || response.status === 432 || response.status === 433) {
|
|
477
|
+
return new Error(`Tavily ${endpoint} API limit was exceeded (HTTP ${response.status}).${withBody}`);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return new Error(`Tavily ${endpoint} API returned HTTP ${response.status}.${withBody || ` ${response.statusText}`}`);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function normalizeTavilyError(error: unknown, operation: Operation, timeoutMs: number, timedOut: boolean, parentSignal: AbortSignal | undefined): Error {
|
|
484
|
+
const endpoint = tavilyEndpoint(operation);
|
|
485
|
+
|
|
486
|
+
if (timedOut) {
|
|
487
|
+
return new Error(
|
|
488
|
+
`Tavily ${endpoint} request timed out after ${timeoutMs}ms. ` +
|
|
489
|
+
`Increase timeout_ms or ${REQUEST_TIMEOUT_ENV} if the fallback endpoint is slow.`,
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (isAbortError(error) && parentSignal?.aborted) {
|
|
494
|
+
return new Error(`Tavily ${endpoint} request was cancelled.`);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (errorIncludes(error, "ENOTFOUND", "EAI_AGAIN")) {
|
|
498
|
+
return new Error(`Could not resolve Tavily host ${TAVILY_API_HOST}.`);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (errorIncludes(error, "ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "UND_ERR_SOCKET", "UND_ERR_CONNECT_TIMEOUT")) {
|
|
502
|
+
const details = collectErrorText(error);
|
|
503
|
+
return new Error(`Connection to Tavily failed while calling ${endpoint}.${details ? ` ${details}` : ""}`);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
if (error instanceof TypeError && error.message.toLowerCase().includes("fetch")) {
|
|
507
|
+
return new Error(`Request to Tavily failed while calling ${endpoint}: ${error.message}`);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async function postTavilyJson<T>(apiKey: string, operation: Operation, body: Record<string, unknown>, signal: AbortSignal | undefined, timeoutMs: number): Promise<T> {
|
|
514
|
+
const endpoint = tavilyEndpoint(operation);
|
|
515
|
+
const requestSignal = createRequestSignal(signal, timeoutMs);
|
|
516
|
+
|
|
517
|
+
try {
|
|
518
|
+
const response = await fetch(`${TAVILY_API_HOST}/${endpoint}`, {
|
|
519
|
+
method: "POST",
|
|
520
|
+
headers: {
|
|
521
|
+
Authorization: `Bearer ${apiKey}`,
|
|
522
|
+
"Content-Type": "application/json",
|
|
523
|
+
},
|
|
524
|
+
body: JSON.stringify(body),
|
|
525
|
+
signal: requestSignal.signal,
|
|
526
|
+
});
|
|
527
|
+
const responseBody = await response.text().catch(() => "");
|
|
528
|
+
|
|
529
|
+
if (!response.ok) throw createTavilyHttpError(response, operation, responseBody);
|
|
530
|
+
if (!responseBody.trim()) throw new Error(`Tavily ${endpoint} API returned an empty response.`);
|
|
531
|
+
|
|
532
|
+
try {
|
|
533
|
+
return JSON.parse(responseBody) as T;
|
|
534
|
+
} catch (error) {
|
|
535
|
+
const parseMessage = error instanceof Error ? error.message : String(error);
|
|
536
|
+
const bodySuffix = formatErrorBody(responseBody);
|
|
537
|
+
throw new Error(`Tavily ${endpoint} API returned invalid JSON: ${parseMessage}.${bodySuffix ? ` Body: ${bodySuffix}` : ""}`);
|
|
538
|
+
}
|
|
539
|
+
} catch (error) {
|
|
540
|
+
throw normalizeTavilyError(error, operation, timeoutMs, requestSignal.timedOut(), signal);
|
|
541
|
+
} finally {
|
|
542
|
+
requestSignal.cleanup();
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
366
546
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
367
547
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
368
548
|
}
|
|
@@ -371,17 +551,17 @@ function optionalString(value: unknown): string | undefined {
|
|
|
371
551
|
return typeof value === "string" ? value : undefined;
|
|
372
552
|
}
|
|
373
553
|
|
|
374
|
-
function parseSearchResponse(data: unknown): SearchResponse {
|
|
554
|
+
function parseSearchResponse(data: unknown, providerName = "Ollama"): SearchResponse {
|
|
375
555
|
if (!isRecord(data) || !Array.isArray(data.results)) {
|
|
376
|
-
throw new Error(
|
|
556
|
+
throw new Error(`${providerName} web_search API returned an unexpected response: missing results array.`);
|
|
377
557
|
}
|
|
378
558
|
|
|
379
559
|
return {
|
|
380
560
|
results: data.results.map((item, index) => {
|
|
381
|
-
if (!isRecord(item)) throw new Error(
|
|
561
|
+
if (!isRecord(item)) throw new Error(`${providerName} web_search API returned an invalid result at index ${index}.`);
|
|
382
562
|
|
|
383
563
|
const url = optionalString(item.url);
|
|
384
|
-
if (!url) throw new Error(
|
|
564
|
+
if (!url) throw new Error(`${providerName} web_search API returned an invalid result at index ${index}: missing url.`);
|
|
385
565
|
|
|
386
566
|
return {
|
|
387
567
|
title: optionalString(item.title) || "Untitled",
|
|
@@ -392,6 +572,156 @@ function parseSearchResponse(data: unknown): SearchResponse {
|
|
|
392
572
|
};
|
|
393
573
|
}
|
|
394
574
|
|
|
575
|
+
function parseTavilyExtractResponse(data: unknown, requestedUrl: string): FetchResponse {
|
|
576
|
+
if (!isRecord(data) || !Array.isArray(data.results)) {
|
|
577
|
+
throw new Error("Tavily extract API returned an unexpected response: missing results array.");
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const result = data.results.find((item) => isRecord(item) && item.url === requestedUrl) ?? data.results[0];
|
|
581
|
+
if (!isRecord(result) || typeof result.raw_content !== "string") {
|
|
582
|
+
const failedResult = Array.isArray(data.failed_results)
|
|
583
|
+
? data.failed_results.find((item) => isRecord(item) && item.url === requestedUrl) ?? data.failed_results[0]
|
|
584
|
+
: undefined;
|
|
585
|
+
const failedMessage = isRecord(failedResult) ? optionalString(failedResult.error) : undefined;
|
|
586
|
+
throw new Error(`Tavily extract API did not return content for ${requestedUrl}.${failedMessage ? ` ${failedMessage}` : ""}`);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
return {
|
|
590
|
+
title: requestedUrl,
|
|
591
|
+
content: result.raw_content,
|
|
592
|
+
links: [],
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function errorMessage(error: unknown): string {
|
|
597
|
+
return error instanceof Error ? error.message : String(error);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
async function withTavilyFallback<T>(
|
|
601
|
+
operation: Operation,
|
|
602
|
+
signal: AbortSignal | undefined,
|
|
603
|
+
ollamaHost: string,
|
|
604
|
+
tavilyApiKey: string | undefined,
|
|
605
|
+
ollamaRequest: () => Promise<T>,
|
|
606
|
+
tavilyRequest: (apiKey: string) => Promise<T>,
|
|
607
|
+
): Promise<ProviderResponse<T>> {
|
|
608
|
+
try {
|
|
609
|
+
return { data: await ollamaRequest(), provider: "ollama", host: ollamaHost };
|
|
610
|
+
} catch (ollamaError) {
|
|
611
|
+
if (signal?.aborted) throw ollamaError;
|
|
612
|
+
|
|
613
|
+
if (!tavilyApiKey) throw ollamaError;
|
|
614
|
+
|
|
615
|
+
try {
|
|
616
|
+
return {
|
|
617
|
+
data: await tavilyRequest(tavilyApiKey),
|
|
618
|
+
provider: "tavily",
|
|
619
|
+
host: TAVILY_API_HOST,
|
|
620
|
+
fallbackFrom: { provider: "ollama", error: errorMessage(ollamaError) },
|
|
621
|
+
};
|
|
622
|
+
} catch (tavilyError) {
|
|
623
|
+
if (signal?.aborted) throw tavilyError;
|
|
624
|
+
|
|
625
|
+
throw new Error(
|
|
626
|
+
`Ollama ${endpointName(operation)} failed: ${errorMessage(ollamaError)} ` +
|
|
627
|
+
`Tavily ${tavilyEndpoint(operation)} fallback also failed: ${errorMessage(tavilyError)}`,
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
type CredentialChoice =
|
|
634
|
+
| "Set Ollama API key"
|
|
635
|
+
| "Set Tavily API key"
|
|
636
|
+
| "Show credential status"
|
|
637
|
+
| "Clear stored Ollama key"
|
|
638
|
+
| "Clear stored Tavily key";
|
|
639
|
+
|
|
640
|
+
const CREDENTIAL_CHOICES: CredentialChoice[] = [
|
|
641
|
+
"Set Ollama API key",
|
|
642
|
+
"Set Tavily API key",
|
|
643
|
+
"Show credential status",
|
|
644
|
+
"Clear stored Ollama key",
|
|
645
|
+
"Clear stored Tavily key",
|
|
646
|
+
];
|
|
647
|
+
|
|
648
|
+
function storedCredentialConfigured(name: StoredCredentialName): boolean {
|
|
649
|
+
return Boolean(readStoredCredentials()[name]);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function credentialSource(envName: string, credentialName: StoredCredentialName): string {
|
|
653
|
+
if (process.env[envName]?.trim()) return `environment (${envName})`;
|
|
654
|
+
return storedCredentialConfigured(credentialName) ? "stored in pi-tools-suite credentials" : "not configured";
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function showCredentialStatus(ctx: ExtensionCommandContext): void {
|
|
658
|
+
const ollama = credentialSource(OLLAMA_API_KEY_ENV, "ollama");
|
|
659
|
+
const tavily = credentialSource(TAVILY_API_KEY_ENV, "tavily");
|
|
660
|
+
ctx.ui.notify(`Web credentials\nOllama: ${ollama}\nTavily: ${tavily}`, "info");
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function showCredentialLinks(ctx: ExtensionCommandContext): void {
|
|
664
|
+
ctx.ui.notify(
|
|
665
|
+
`Get API keys\nOllama: ${OLLAMA_API_KEYS_URL}\nTavily: ${TAVILY_API_KEYS_URL}`,
|
|
666
|
+
"info",
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
async function setCredential(
|
|
671
|
+
ctx: ExtensionCommandContext,
|
|
672
|
+
provider: "Ollama" | "Tavily",
|
|
673
|
+
credentialName: StoredCredentialName,
|
|
674
|
+
): Promise<void> {
|
|
675
|
+
const key = (await ctx.ui.input(`${provider} API key`, "Paste the API key; Escape cancels"))?.trim();
|
|
676
|
+
if (!key) {
|
|
677
|
+
ctx.ui.notify(`${provider} credential was not changed.`, "warning");
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
updateStoredCredential(credentialName, key);
|
|
682
|
+
ctx.ui.notify(`${provider} credential saved securely and active for future web tool calls.`, "info");
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
function clearCredential(
|
|
686
|
+
ctx: ExtensionCommandContext,
|
|
687
|
+
provider: "Ollama" | "Tavily",
|
|
688
|
+
credentialName: StoredCredentialName,
|
|
689
|
+
envName: string,
|
|
690
|
+
): void {
|
|
691
|
+
updateStoredCredential(credentialName, undefined);
|
|
692
|
+
const envSuffix = process.env[envName]?.trim() ? ` ${envName} is still set and remains active.` : "";
|
|
693
|
+
ctx.ui.notify(`Stored ${provider} credential removed.${envSuffix}`, "info");
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function registerCredentialCommand(pi: ExtensionAPI): void {
|
|
697
|
+
pi.registerCommand("web-credentials", {
|
|
698
|
+
description: "Configure Ollama and Tavily API keys for web_search/web_fetch",
|
|
699
|
+
handler: async (_args, ctx) => {
|
|
700
|
+
if (!ctx.hasUI) return;
|
|
701
|
+
showCredentialLinks(ctx);
|
|
702
|
+
|
|
703
|
+
const choice = await ctx.ui.select("Web search credentials", CREDENTIAL_CHOICES) as CredentialChoice | undefined;
|
|
704
|
+
switch (choice) {
|
|
705
|
+
case "Set Ollama API key":
|
|
706
|
+
await setCredential(ctx, "Ollama", "ollama");
|
|
707
|
+
break;
|
|
708
|
+
case "Set Tavily API key":
|
|
709
|
+
await setCredential(ctx, "Tavily", "tavily");
|
|
710
|
+
break;
|
|
711
|
+
case "Show credential status":
|
|
712
|
+
showCredentialStatus(ctx);
|
|
713
|
+
break;
|
|
714
|
+
case "Clear stored Ollama key":
|
|
715
|
+
clearCredential(ctx, "Ollama", "ollama", OLLAMA_API_KEY_ENV);
|
|
716
|
+
break;
|
|
717
|
+
case "Clear stored Tavily key":
|
|
718
|
+
clearCredential(ctx, "Tavily", "tavily", TAVILY_API_KEY_ENV);
|
|
719
|
+
break;
|
|
720
|
+
}
|
|
721
|
+
},
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
|
|
395
725
|
function parseFetchResponse(data: unknown): FetchResponse {
|
|
396
726
|
if (!isRecord(data) || typeof data.content !== "string") {
|
|
397
727
|
throw new Error("Ollama web_fetch API returned an unexpected response: missing content string.");
|
|
@@ -464,30 +794,37 @@ function timeoutParameter() {
|
|
|
464
794
|
);
|
|
465
795
|
}
|
|
466
796
|
|
|
467
|
-
function searchResultDetails(
|
|
797
|
+
function searchResultDetails(response: ProviderResponse<SearchResponse>, timeoutMs: number, truncated: boolean) {
|
|
468
798
|
return {
|
|
469
|
-
results: data.results,
|
|
470
|
-
resultCount: data.results.length,
|
|
471
|
-
|
|
799
|
+
results: response.data.results,
|
|
800
|
+
resultCount: response.data.results.length,
|
|
801
|
+
provider: response.provider,
|
|
802
|
+
host: response.host,
|
|
803
|
+
fallbackFrom: response.fallbackFrom,
|
|
472
804
|
timeoutMs,
|
|
473
805
|
truncated,
|
|
474
806
|
};
|
|
475
807
|
}
|
|
476
808
|
|
|
477
|
-
function fetchResultDetails(
|
|
809
|
+
function fetchResultDetails(response: ProviderResponse<FetchResponse>, timeoutMs: number, truncated: boolean) {
|
|
810
|
+
const data = response.data;
|
|
478
811
|
return {
|
|
479
812
|
title: data.title,
|
|
480
813
|
content: data.content,
|
|
481
814
|
contentBytes: contentByteLength(data.content),
|
|
482
815
|
links: data.links ?? [],
|
|
483
816
|
linkCount: data.links?.length ?? 0,
|
|
484
|
-
|
|
817
|
+
provider: response.provider,
|
|
818
|
+
host: response.host,
|
|
819
|
+
fallbackFrom: response.fallbackFrom,
|
|
485
820
|
timeoutMs,
|
|
486
821
|
truncated,
|
|
487
822
|
};
|
|
488
823
|
}
|
|
489
824
|
|
|
490
825
|
export default function webSearch(pi: ExtensionAPI) {
|
|
826
|
+
registerCredentialCommand(pi);
|
|
827
|
+
|
|
491
828
|
pi.registerTool({
|
|
492
829
|
...WEB_SEARCH_TOOL_DESCRIPTIONS.webSearch,
|
|
493
830
|
parameters: Type.Object({
|
|
@@ -497,16 +834,30 @@ export default function webSearch(pi: ExtensionAPI) {
|
|
|
497
834
|
}),
|
|
498
835
|
async execute(_toolCallId, params, signal) {
|
|
499
836
|
const maxResults = params.max_results ?? 5;
|
|
500
|
-
const
|
|
837
|
+
const ollamaTarget = resolveOllamaTarget();
|
|
838
|
+
const tavilyApiKey = resolveApiKey(TAVILY_API_KEY_ENV, "tavily");
|
|
501
839
|
const timeoutMs = resolveRequestTimeoutMs(params.timeout_ms);
|
|
502
840
|
|
|
503
|
-
const
|
|
504
|
-
|
|
505
|
-
|
|
841
|
+
const response = await withTavilyFallback(
|
|
842
|
+
"Search",
|
|
843
|
+
signal,
|
|
844
|
+
ollamaTarget.host,
|
|
845
|
+
tavilyApiKey,
|
|
846
|
+
async () => parseSearchResponse(await postOllamaJson<unknown>(ollamaTarget, "web_search", { query: params.query, max_results: maxResults }, "Search", signal, timeoutMs)),
|
|
847
|
+
async (apiKey) => parseSearchResponse(
|
|
848
|
+
await postTavilyJson<unknown>(apiKey, "Search", {
|
|
849
|
+
query: params.query,
|
|
850
|
+
max_results: Math.max(0, Math.min(TAVILY_MAX_SEARCH_RESULTS, Math.trunc(maxResults))),
|
|
851
|
+
search_depth: "basic",
|
|
852
|
+
}, signal, timeoutMs),
|
|
853
|
+
"Tavily",
|
|
854
|
+
),
|
|
855
|
+
);
|
|
856
|
+
const formatted = truncateForTool(formatSearchResults(response.data.results));
|
|
506
857
|
|
|
507
858
|
return {
|
|
508
859
|
content: [{ type: "text", text: formatted.text }],
|
|
509
|
-
details: searchResultDetails(
|
|
860
|
+
details: searchResultDetails(response, timeoutMs, formatted.truncated),
|
|
510
861
|
};
|
|
511
862
|
},
|
|
512
863
|
});
|
|
@@ -518,16 +869,30 @@ export default function webSearch(pi: ExtensionAPI) {
|
|
|
518
869
|
timeout_ms: timeoutParameter(),
|
|
519
870
|
}),
|
|
520
871
|
async execute(_toolCallId, params, signal) {
|
|
521
|
-
const
|
|
872
|
+
const ollamaTarget = resolveOllamaTarget();
|
|
873
|
+
const tavilyApiKey = resolveApiKey(TAVILY_API_KEY_ENV, "tavily");
|
|
522
874
|
const timeoutMs = resolveRequestTimeoutMs(params.timeout_ms);
|
|
523
875
|
|
|
524
|
-
const
|
|
525
|
-
|
|
526
|
-
|
|
876
|
+
const response = await withTavilyFallback(
|
|
877
|
+
"Fetch",
|
|
878
|
+
signal,
|
|
879
|
+
ollamaTarget.host,
|
|
880
|
+
tavilyApiKey,
|
|
881
|
+
async () => parseFetchResponse(await postOllamaJson<unknown>(ollamaTarget, "web_fetch", { url: params.url }, "Fetch", signal, timeoutMs)),
|
|
882
|
+
async (apiKey) => parseTavilyExtractResponse(
|
|
883
|
+
await postTavilyJson<unknown>(apiKey, "Fetch", {
|
|
884
|
+
urls: [params.url],
|
|
885
|
+
extract_depth: "basic",
|
|
886
|
+
format: "markdown",
|
|
887
|
+
}, signal, timeoutMs),
|
|
888
|
+
params.url,
|
|
889
|
+
),
|
|
890
|
+
);
|
|
891
|
+
const formatted = truncateForTool(formatFetchResult(response.data));
|
|
527
892
|
|
|
528
893
|
return {
|
|
529
894
|
content: [{ type: "text", text: formatted.text }],
|
|
530
|
-
details: fetchResultDetails(
|
|
895
|
+
details: fetchResultDetails(response, timeoutMs, formatted.truncated),
|
|
531
896
|
};
|
|
532
897
|
},
|
|
533
898
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-ui-extend",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.77",
|
|
4
4
|
"description": "Pix: a workspace-first terminal UI for Pi with tabs, readable tool activity, voice input, and bundled agent tools.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"repository": {
|
|
@@ -75,9 +75,9 @@
|
|
|
75
75
|
"prepublishOnly": "npm run check && npm run build:pix && npm run generate-schemas"
|
|
76
76
|
},
|
|
77
77
|
"dependencies": {
|
|
78
|
-
"@earendil-works/pi-ai": "0.
|
|
79
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
80
|
-
"@earendil-works/pi-tui": "0.
|
|
78
|
+
"@earendil-works/pi-ai": "0.82.1",
|
|
79
|
+
"@earendil-works/pi-coding-agent": "0.82.1",
|
|
80
|
+
"@earendil-works/pi-tui": "0.82.1",
|
|
81
81
|
"@mariozechner/clipboard": "^0.3.9",
|
|
82
82
|
"jsonc-parser": "3.3.1",
|
|
83
83
|
"typebox": "1.1.38",
|
package/skills/context7/SKILL.md
CHANGED
|
@@ -9,6 +9,16 @@ This skill fetches current documentation and code examples for any programming l
|
|
|
9
9
|
|
|
10
10
|
Context7 indexes documentation from official sources and provides version-specific, relevant excerpts — much more reliable than relying on potentially outdated training data.
|
|
11
11
|
|
|
12
|
+
## Setup
|
|
13
|
+
|
|
14
|
+
Create a Context7 API key in the Context7 dashboard and export it before using the skill:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
export CONTEXT7_API_KEY="..."
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The script does not contain a fallback key and exits before making a network request when the variable is missing. It also requires `curl` and `jq` on `PATH`.
|
|
21
|
+
|
|
12
22
|
## Two-step workflow
|
|
13
23
|
|
|
14
24
|
### Step 1: Resolve the library ID
|
|
@@ -10,11 +10,12 @@
|
|
|
10
10
|
# 1. resolve — find the Context7 library ID for a package
|
|
11
11
|
# 2. docs — query documentation for a resolved library ID
|
|
12
12
|
#
|
|
13
|
-
# Requires CONTEXT7_API_KEY env var
|
|
13
|
+
# Requires CONTEXT7_API_KEY env var.
|
|
14
14
|
|
|
15
15
|
set -euo pipefail
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
: "${CONTEXT7_API_KEY:?Context7 requires CONTEXT7_API_KEY. Create a key at https://context7.com/dashboard and export it before using this skill.}"
|
|
18
|
+
API_KEY="$CONTEXT7_API_KEY"
|
|
18
19
|
BASE_URL="https://mcp.context7.com/mcp"
|
|
19
20
|
|
|
20
21
|
call_mcp() {
|