surgent 0.7.0-alpha.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 (132) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +407 -0
  3. package/bin/surgent.js +211 -0
  4. package/dist/optimizers/LICENSE +21 -0
  5. package/dist/optimizers/index.js +1984 -0
  6. package/dist/optimizers/index.js.map +7 -0
  7. package/dist/optimizers/package.json +31 -0
  8. package/package.json +45 -0
  9. package/src/agent/built-in/documenter.md +58 -0
  10. package/src/agent/built-in/general.md +107 -0
  11. package/src/agent/built-in/planner.md +73 -0
  12. package/src/agent/built-in/scout.md +97 -0
  13. package/src/agent/command.ts +140 -0
  14. package/src/agent/helpers.ts +95 -0
  15. package/src/agent/index.ts +9 -0
  16. package/src/agent/storage.ts +287 -0
  17. package/src/agent/types.ts +28 -0
  18. package/src/checkpoint/git.ts +173 -0
  19. package/src/checkpoint/index.ts +117 -0
  20. package/src/checkpoint/snapshot.ts +28 -0
  21. package/src/checkpoint/stage.ts +59 -0
  22. package/src/checkpoint/store.ts +108 -0
  23. package/src/cleanup/checkpoint.ts +31 -0
  24. package/src/cleanup/helpers.ts +24 -0
  25. package/src/cleanup/index.ts +21 -0
  26. package/src/cleanup/permission.ts +74 -0
  27. package/src/cleanup/subsession.ts +46 -0
  28. package/src/commands/helpers.ts +217 -0
  29. package/src/commands/index.ts +79 -0
  30. package/src/commands/render.ts +95 -0
  31. package/src/commands/types.ts +11 -0
  32. package/src/mcp-client/call-tool.ts +143 -0
  33. package/src/mcp-client/client.ts +90 -0
  34. package/src/mcp-client/command.ts +257 -0
  35. package/src/mcp-client/helpers.ts +153 -0
  36. package/src/mcp-client/index.ts +21 -0
  37. package/src/mcp-client/list-tools.ts +84 -0
  38. package/src/mcp-client/storage.ts +190 -0
  39. package/src/mcp-client/types.ts +34 -0
  40. package/src/mcp-client/validation.ts +115 -0
  41. package/src/optimizers/compactor/bash.ts +159 -0
  42. package/src/optimizers/compactor/grep.ts +141 -0
  43. package/src/optimizers/compactor/index.ts +132 -0
  44. package/src/optimizers/deduplicator/helpers.ts +75 -0
  45. package/src/optimizers/deduplicator/index.ts +23 -0
  46. package/src/optimizers/deduplicator/resources.ts +77 -0
  47. package/src/optimizers/deduplicator/state.ts +119 -0
  48. package/src/optimizers/deduplicator/types.ts +14 -0
  49. package/src/optimizers/entries.ts +104 -0
  50. package/src/optimizers/index.ts +17 -0
  51. package/src/optimizers/inspector/helpers.ts +60 -0
  52. package/src/optimizers/inspector/index.ts +89 -0
  53. package/src/optimizers/inspector/inspect.ts +88 -0
  54. package/src/optimizers/inspector/types.ts +7 -0
  55. package/src/optimizers/languages/go.ts +79 -0
  56. package/src/optimizers/languages/grammar.ts +200 -0
  57. package/src/optimizers/languages/index.ts +75 -0
  58. package/src/optimizers/languages/java.ts +64 -0
  59. package/src/optimizers/languages/python.ts +63 -0
  60. package/src/optimizers/languages/rust.ts +71 -0
  61. package/src/optimizers/languages/symbols.ts +95 -0
  62. package/src/optimizers/languages/tree-sitter-languages.d.ts +23 -0
  63. package/src/optimizers/languages/types.ts +134 -0
  64. package/src/optimizers/languages/typescript.ts +116 -0
  65. package/src/optimizers/mapper/files.ts +94 -0
  66. package/src/optimizers/mapper/index.ts +133 -0
  67. package/src/optimizers/mapper/types.ts +6 -0
  68. package/src/optimizers/pruner/cleanup.ts +121 -0
  69. package/src/optimizers/pruner/context.ts +46 -0
  70. package/src/optimizers/pruner/index.ts +45 -0
  71. package/src/optimizers/pruner/session.ts +34 -0
  72. package/src/optimizers/pruner/types.ts +18 -0
  73. package/src/permission/bash.ts +124 -0
  74. package/src/permission/command.ts +111 -0
  75. package/src/permission/components/prompt.ts +255 -0
  76. package/src/permission/components/rules-list.ts +342 -0
  77. package/src/permission/constants.ts +48 -0
  78. package/src/permission/helpers.ts +156 -0
  79. package/src/permission/index.ts +134 -0
  80. package/src/permission/pattern.ts +51 -0
  81. package/src/permission/piignore.ts +148 -0
  82. package/src/permission/precedence.ts +54 -0
  83. package/src/permission/resolution.ts +116 -0
  84. package/src/permission/storage.ts +142 -0
  85. package/src/permission/types.ts +57 -0
  86. package/src/questionnaire/component.ts +357 -0
  87. package/src/questionnaire/helpers.ts +220 -0
  88. package/src/questionnaire/index.ts +67 -0
  89. package/src/questionnaire/schemas.ts +50 -0
  90. package/src/questionnaire/types.ts +47 -0
  91. package/src/redactor/index.ts +34 -0
  92. package/src/redactor/patterns.ts +234 -0
  93. package/src/redactor/secrets.ts +113 -0
  94. package/src/subagent/helpers.ts +93 -0
  95. package/src/subagent/index.ts +81 -0
  96. package/src/subagent/storage.ts +100 -0
  97. package/src/subagent/subsession.ts +266 -0
  98. package/src/subagent/types.ts +83 -0
  99. package/src/subagent/validation.ts +100 -0
  100. package/src/ui/components/action-select-list.ts +165 -0
  101. package/src/ui/components/bash-mode.ts +281 -0
  102. package/src/ui/components/extended-select-list.ts +166 -0
  103. package/src/ui/components/form-field.ts +184 -0
  104. package/src/ui/components/form.ts +179 -0
  105. package/src/ui/components/frame.ts +60 -0
  106. package/src/ui/components/input-mode-indicator.ts +64 -0
  107. package/src/ui/components/keybound.ts +150 -0
  108. package/src/ui/components/lines.ts +27 -0
  109. package/src/ui/components/placeholder-input.ts +59 -0
  110. package/src/ui/components/scoped-input.ts +78 -0
  111. package/src/ui/components/scrollable-view.ts +155 -0
  112. package/src/ui/index.ts +40 -0
  113. package/src/utils.ts +206 -0
  114. package/src/web-tools/index.ts +15 -0
  115. package/src/web-tools/providers/brave.ts +55 -0
  116. package/src/web-tools/providers/firecrawl.ts +66 -0
  117. package/src/web-tools/providers/index.ts +50 -0
  118. package/src/web-tools/providers/jina.ts +48 -0
  119. package/src/web-tools/providers/native.ts +57 -0
  120. package/src/web-tools/providers/tavily.ts +56 -0
  121. package/src/web-tools/settings.ts +15 -0
  122. package/src/web-tools/web-fetch/helpers.ts +66 -0
  123. package/src/web-tools/web-fetch/index.ts +91 -0
  124. package/src/web-tools/web-fetch/parser.ts +51 -0
  125. package/src/web-tools/web-fetch/storage.ts +65 -0
  126. package/src/web-tools/web-fetch/types.ts +8 -0
  127. package/src/web-tools/web-login/helpers.ts +79 -0
  128. package/src/web-tools/web-login/index.ts +100 -0
  129. package/src/web-tools/web-login/types.ts +4 -0
  130. package/src/web-tools/web-search/helpers.ts +36 -0
  131. package/src/web-tools/web-search/index.ts +98 -0
  132. package/src/web-tools/web-search/types.ts +15 -0
