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.
@@ -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
- function getOllamaHost(): string {
52
- return normalizeOllamaHost(process.env.OLLAMA_HOST);
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(`Unauthorized by Ollama ${apiName} API at ${host}. Run \`ollama signin\` to authenticate.`);
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>(host: string, endpoint: "web_search" | "web_fetch", body: Record<string, unknown>, operation: Operation, signal: AbortSignal | undefined, timeoutMs: number, retryEndpointUnavailable = true): Promise<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(`${host}/api/experimental/${endpoint}`, {
438
+ const response = await fetch(ollamaRequestUrl(target, endpoint), {
341
439
  method: "POST",
342
- headers: { "Content-Type": "application/json" },
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>(host, endpoint, body, operation, signal, timeoutMs, false), host, operation, timeoutMs, signal);
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>(host, endpoint, body, operation, signal, timeoutMs, false), host, operation, timeoutMs, signal);
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("Ollama web_search API returned an unexpected response: missing results array.");
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(`Ollama web_search API returned an invalid result at index ${index}.`);
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(`Ollama web_search API returned an invalid result at index ${index}: missing url.`);
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(data: SearchResponse, host: string, timeoutMs: number, truncated: boolean) {
797
+ function searchResultDetails(response: ProviderResponse<SearchResponse>, timeoutMs: number, truncated: boolean) {
468
798
  return {
469
- results: data.results,
470
- resultCount: data.results.length,
471
- host,
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(data: FetchResponse, host: string, timeoutMs: number, truncated: boolean) {
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
- host,
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 host = getOllamaHost();
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 rawData = await postOllamaJson<unknown>(host, "web_search", { query: params.query, max_results: maxResults }, "Search", signal, timeoutMs);
504
- const data = parseSearchResponse(rawData);
505
- const formatted = truncateForTool(formatSearchResults(data.results));
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(data, host, timeoutMs, formatted.truncated),
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 host = getOllamaHost();
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 rawData = await postOllamaJson<unknown>(host, "web_fetch", { url: params.url }, "Fetch", signal, timeoutMs);
525
- const data = parseFetchResponse(rawData);
526
- const formatted = truncateForTool(formatFetchResult(data));
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(data, host, timeoutMs, formatted.truncated),
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.72",
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.81.1",
79
- "@earendil-works/pi-coding-agent": "0.81.1",
80
- "@earendil-works/pi-tui": "0.81.1",
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",
@@ -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 (falls back to hardcoded default).
13
+ # Requires CONTEXT7_API_KEY env var.
14
14
 
15
15
  set -euo pipefail
16
16
 
17
- API_KEY="${CONTEXT7_API_KEY:-ctx7sk-d3b58838-f015-48a3-8059-93562f78837d}"
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() {