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,155 @@
1
+ import { DynamicBorder, getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ Key,
4
+ Markdown,
5
+ isFocusable,
6
+ type Component,
7
+ type Focusable,
8
+ type TUI,
9
+ } from "@earendil-works/pi-tui";
10
+ import { Lines } from "./lines.js";
11
+ import { Frame } from "./frame.js";
12
+ import type { Keybindings } from "./keybound.js";
13
+
14
+ export type ScrollableViewOptions = {
15
+ markdown: string;
16
+ input?: Component & Partial<Focusable>;
17
+ };
18
+
19
+ export class ScrollableView extends Frame implements Focusable {
20
+ onCancel?: () => void;
21
+
22
+ private readonly markdownView: Markdown;
23
+ private input: (Component & Partial<Focusable>) | undefined;
24
+ private contentScrollOffset = 0;
25
+ private lastViewportHeight = 1;
26
+ private lastMarkdownLineCount = 0;
27
+ private editing: boolean = false;
28
+ private _focused = false;
29
+
30
+ constructor(
31
+ private readonly tui: TUI,
32
+ theme: Theme,
33
+ options: ScrollableViewOptions,
34
+ ) {
35
+ super(theme);
36
+
37
+ this.input = options.input;
38
+ this.markdownView = new Markdown(options.markdown, 0, 0, getMarkdownTheme());
39
+
40
+ const keybindings: Keybindings = [
41
+ { key: Key.escape, hint: "discard and exit", handler: () => this.onCancel?.() },
42
+ {
43
+ key: { navigation: "vertical" },
44
+ hint: "navigate",
45
+ navigate: (data) => this.scrollBy(data as "up" | "down"),
46
+ },
47
+ {
48
+ key: { navigation: "page" },
49
+ hint: "page",
50
+ navigate: (data) => this.scrollBy(data as "pageUp" | "pageDown"),
51
+ },
52
+ ];
53
+
54
+ if (this.input) {
55
+ keybindings.push({
56
+ key: Key.tab,
57
+ hint: "switch focus",
58
+ handler: () => {
59
+ this.editing = !this.editing;
60
+ this.setArrowKeyAccess({ navigation: "page" }, !this.editing);
61
+ this.setArrowKeyAccess({ navigation: "vertical" }, { consumable: !this.editing });
62
+ this.syncInputFocus();
63
+ },
64
+ });
65
+ }
66
+
67
+ this.registerKeybindings(keybindings);
68
+ this.syncInputFocus();
69
+ }
70
+
71
+ get focused(): boolean {
72
+ return this._focused;
73
+ }
74
+
75
+ set focused(value: boolean) {
76
+ this._focused = value;
77
+ this.syncInputFocus();
78
+ }
79
+
80
+ override get hints(): [string, string][] {
81
+ return [["enter", "select"], ...super.hints];
82
+ }
83
+
84
+ override invalidate() {
85
+ super.invalidate();
86
+ this.markdownView.invalidate();
87
+ this.input?.invalidate();
88
+ }
89
+
90
+ handleInput(data: string) {
91
+ if (data === "\n" || this.handleKb(data) || !this.editing) return;
92
+ this.input?.handleInput?.(data);
93
+ }
94
+
95
+ protected override children(width: number): string[] {
96
+ const contentWidth = Math.max(6, width - 1);
97
+ const childHeightBudget = Math.max(1, this.tui.terminal.rows - 10);
98
+
99
+ const lines = new Lines(contentWidth);
100
+ const border = new DynamicBorder((s) => this.theme.fg(this.editing ? "accent" : "dim", s));
101
+
102
+ const inputCandidates = this.input ? this.input.render(contentWidth) : [];
103
+ const maxInputLineCount = inputCandidates.length > 0 ? Math.max(0, childHeightBudget - 1) : 0;
104
+ const inputLines = inputCandidates.slice(-maxInputLineCount);
105
+ const inputSectionRows = inputLines.length > 0 ? inputLines.length + 1 : 0;
106
+
107
+ this.lastViewportHeight = Math.max(0, childHeightBudget - inputSectionRows);
108
+
109
+ const markdownLines = this.markdownView.render(contentWidth);
110
+ this.lastMarkdownLineCount = markdownLines.length;
111
+ this.clampScrollOffset(markdownLines.length);
112
+
113
+ const viewportStartIndex = this.contentScrollOffset;
114
+ const viewportEndIndex = viewportStartIndex + this.lastViewportHeight;
115
+ const visibleMarkdownLines = markdownLines.slice(viewportStartIndex, viewportEndIndex);
116
+
117
+ for (const markdownLine of visibleMarkdownLines) {
118
+ lines.add(markdownLine);
119
+ }
120
+
121
+ lines.space();
122
+ lines.add(border.render(contentWidth)[0]!);
123
+
124
+ if (inputLines.length > 0) {
125
+ lines.space();
126
+ for (const inputLine of inputLines) {
127
+ lines.add(inputLine);
128
+ }
129
+ }
130
+
131
+ return lines.get();
132
+ }
133
+
134
+ private scrollBy(data: "up" | "down" | "pageUp" | "pageDown") {
135
+ let amount: number;
136
+ if (data === "up") amount = -2;
137
+ else if (data === "down") amount = 2;
138
+ else if (data === "pageUp") amount = -Math.max(1, this.lastViewportHeight - 1);
139
+ else amount = Math.max(1, this.lastViewportHeight - 1);
140
+
141
+ this.contentScrollOffset += amount;
142
+ this.clampScrollOffset();
143
+ }
144
+
145
+ private clampScrollOffset(totalMarkdownLines?: number) {
146
+ const lineCount = totalMarkdownLines ?? this.lastMarkdownLineCount;
147
+ const maxOffset = Math.max(0, lineCount - this.lastViewportHeight);
148
+ this.contentScrollOffset = Math.max(0, Math.min(maxOffset, this.contentScrollOffset));
149
+ }
150
+
151
+ private syncInputFocus() {
152
+ if (!this.input || !isFocusable(this.input)) return;
153
+ this.input.focused = this._focused && this.editing;
154
+ }
155
+ }
@@ -0,0 +1,40 @@
1
+ import { VERSION as PI_VERSION } from "@earendil-works/pi-coding-agent";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { Key } from "@earendil-works/pi-tui";
4
+ import ModeIndicatorEditor from "./components/input-mode-indicator.js";
5
+ import { readFileSync } from "node:fs";
6
+
7
+ const PACKAGE_JSON_PATH = new URL("../../package.json", import.meta.url);
8
+ const packageJson = JSON.parse(readFileSync(PACKAGE_JSON_PATH, "utf8")) as {
9
+ name: string;
10
+ version: string;
11
+ };
12
+
13
+ const APP_NAME = packageJson.name;
14
+ const APP_VERSION = packageJson.version;
15
+ const BASH_MODE_HOTKEY = Key.ctrlAlt("b");
16
+
17
+ export default function (pi: ExtensionAPI) {
18
+ let activeEditor: ModeIndicatorEditor | undefined;
19
+
20
+ pi.registerShortcut(BASH_MODE_HOTKEY, {
21
+ description: "Cycle input mode (prompt / bash (included in context) / normal bash)",
22
+ handler: () => activeEditor?.cycleMode(),
23
+ });
24
+
25
+ pi.on("session_start", (_event, ctx) => {
26
+ if (!ctx.hasUI) return;
27
+
28
+ ctx.ui.setHeader((_tui, theme) => ({
29
+ render: () => [
30
+ `${theme.bold(theme.fg("accent", APP_NAME))}${theme.fg("dim", ` v${APP_VERSION} · built on top of pi v${PI_VERSION}`)}`,
31
+ ],
32
+ invalidate() {},
33
+ }));
34
+
35
+ ctx.ui.setEditorComponent((tui, theme, keybindings) => {
36
+ activeEditor = new ModeIndicatorEditor(tui, theme, keybindings, ctx.ui.theme);
37
+ return activeEditor;
38
+ });
39
+ });
40
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,206 @@
1
+ import { spawn } from "node:child_process";
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ keyHint,
7
+ SettingsManager,
8
+ Theme,
9
+ truncateToVisualLines,
10
+ type ExtensionCommandContext,
11
+ } from "@earendil-works/pi-coding-agent";
12
+ import { Spacer, Text } from "@earendil-works/pi-tui";
13
+ import { Container } from "@earendil-works/pi-tui";
14
+
15
+ const PI_PATHS = {
16
+ web: "web-results",
17
+ agents: "agents",
18
+ settings: "settings.json",
19
+ mcp: "mcp.json",
20
+ permissions: "permissions.json",
21
+ checkpoints: "checkpoints",
22
+ subsessions: "subsessions.json",
23
+ subsessionsDir: "subsessions",
24
+ plans: "plans",
25
+ grammars: "grammars",
26
+ system: "SYSTEM.md",
27
+ } as const;
28
+
29
+ type PathKey = keyof typeof PI_PATHS;
30
+
31
+ export function getPiPath(key: PathKey, scope: "global", ...path: string[]): string;
32
+ export function getPiPath(key: PathKey, cwd: string, ...path: string[]): string;
33
+ export function getPiPath(key: PathKey): string;
34
+ export function getPiPath(key: PathKey, ...full: string[]): string {
35
+ const path = PI_PATHS[key];
36
+ const remaining = path.includes(".") ? [] : full.slice(1);
37
+ const isGlobal = !full[0] || full[0] === "global";
38
+ const baseDir = isGlobal ? homedir() : full[0]!;
39
+ const piPath = isGlobal ? [".pi", "agent"] : [".pi"];
40
+ return join(baseDir, ...piPath, path, ...remaining);
41
+ }
42
+
43
+ export async function readJson<T>(filePath: string, fallback: T): Promise<T> {
44
+ try {
45
+ return JSON.parse(await readFile(filePath, "utf8")) as T;
46
+ } catch {
47
+ return fallback;
48
+ }
49
+ }
50
+
51
+ export async function writeJson(filePath: string, data: unknown) {
52
+ await writeFile(filePath, JSON.stringify(data, null, 2) + "\n", "utf8");
53
+ }
54
+
55
+ export async function runCommand(
56
+ cwd: string,
57
+ command: string,
58
+ argumentsList: string[],
59
+ options?: { signal?: AbortSignal; successExitCodes?: number[]; abortMessage?: string },
60
+ ): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
61
+ const abortMessage = options?.abortMessage ?? "command aborted";
62
+ if (options?.signal?.aborted) {
63
+ throw new Error(abortMessage);
64
+ }
65
+
66
+ return await new Promise<{ stdout: string; stderr: string; exitCode: number | null }>(
67
+ (resolveCommand, rejectCommand) => {
68
+ const executable = process.platform === "win32" ? `${command}.cmd` : command;
69
+ const childProcess = spawn(executable, argumentsList, {
70
+ cwd,
71
+ env: process.env,
72
+ signal: options?.signal,
73
+ stdio: ["ignore", "pipe", "pipe"],
74
+ });
75
+
76
+ let stdout = "";
77
+ let stderr = "";
78
+
79
+ childProcess.stdout.on("data", (chunk) => {
80
+ stdout += chunk.toString();
81
+ });
82
+
83
+ childProcess.stderr.on("data", (chunk) => {
84
+ stderr += chunk.toString();
85
+ });
86
+
87
+ childProcess.on("error", (error) => {
88
+ rejectCommand(error);
89
+ });
90
+
91
+ childProcess.on("close", (exitCode) => {
92
+ if (options?.signal?.aborted) {
93
+ rejectCommand(new Error(abortMessage));
94
+ return;
95
+ }
96
+
97
+ const successExitCodes = options?.successExitCodes ?? [0];
98
+ if (!successExitCodes.includes(exitCode ?? -1)) {
99
+ const commandText = `${command} ${argumentsList.join(" ")}`;
100
+ const stderrText = stderr.trim();
101
+ rejectCommand(
102
+ new Error(
103
+ `${commandText} failed with exit code ${exitCode ?? "unknown"}${stderrText ? `: ${stderrText}` : ""}`,
104
+ ),
105
+ );
106
+ return;
107
+ }
108
+
109
+ resolveCommand({ stdout, stderr, exitCode });
110
+ });
111
+ },
112
+ );
113
+ }
114
+
115
+ export async function openInEditor(
116
+ ctx: ExtensionCommandContext,
117
+ filePath: string,
118
+ ): Promise<boolean> {
119
+ const command = SettingsManager.create(ctx.cwd, undefined, {
120
+ projectTrusted: ctx.isProjectTrusted(),
121
+ }).getExternalEditorCommand();
122
+ const commandParts = tokenizeArgs(command);
123
+ const editor = commandParts.shift();
124
+ if (!editor) {
125
+ ctx.ui.notify("External editor command is empty", "error");
126
+ return false;
127
+ }
128
+
129
+ const exitCode = await new Promise<number | null>((resolvePromise) => {
130
+ const child = spawn(editor, [...commandParts, filePath], {
131
+ stdio: "inherit",
132
+ shell: process.platform === "win32",
133
+ });
134
+ child.on("error", () => resolvePromise(null));
135
+ child.on("close", (code) => resolvePromise(code));
136
+ });
137
+
138
+ if (exitCode === 0) return true;
139
+ ctx.ui.notify(`Failed to open ${filePath}. Set externalEditor, VISUAL, or EDITOR.`, "error");
140
+ return false;
141
+ }
142
+
143
+ export function normalizeText(value: unknown): string {
144
+ return typeof value === "string" ? value.trim() : "";
145
+ }
146
+
147
+ export function unique<T>(values: Iterable<T>): T[] {
148
+ return [...new Set(values)];
149
+ }
150
+
151
+ export function isDefined<T>(value: T | undefined): value is T {
152
+ return value !== undefined;
153
+ }
154
+
155
+ export function isRecord(value: unknown): value is Record<string, unknown> {
156
+ return typeof value === "object" && value !== null && !Array.isArray(value);
157
+ }
158
+
159
+ export function tokenizeArgs(args: string): string[] {
160
+ return args
161
+ .split(/\s+/)
162
+ .map((value) => value.trim())
163
+ .filter(Boolean);
164
+ }
165
+
166
+ export function customText(text: string, pad?: { x?: number; y?: number }) {
167
+ const { x = 0, y = 0 } = pad ?? {};
168
+ return new Text(text, x, y);
169
+ }
170
+
171
+ export function isMissingFileError(error: any) {
172
+ return Boolean(error) && typeof error === "object" && "code" in error && error.code === "ENOENT";
173
+ }
174
+
175
+ export function isUuidv7(input: string): boolean {
176
+ const pattern = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
177
+ return pattern.test(input);
178
+ }
179
+
180
+ export function renderCallText(text: string, isPartial: boolean) {
181
+ const container = new Container();
182
+ container.addChild(new Text(text, 0, 0));
183
+ if (!isPartial) {
184
+ container.addChild(new Spacer(1));
185
+ }
186
+ return container;
187
+ }
188
+
189
+ export function renderResultText(text: string, theme: Theme, expanded: boolean) {
190
+ if (expanded) {
191
+ return new Text(theme.fg("toolOutput", text), 0, 0);
192
+ }
193
+ return {
194
+ render(width: number) {
195
+ const result = truncateToVisualLines(text, 10, width);
196
+ const lines = result.visualLines.map((line) => theme.fg("toolOutput", line));
197
+ if (result.skippedCount > 0) {
198
+ lines.push(
199
+ `${theme.fg("toolOutput", `... (${result.skippedCount} more lines,`)} ${keyHint("app.tools.expand", "to expand")}${theme.fg("toolOutput", ")")}`,
200
+ );
201
+ }
202
+ return lines;
203
+ },
204
+ invalidate() {},
205
+ };
206
+ }
@@ -0,0 +1,15 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import webLoginCommand from "./web-login/index.js";
3
+ import webFetchTool from "./web-fetch/index.js";
4
+ import { getArgumentCompletions } from "./web-login/helpers.js";
5
+ import webSearchTool from "./web-search/index.js";
6
+
7
+ export default function (pi: ExtensionAPI) {
8
+ pi.registerCommand("web-login", {
9
+ description: "Configure API keys for authenticated web providers",
10
+ getArgumentCompletions,
11
+ handler: webLoginCommand,
12
+ });
13
+ pi.registerTool(webFetchTool);
14
+ pi.registerTool(webSearchTool);
15
+ }
@@ -0,0 +1,55 @@
1
+ import { getHttpError, joinSnippets, normalizeSearchResult } from "../web-search/helpers.js";
2
+ import type { WebSearchResult } from "../web-search/types.js";
3
+ import { isDefined } from "../../utils.js";
4
+ import type { WebSearchProvider } from "./index.js";
5
+
6
+ interface BraveSearchItem {
7
+ title?: string;
8
+ description?: string;
9
+ snippet?: string;
10
+ url?: string;
11
+ extra_snippets?: string[];
12
+ meta_url?: { href?: string };
13
+ }
14
+
15
+ interface BraveSearchResponse {
16
+ news?: { results?: BraveSearchItem[] };
17
+ web?: { results?: BraveSearchItem[] };
18
+ }
19
+
20
+ export class BraveWebSearchProvider implements WebSearchProvider {
21
+ constructor(private readonly apiKey: string) {}
22
+
23
+ async search(query: string, news: boolean, max: number): Promise<WebSearchResult[]> {
24
+ const endpoint = news ? "news/search" : "web/search";
25
+ const searchParams = new URLSearchParams({
26
+ count: String(max),
27
+ extra_snippets: "true",
28
+ q: query,
29
+ });
30
+
31
+ const response = await fetch(
32
+ `https://api.search.brave.com/res/v1/${endpoint}?${searchParams.toString()}`,
33
+ {
34
+ headers: { Accept: "application/json", "X-Subscription-Token": this.apiKey },
35
+ },
36
+ );
37
+
38
+ if (!response.ok) {
39
+ throw new Error(await getHttpError(response));
40
+ }
41
+
42
+ const payload = (await response.json()) as BraveSearchResponse;
43
+ const items = (news ? payload.news : payload.web)?.results ?? [];
44
+
45
+ return items
46
+ .map((item) =>
47
+ normalizeSearchResult({
48
+ description: item.description ?? item.snippet ?? joinSnippets(item.extra_snippets),
49
+ title: item.title,
50
+ url: item.url ?? item.meta_url?.href,
51
+ }),
52
+ )
53
+ .filter(isDefined);
54
+ }
55
+ }
@@ -0,0 +1,66 @@
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 Firecrawl from "@mendable/firecrawl-js";
7
+ import type { WebFetchProvider, WebSearchProvider } from "./index.js";
8
+
9
+ interface FirecrawlSearchItem {
10
+ title?: string;
11
+ description?: string;
12
+ snippet?: string;
13
+ url?: string;
14
+ }
15
+
16
+ interface FirecrawlSearchResponse {
17
+ news?: FirecrawlSearchItem[];
18
+ web?: FirecrawlSearchItem[];
19
+ }
20
+
21
+ interface FirecrawlDocument {
22
+ markdown?: string;
23
+ metadata?: { sourceURL?: string; ogUrl?: string };
24
+ }
25
+
26
+ export class FirecrawlProvider implements WebSearchProvider, WebFetchProvider {
27
+ constructor(private readonly apiKey: string) {}
28
+
29
+ async search(query: string, news: boolean, max: number): Promise<WebSearchResult[]> {
30
+ const client = new Firecrawl({ apiKey: this.apiKey });
31
+ const source = news ? "news" : "web";
32
+ const response = (await client.search(query, {
33
+ limit: max,
34
+ sources: [source],
35
+ })) as FirecrawlSearchResponse;
36
+ const items = news ? (response.news ?? []) : (response.web ?? []);
37
+
38
+ return items
39
+ .map((item) =>
40
+ normalizeSearchResult({
41
+ description: item.description ?? item.snippet,
42
+ title: item.title,
43
+ url: item.url,
44
+ }),
45
+ )
46
+ .filter(isDefined);
47
+ }
48
+
49
+ async fetch(url: string): Promise<WebFetchResponse> {
50
+ const client = new Firecrawl({ apiKey: this.apiKey });
51
+ try {
52
+ const document = (await client.scrape(url, {
53
+ formats: ["markdown", "html"],
54
+ })) as FirecrawlDocument;
55
+ const content = normalizeFetchedContent(document?.markdown);
56
+
57
+ if (!content) {
58
+ return { provider: "firecrawl", url, error: "Firecrawl returned no markdown content." };
59
+ }
60
+
61
+ return { provider: "firecrawl", content, url };
62
+ } catch (error) {
63
+ return { provider: "firecrawl", url, error: formatErrorMessage(error) };
64
+ }
65
+ }
66
+ }
@@ -0,0 +1,50 @@
1
+ import type { WebFetchProviderId, WebFetchResponse } from "../web-fetch/types.js";
2
+ import type { WebToolsProviderId } from "../web-login/types.js";
3
+ import type { WebSearchProviderId, WebSearchResult } from "../web-search/types.js";
4
+ import { BraveWebSearchProvider } from "./brave.js";
5
+ import { FirecrawlProvider } from "./firecrawl.js";
6
+ import { JinaWebFetchProvider } from "./jina.js";
7
+ import { NativeWebFetchProvider } from "./native.js";
8
+ import { TavilyProvider } from "./tavily.js";
9
+
10
+ export interface WebSearchProvider {
11
+ search(query: string, news: boolean, max: number): Promise<WebSearchResult[]>;
12
+ }
13
+
14
+ export interface WebFetchProvider {
15
+ fetch(url: string): Promise<WebFetchResponse>;
16
+ }
17
+
18
+ export class WebToolsFactory {
19
+ createWebSearcher(name: WebSearchProviderId, apiKey?: string): WebSearchProvider {
20
+ switch (name) {
21
+ case "brave-search":
22
+ return new BraveWebSearchProvider(this.requireApiKey(name, apiKey));
23
+ case "firecrawl":
24
+ return new FirecrawlProvider(this.requireApiKey(name, apiKey));
25
+ case "tavily":
26
+ return new TavilyProvider(this.requireApiKey(name, apiKey));
27
+ }
28
+ }
29
+
30
+ createWebFetcher(name: WebFetchProviderId, apiKey?: string): WebFetchProvider {
31
+ switch (name) {
32
+ case "native":
33
+ return new NativeWebFetchProvider();
34
+ case "jina":
35
+ return new JinaWebFetchProvider(apiKey);
36
+ case "firecrawl":
37
+ return new FirecrawlProvider(this.requireApiKey(name, apiKey));
38
+ case "tavily":
39
+ return new TavilyProvider(this.requireApiKey(name, apiKey));
40
+ }
41
+ }
42
+
43
+ private requireApiKey(name: WebToolsProviderId, apiKey?: string): string {
44
+ if (!apiKey) {
45
+ throw new Error(`Provider ${name} requires an API key.`);
46
+ }
47
+
48
+ return apiKey;
49
+ }
50
+ }
@@ -0,0 +1,48 @@
1
+ import { formatErrorMessage, getHttpError, normalizeFetchedContent } from "../web-fetch/helpers.js";
2
+ import type { WebFetchResponse } from "../web-fetch/types.js";
3
+ import type { WebFetchProvider } from "./index.js";
4
+
5
+ export class JinaWebFetchProvider implements WebFetchProvider {
6
+ constructor(private readonly apiKey?: string) {}
7
+
8
+ async fetch(url: string): Promise<WebFetchResponse> {
9
+ try {
10
+ const response = await fetch(this.toJinaUrl(url), {
11
+ headers: {
12
+ Accept: "text/plain, text/markdown;q=0.9",
13
+ ...(this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}),
14
+ },
15
+ });
16
+
17
+ if (!response.ok) {
18
+ throw new Error(await getHttpError(response));
19
+ }
20
+
21
+ const body = normalizeFetchedContent(await response.text());
22
+ const content = this.extractJinaMarkdown(body);
23
+
24
+ if (!content) {
25
+ throw new Error("Jina returned empty content.");
26
+ }
27
+
28
+ return { provider: "jina", content, url };
29
+ } catch (error) {
30
+ return { provider: "jina", url, error: formatErrorMessage(error) };
31
+ }
32
+ }
33
+
34
+ private toJinaUrl(url: string): string {
35
+ return `https://r.jina.ai/http://${url.replace(/^https?:\/\//, "")}`;
36
+ }
37
+
38
+ private extractJinaMarkdown(body: string): string {
39
+ const marker = "Markdown Content:";
40
+ const markerIndex = body.indexOf(marker);
41
+
42
+ if (markerIndex === -1) {
43
+ return body;
44
+ }
45
+
46
+ return body.slice(markerIndex + marker.length).trim();
47
+ }
48
+ }
@@ -0,0 +1,57 @@
1
+ import TurndownService from "turndown";
2
+ import {
3
+ formatErrorMessage,
4
+ getHttpError,
5
+ isTextLikeContentType,
6
+ normalizeFetchedContent,
7
+ } from "../web-fetch/helpers.js";
8
+ import type { WebFetchResponse } from "../web-fetch/types.js";
9
+ import type { WebFetchProvider } from "./index.js";
10
+
11
+ export class NativeWebFetchProvider implements WebFetchProvider {
12
+ private readonly turndown = new TurndownService();
13
+
14
+ async fetch(url: string): Promise<WebFetchResponse> {
15
+ try {
16
+ const response = await fetch(url, {
17
+ headers: {
18
+ Accept: "text/html, text/plain, text/markdown;q=0.9, */*;q=0.1",
19
+ },
20
+ });
21
+
22
+ if (!response.ok) {
23
+ throw new Error(await getHttpError(response));
24
+ }
25
+
26
+ const contentType = response.headers.get("content-type");
27
+ if (!isTextLikeContentType(contentType)) {
28
+ throw new Error(`Unsupported content type: ${contentType ?? "unknown"}`);
29
+ }
30
+
31
+ const rawBody = await response.text();
32
+ const content = this.normalizeContent(rawBody, contentType);
33
+
34
+ if (!content) {
35
+ throw new Error("Native fetch returned empty content.");
36
+ }
37
+
38
+ return { provider: "native", content, url };
39
+ } catch (error) {
40
+ return { provider: "native", url, error: formatErrorMessage(error) };
41
+ }
42
+ }
43
+
44
+ private normalizeContent(body: string, contentType: string | null): string {
45
+ if (this.looksLikeHtml(body, contentType)) {
46
+ return normalizeFetchedContent(this.turndown.turndown(body));
47
+ }
48
+
49
+ return normalizeFetchedContent(body);
50
+ }
51
+
52
+ private looksLikeHtml(body: string, contentType: string | null): boolean {
53
+ const normalizedType = (contentType ?? "").toLowerCase();
54
+
55
+ return normalizedType.includes("html") || /<html[\s>]/i.test(body) || /<body[\s>]/i.test(body);
56
+ }
57
+ }