@@ -0,0 +1,56 @@
1
+ import { formatErrorMessage, normalizeFetchedContent } from "../web-fetch/helpers.js";
2
+ import type { WebFetchResponse } from "../web-fetch/types.js";
3
+ import { normalizeSearchResult } from "../web-search/helpers.js";
4
+ import type { WebSearchResult } from "../web-search/types.js";
5
+ import { isDefined } from "../../utils.js";
6
+ import { tavily } from "@tavily/core";
7
+ import type { WebFetchProvider, WebSearchProvider } from "./index.js";
8
+
9
+ interface TavilySearchResponse {
10
+ results?: Array<{ title?: string; content?: string; url?: string }>;
11
+ }
12
+
13
+ interface TavilyExtractResponse {
14
+ failedResults?: Array<{ error?: string; url: string }>;
15
+ results?: Array<{ rawContent?: string; url: string }>;
16
+ }
17
+
18
+ export class TavilyProvider implements WebSearchProvider, WebFetchProvider {
19
+ constructor(private readonly apiKey: string) {}
20
+
21
+ async search(query: string, news: boolean, max: number): Promise<WebSearchResult[]> {
22
+ const client = tavily({ apiKey: this.apiKey });
23
+ const response = (await client.search(query, {
24
+ includeAnswer: false,
25
+ includeRawContent: false,
26
+ maxResults: max,
27
+ searchDepth: "basic",
28
+ topic: news ? "news" : "general",
29
+ })) as TavilySearchResponse;
30
+
31
+ return (response.results ?? [])
32
+ .map((item) =>
33
+ normalizeSearchResult({ description: item.content, title: item.title, url: item.url }),
34
+ )
35
+ .filter(isDefined);
36
+ }
37
+
38
+ async fetch(url: string): Promise<WebFetchResponse> {
39
+ const client = tavily({ apiKey: this.apiKey });
40
+ try {
41
+ const response = (await client.extract([url], {
42
+ format: "markdown",
43
+ })) as TavilyExtractResponse;
44
+ const content = normalizeFetchedContent(response.results?.[0]?.rawContent);
45
+
46
+ if (content) {
47
+ return { provider: "tavily", content, url };
48
+ }
49
+
50
+ const errorMsg = response.failedResults?.[0]?.error ?? "Tavily returned no content.";
51
+ return { provider: "tavily", url, error: errorMsg };
52
+ } catch (error) {
53
+ return { provider: "tavily", url, error: formatErrorMessage(error) };
54
+ }
55
+ }
56
+ }
@@ -0,0 +1,15 @@
1
+ export const WEB_SEARCH_PROVIDERS = [
2
+ { name: "tavily", label: "Tavily" },
3
+ { name: "brave-search", label: "Brave Search" },
4
+ { name: "firecrawl", label: "Firecrawl" },
5
+ ] as const;
6
+
7
+ export const WEB_FETCH_PROVIDERS = [
8
+ { name: "jina", label: "Jina", note: "optional, only helps increase rate limits" },
9
+ { name: "firecrawl", label: "Firecrawl" },
10
+ { name: "tavily", label: "Tavily" },
11
+ ] as const;
12
+
13
+ export const WEB_TOOLS_PROVIDERS = [
14
+ ...new Map([...WEB_SEARCH_PROVIDERS, ...WEB_FETCH_PROVIDERS].map((p) => [p.name, p])).values(),
15
+ ];
@@ -0,0 +1,66 @@
1
+ import { normalizeText } from "../../utils.js";
2
+ import { parseWebFetchContent } from "./parser.js";
3
+ import { getCacheFilePath } from "./storage.js";
4
+ import type { WebFetchResponse } from "./types.js";
5
+
6
+ export function normalizeFetchedContent(value: unknown): string {
7
+ return normalizeText(value).replace(/\r\n/g, "\n");
8
+ }
9
+
10
+ export function toCanonicalUrl(value: string): string {
11
+ const normalized = normalizeText(value);
12
+ try {
13
+ return new URL(normalized).href;
14
+ } catch {
15
+ return normalized;
16
+ }
17
+ }
18
+
19
+ export function isTextLikeContentType(contentType: string | null): boolean {
20
+ const normalized = normalizeText(contentType).toLowerCase();
21
+ if (!normalized) return true;
22
+ return (
23
+ normalized.startsWith("text/") ||
24
+ normalized.includes("json") ||
25
+ normalized.includes("xml") ||
26
+ normalized.includes("javascript") ||
27
+ normalized.includes("html")
28
+ );
29
+ }
30
+
31
+ export function formatFetchResult(result: WebFetchResponse, cacheDate: string): string {
32
+ const filePath = getCacheFilePath(result.url, cacheDate);
33
+ return `Provider: ${result.provider}\nOutput path: ${filePath}\nSections:\n${parseWebFetchContent(result.content!)}`;
34
+ }
35
+
36
+ export function formatErrorMessage(error: unknown): string {
37
+ if (error instanceof Error && error.message) {
38
+ return error.message;
39
+ }
40
+ return String(error);
41
+ }
42
+
43
+ export async function getHttpError(response: Response): Promise<string> {
44
+ const responseText = await response.text();
45
+ const body = responseText.trim();
46
+ if (!body) {
47
+ return `HTTP ${response.status}`;
48
+ }
49
+ return `HTTP ${response.status}: ${body}`;
50
+ }
51
+
52
+ export function getValidatedUrl(url: string): string {
53
+ const value = url.trim();
54
+ let parsed: URL;
55
+ try {
56
+ parsed = new URL(value);
57
+ } catch {
58
+ throw new Error(`Invalid URL: ${value}`);
59
+ }
60
+
61
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
62
+ throw new Error(`Unsupported URL protocol: ${value}`);
63
+ }
64
+
65
+ return parsed.href;
66
+ }
@@ -0,0 +1,91 @@
1
+ import { defineTool } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import { formatErrorMessage, formatFetchResult, getValidatedUrl } from "./helpers.js";
4
+ import {
5
+ getCurrentCacheDate,
6
+ pruneExpiredCacheDirs,
7
+ readCachedContent,
8
+ writeFetchedResult,
9
+ } from "./storage.js";
10
+ import { WebToolsFactory } from "../providers/index.js";
11
+ import type { WebFetchResponse } from "./types.js";
12
+ import { WEB_FETCH_PROVIDERS } from "../settings.js";
13
+ import { getApiKey } from "../web-login/helpers.js";
14
+ import { renderCallText } from "../../utils.js";
15
+
16
+ const webToolsFactory = new WebToolsFactory();
17
+
18
+ const webFetchTool = defineTool({
19
+ name: "web_fetch",
20
+ label: "Web Fetch",
21
+ description: "Fetch a public URL, cache markdown locally, return file path and heading outline.",
22
+ promptSnippet: "Fetch a known URL. Returns metadata and heading outline.",
23
+ promptGuidelines: [
24
+ "Don't use web_fetch when relevant info is already in web_search.",
25
+ "Use web_fetch for known URLs, not discovery.",
26
+ "Use web_fetch output path when page body is needed.",
27
+ "Web content tends to be very big. Use grep to search for needed content in output first before read ",
28
+ ],
29
+ parameters: Type.Object({
30
+ url: Type.String({ description: "An HTTP(S) URL" }),
31
+ }),
32
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
33
+ const url = getValidatedUrl(params.url);
34
+ const cacheDate = getCurrentCacheDate();
35
+ const attempts: string[] = [];
36
+ const nativeFetch = { name: "native", label: "Native fetch" } as const;
37
+
38
+ pruneExpiredCacheDirs(cacheDate);
39
+ if (signal?.aborted) {
40
+ throw new Error("web_fetch was cancelled.");
41
+ }
42
+
43
+ const cached = await readCachedContent(url, cacheDate);
44
+ if (cached !== undefined) {
45
+ const result: WebFetchResponse = { provider: "native", content: cached, url };
46
+ return {
47
+ content: [{ type: "text", text: formatFetchResult(result, cacheDate) }],
48
+ details: result satisfies WebFetchResponse,
49
+ };
50
+ }
51
+
52
+ for (const provider of [nativeFetch, ...WEB_FETCH_PROVIDERS]) {
53
+ if (signal?.aborted) {
54
+ throw new Error("web_fetch was cancelled.");
55
+ }
56
+
57
+ const apiKey =
58
+ provider.name === "native" ? undefined : await getApiKey(ctx.modelRegistry, provider.name);
59
+
60
+ if ((provider.name === "firecrawl" || provider.name === "tavily") && !apiKey) {
61
+ attempts.push(`${provider.label}: not configured`);
62
+ continue;
63
+ }
64
+
65
+ try {
66
+ const response = await webToolsFactory.createWebFetcher(provider.name, apiKey).fetch(url);
67
+ if (response.error === undefined) {
68
+ await writeFetchedResult(url, response.content, cacheDate);
69
+ return {
70
+ content: [{ type: "text", text: formatFetchResult(response, cacheDate) }],
71
+ details: response satisfies WebFetchResponse,
72
+ };
73
+ }
74
+
75
+ attempts.push(`${provider.label}: ${response.error}`);
76
+ } catch (error) {
77
+ attempts.push(`${provider.label}: ${formatErrorMessage(error)}`);
78
+ }
79
+ }
80
+
81
+ throw new Error(`Web fetch failed.\n|- ${attempts.join("\n|- ")}`);
82
+ },
83
+ renderCall(args, theme, { isPartial }) {
84
+ return renderCallText(
85
+ `${theme.fg("toolTitle", "web_fetch")} ${theme.underline(theme.fg("accent", args.url))}`,
86
+ isPartial,
87
+ );
88
+ },
89
+ });
90
+
91
+ export default webFetchTool;
@@ -0,0 +1,51 @@
1
+ export function parseWebFetchContent(content: string): string {
2
+ const lines = content.split("\n");
3
+ const headings: Array<{ endLine?: number; level: number; line: number; title: string }> = [];
4
+ let fenceMarker: string | undefined;
5
+
6
+ for (const [index, line] of lines.entries()) {
7
+ const fenceMatch = line.match(/^(```+|~~~+)/);
8
+ if (fenceMatch) {
9
+ const marker = fenceMatch[1]!;
10
+ if (!fenceMarker) {
11
+ fenceMarker = marker[0]!.repeat(marker.length);
12
+ } else if (line.startsWith(fenceMarker)) {
13
+ fenceMarker = undefined;
14
+ }
15
+ continue;
16
+ }
17
+
18
+ if (fenceMarker) {
19
+ continue;
20
+ }
21
+
22
+ const headingMatch = line.match(/^(#{1,5})[ \t]+(.+?)(?:[ \t]+#+[ \t]*)?$/);
23
+ if (!headingMatch) {
24
+ continue;
25
+ }
26
+
27
+ headings.push({
28
+ level: headingMatch[1]!.length,
29
+ line: index + 1,
30
+ title: headingMatch[2]!.trim(),
31
+ });
32
+ }
33
+
34
+ if (headings.length === 0) {
35
+ return "";
36
+ }
37
+
38
+ for (const [index, heading] of headings.entries()) {
39
+ const nextSiblingOrAncestor = headings
40
+ .slice(index + 1)
41
+ .find((candidate) => candidate.level <= heading.level);
42
+ heading.endLine = nextSiblingOrAncestor ? nextSiblingOrAncestor.line - 1 : lines.length;
43
+ }
44
+
45
+ return headings
46
+ .map((heading) => {
47
+ const indent = " ".repeat(heading.level - 1);
48
+ return `${indent}${"#".repeat(heading.level)} ${heading.title} (L${heading.line}-${heading.endLine})`;
49
+ })
50
+ .join("\n");
51
+ }
@@ -0,0 +1,65 @@
1
+ import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { createHash } from "node:crypto";
3
+ import { dirname, join } from "node:path";
4
+ import { toCanonicalUrl } from "./helpers.js";
5
+ import { getPiPath } from "../../utils.js";
6
+
7
+ export function getCurrentCacheDate(): string {
8
+ const now = new Date();
9
+ const year = String(now.getFullYear());
10
+ const month = String(now.getMonth() + 1).padStart(2, "0");
11
+ const day = String(now.getDate()).padStart(2, "0");
12
+ return `${year}-${month}-${day}`;
13
+ }
14
+
15
+ export function getCacheFilePath(url: string, date = getCurrentCacheDate()): string {
16
+ const canonicalUrl = toCanonicalUrl(url);
17
+ const fileName = `${createHash("md5").update(canonicalUrl).digest("hex")}.md`;
18
+ return getPiPath("web", "global", date, fileName);
19
+ }
20
+
21
+ export async function pruneExpiredCacheDirs(today = getCurrentCacheDate()) {
22
+ const cacheRoot = getPiPath("web");
23
+ try {
24
+ const entries = await readdir(cacheRoot, { withFileTypes: true });
25
+ await Promise.all(
26
+ entries
27
+ .filter((entry) => entry.isDirectory() && entry.name !== today)
28
+ .map((entry) => rm(join(cacheRoot, entry.name), { force: true, recursive: true })),
29
+ );
30
+ } catch (error) {
31
+ if (isMissingFileError(error)) return;
32
+ throw error;
33
+ }
34
+ }
35
+
36
+ export async function readCachedContent(
37
+ url: string,
38
+ date = getCurrentCacheDate(),
39
+ ): Promise<string | undefined> {
40
+ try {
41
+ return await readFile(getCacheFilePath(url, date), "utf8");
42
+ } catch (error) {
43
+ if (isMissingFileError(error)) return;
44
+ throw error;
45
+ }
46
+ }
47
+
48
+ export async function writeFetchedResult(
49
+ url: string,
50
+ content: string,
51
+ date = getCurrentCacheDate(),
52
+ ) {
53
+ const filePath = getCacheFilePath(url, date);
54
+ await mkdir(dirname(filePath), { recursive: true });
55
+ await writeFile(filePath, content, "utf8");
56
+ }
57
+
58
+ function isMissingFileError(error: unknown): boolean {
59
+ return (
60
+ typeof error === "object" &&
61
+ error !== null &&
62
+ "code" in error &&
63
+ (error as { code?: unknown }).code === "ENOENT"
64
+ );
65
+ }
@@ -0,0 +1,8 @@
1
+ import type { WEB_FETCH_PROVIDERS } from "../settings.js";
2
+
3
+ export type WebFetchProviderId = (typeof WEB_FETCH_PROVIDERS)[number]["name"] | "native";
4
+
5
+ export type WebFetchResponse = {
6
+ url: string;
7
+ provider: WebFetchProviderId;
8
+ } & ({ content: string; error?: never } | { error: string; content?: never });
@@ -0,0 +1,79 @@
1
+ import type { CredentialStore } from "@earendil-works/pi-ai";
2
+ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
3
+ import { WEB_TOOLS_PROVIDERS } from "../settings.js";
4
+ import type { WebToolsProvider, WebToolsProviderId } from "./types.js";
5
+
6
+ export function findWebToolsProvider(input: string): WebToolsProvider | undefined {
7
+ const normalized = input.trim().toLowerCase();
8
+ if (!normalized) return;
9
+ return WEB_TOOLS_PROVIDERS.find((provider) => provider.name === normalized);
10
+ }
11
+
12
+ export function getWebToolsProviderOptions(): string[] {
13
+ return WEB_TOOLS_PROVIDERS.map((provider) => provider.label);
14
+ }
15
+
16
+ export function getWebToolsProviderByLabel(label: string): WebToolsProvider | undefined {
17
+ return WEB_TOOLS_PROVIDERS.find((provider) => provider.label === label);
18
+ }
19
+
20
+ function maskApiKey(key: string): string {
21
+ if (key.length <= 4) return key;
22
+ return key.slice(0, 4) + "*".repeat(key.length - 4);
23
+ }
24
+
25
+ function getCredentialStore(modelRegistry: ModelRegistry): CredentialStore {
26
+ return (
27
+ (modelRegistry as unknown as { runtime: unknown }).runtime as {
28
+ credentials: CredentialStore;
29
+ }
30
+ ).credentials;
31
+ }
32
+
33
+ export async function getApiKey(
34
+ modelRegistry: ModelRegistry,
35
+ providerId: WebToolsProviderId,
36
+ ): Promise<string | undefined> {
37
+ const credential = await getCredentialStore(modelRegistry).read(providerId);
38
+ return credential?.type === "api_key" ? credential.key : undefined;
39
+ }
40
+
41
+ export async function formatProviderStatus(
42
+ modelRegistry: ModelRegistry,
43
+ provider: WebToolsProvider,
44
+ ): Promise<string> {
45
+ const apiKey = await getApiKey(modelRegistry, provider.name);
46
+ return apiKey
47
+ ? `${provider.label} (configured — ${maskApiKey(apiKey)})`
48
+ : `${provider.label} (not configured)`;
49
+ }
50
+
51
+ export async function setApiKey(
52
+ modelRegistry: ModelRegistry,
53
+ providerId: WebToolsProviderId,
54
+ apiKey: string,
55
+ ) {
56
+ await getCredentialStore(modelRegistry).modify(providerId, async () => ({
57
+ type: "api_key",
58
+ key: apiKey,
59
+ }));
60
+ }
61
+
62
+ export async function clearApiKey(modelRegistry: ModelRegistry, providerId: WebToolsProviderId) {
63
+ await getCredentialStore(modelRegistry).delete(providerId);
64
+ }
65
+
66
+ export function getArgumentCompletions(prefix: string) {
67
+ const normalized = prefix.trim().toLowerCase();
68
+ const matches = WEB_TOOLS_PROVIDERS.filter((provider) => provider.name.startsWith(normalized));
69
+
70
+ if (matches.length === 0) {
71
+ return null;
72
+ }
73
+
74
+ return matches.map((provider) => ({ value: provider.name, label: provider.label }));
75
+ }
76
+
77
+ export function getSupportedProviderNames(): string {
78
+ return WEB_TOOLS_PROVIDERS.map((provider) => provider.name).join(", ");
79
+ }
@@ -0,0 +1,100 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ clearApiKey,
4
+ findWebToolsProvider,
5
+ formatProviderStatus,
6
+ getApiKey,
7
+ getSupportedProviderNames,
8
+ getWebToolsProviderByLabel,
9
+ getWebToolsProviderOptions,
10
+ setApiKey,
11
+ } from "./helpers.js";
12
+ import type { WebToolsProvider } from "./types.js";
13
+
14
+ async function selectProvider(ctx: ExtensionCommandContext): Promise<WebToolsProvider | undefined> {
15
+ const selected = await ctx.ui.select(
16
+ "Configure web provider authentication",
17
+ getWebToolsProviderOptions(),
18
+ );
19
+
20
+ if (!selected) return;
21
+ return getWebToolsProviderByLabel(selected);
22
+ }
23
+
24
+ async function chooseAction(
25
+ ctx: ExtensionCommandContext,
26
+ provider: WebToolsProvider,
27
+ ): Promise<"save" | "clear" | undefined> {
28
+ if (await getApiKey(ctx.modelRegistry, provider.name)) {
29
+ const selected = await ctx.ui.select(`${provider.label} credentials`, [
30
+ "Save new API key",
31
+ "Clear saved API key",
32
+ ]);
33
+ if (!selected) return;
34
+ return selected === "Clear saved API key" ? "clear" : "save";
35
+ }
36
+ return "save";
37
+ }
38
+
39
+ async function saveProviderKey(ctx: ExtensionCommandContext, provider: WebToolsProvider) {
40
+ const note = provider.name === "jina" ? ` (${provider.note})` : "";
41
+
42
+ if (await getApiKey(ctx.modelRegistry, provider.name)) {
43
+ const replace = await ctx.ui.confirm(
44
+ `${provider.label} API key`,
45
+ `${provider.label} already has a saved API key. Replace it?`,
46
+ );
47
+ if (!replace) return;
48
+ }
49
+
50
+ const apiKey = await ctx.ui.input(
51
+ `${provider.label} API key${note}`,
52
+ `Paste your ${provider.label} API key`,
53
+ );
54
+
55
+ if (!apiKey) {
56
+ ctx.ui.notify(`No ${provider.label} API key was saved`, "warning");
57
+ return;
58
+ }
59
+
60
+ await setApiKey(ctx.modelRegistry, provider.name, apiKey.trim());
61
+ ctx.ui.notify(`Saved ${provider.label} API key`, "info");
62
+ }
63
+
64
+ async function clearProviderKey(ctx: ExtensionCommandContext, provider: WebToolsProvider) {
65
+ const confirmed = await ctx.ui.confirm(
66
+ `Clear ${provider.label} API key`,
67
+ `Remove the saved ${provider.label} API key from shared auth storage?`,
68
+ );
69
+ if (!confirmed) return;
70
+
71
+ await clearApiKey(ctx.modelRegistry, provider.name);
72
+ ctx.ui.notify(`Cleared ${provider.label} API key`, "info");
73
+ }
74
+
75
+ export default async function webLoginCommand(args: string, ctx: ExtensionCommandContext) {
76
+ const arg = args.trim();
77
+ const provider = arg ? findWebToolsProvider(arg) : await selectProvider(ctx);
78
+
79
+ if (!provider) {
80
+ if (arg) {
81
+ ctx.ui.notify(
82
+ `Unknown provider \"${arg}\". Supported providers: ${getSupportedProviderNames()}.`,
83
+ "error",
84
+ );
85
+ }
86
+ return;
87
+ }
88
+
89
+ ctx.ui.notify(await formatProviderStatus(ctx.modelRegistry, provider), "info");
90
+
91
+ const action = await chooseAction(ctx, provider);
92
+
93
+ if (!action) return;
94
+ if (action === "clear") {
95
+ await clearProviderKey(ctx, provider);
96
+ return;
97
+ }
98
+
99
+ await saveProviderKey(ctx, provider);
100
+ }
@@ -0,0 +1,4 @@
1
+ import { WEB_TOOLS_PROVIDERS } from "../settings.js";
2
+
3
+ export type WebToolsProvider = (typeof WEB_TOOLS_PROVIDERS)[number];
4
+ export type WebToolsProviderId = WebToolsProvider["name"];
@@ -0,0 +1,36 @@
1
+ import { normalizeText } from "../../utils.js";
2
+ import type { WebSearchResult, WebSearchResultInput } from "./types.js";
3
+
4
+ export function normalizeSearchResult(item: WebSearchResultInput): WebSearchResult | undefined {
5
+ const title = normalizeText(item.title);
6
+ const url = normalizeText(item.url);
7
+ if (!title || !url) return;
8
+ return { description: normalizeText(item.description), title, url };
9
+ }
10
+
11
+ export function joinSnippets(snippets: string[] | undefined): string {
12
+ if (!Array.isArray(snippets)) {
13
+ return "";
14
+ }
15
+
16
+ return snippets
17
+ .filter((snippet) => typeof snippet === "string" && snippet.trim().length > 0)
18
+ .join(" ")
19
+ .trim();
20
+ }
21
+
22
+ export function formatErrorMessage(error: unknown): string {
23
+ if (error instanceof Error && error.message) {
24
+ return error.message;
25
+ }
26
+ return String(error);
27
+ }
28
+
29
+ export async function getHttpError(response: Response): Promise<string> {
30
+ const responseText = await response.text();
31
+ const body = responseText.trim();
32
+ if (!body) {
33
+ return `HTTP ${response.status}`;
34
+ }
35
+ return `HTTP ${response.status}: ${body}`;
36
+ }
@@ -0,0 +1,98 @@
1
+ import { defineTool } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import { Type } from "typebox";
4
+ import { WEB_SEARCH_PROVIDERS } from "../settings.js";
5
+ import { WebToolsFactory } from "../providers/index.js";
6
+ import { formatErrorMessage } from "./helpers.js";
7
+ import { getApiKey } from "../web-login/helpers.js";
8
+ import type { WebSearchResult } from "./types.js";
9
+ import { renderCallText, renderResultText } from "../../utils.js";
10
+
11
+ const webToolsFactory = new WebToolsFactory();
12
+
13
+ const webSearchTool = defineTool({
14
+ name: "web_search",
15
+ label: "Web Search",
16
+ description: "Search web or news. Returns ranked title/summary/url results.",
17
+ promptSnippet: "Search web or news. Returns ranked results.",
18
+ promptGuidelines: [
19
+ "Use web_search for external/unavailable info.",
20
+ "Use web_search with news=true for recent reporting; default is web search.",
21
+ "Use web_search with small max first; results are re-ranked best-first.",
22
+ ],
23
+ parameters: Type.Object({
24
+ query: Type.String({ description: "Search query" }),
25
+ max: Type.Optional(
26
+ Type.Number({ description: "Max results (default: 5)", minimum: 1, maximum: 10 }),
27
+ ),
28
+ news: Type.Optional(Type.Boolean({ description: "Search news when true; web when false" })),
29
+ }),
30
+ async execute(_toolCallId, { query, max = 5, news = false }, signal, _onUpdate, ctx) {
31
+ const trimmed = query.trim();
32
+ if (!trimmed) {
33
+ throw new Error("Query must not be empty.");
34
+ }
35
+
36
+ const attempts: string[] = [];
37
+ let anyConfiguredProvider = false;
38
+
39
+ for (const provider of WEB_SEARCH_PROVIDERS) {
40
+ if (signal?.aborted) {
41
+ throw new Error("web_search was cancelled.");
42
+ }
43
+
44
+ const apiKey = await getApiKey(ctx.modelRegistry, provider.name);
45
+
46
+ if (!apiKey) {
47
+ attempts.push(`${provider.label}: not configured`);
48
+ continue;
49
+ }
50
+
51
+ anyConfiguredProvider = true;
52
+
53
+ try {
54
+ const results = await webToolsFactory
55
+ .createWebSearcher(provider.name, apiKey)
56
+ .search(trimmed, news, max);
57
+
58
+ if (results.length === 0) {
59
+ attempts.push(`${provider.label}: returned no results`);
60
+ continue;
61
+ }
62
+
63
+ return {
64
+ content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
65
+ details: { results } satisfies { results: WebSearchResult[] },
66
+ };
67
+ } catch (error) {
68
+ attempts.push(`${provider.label}: ${formatErrorMessage(error)}`);
69
+ }
70
+ }
71
+
72
+ if (!anyConfiguredProvider) {
73
+ throw new Error(
74
+ "No configured web search providers available. Use /web-login to configure Tavily, Brave Search, or Firecrawl.",
75
+ );
76
+ }
77
+
78
+ throw new Error(
79
+ `Web search failed across all configured providers.\n|- ${attempts.join("\n|- ")}`,
80
+ );
81
+ },
82
+ renderCall(args, theme, { isPartial }) {
83
+ return renderCallText(
84
+ `${theme.fg("toolTitle", "web_search")} ${theme.fg("accent", args.query)}`,
85
+ isPartial,
86
+ );
87
+ },
88
+ renderResult(result, { expanded, isPartial }, theme) {
89
+ if (isPartial) {
90
+ return new Text(theme.fg("warning", "Searching..."), 0, 0);
91
+ }
92
+ const details = result.details as { results: WebSearchResult[] } | undefined;
93
+ const text = (details?.results.map((item) => item.url) ?? ["No search results"]).join("\n");
94
+ return renderResultText(text, theme, expanded);
95
+ },
96
+ });
97
+
98
+ export default webSearchTool;