@ottocode/sdk 0.1.173

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 (125) hide show
  1. package/README.md +338 -0
  2. package/package.json +128 -0
  3. package/src/agent/types.ts +19 -0
  4. package/src/auth/src/copilot-oauth.ts +190 -0
  5. package/src/auth/src/index.ts +100 -0
  6. package/src/auth/src/oauth.ts +234 -0
  7. package/src/auth/src/openai-oauth.ts +394 -0
  8. package/src/auth/src/wallet.ts +51 -0
  9. package/src/browser.ts +32 -0
  10. package/src/config/src/index.ts +110 -0
  11. package/src/config/src/manager.ts +181 -0
  12. package/src/config/src/paths.ts +98 -0
  13. package/src/core/src/errors.ts +102 -0
  14. package/src/core/src/index.ts +108 -0
  15. package/src/core/src/providers/resolver.ts +244 -0
  16. package/src/core/src/streaming/artifacts.ts +41 -0
  17. package/src/core/src/terminals/bun-pty.ts +13 -0
  18. package/src/core/src/terminals/circular-buffer.ts +30 -0
  19. package/src/core/src/terminals/ensure-bun-pty.ts +70 -0
  20. package/src/core/src/terminals/index.ts +8 -0
  21. package/src/core/src/terminals/manager.ts +158 -0
  22. package/src/core/src/terminals/rust-libs.ts +30 -0
  23. package/src/core/src/terminals/terminal.ts +132 -0
  24. package/src/core/src/tools/bin-manager.ts +250 -0
  25. package/src/core/src/tools/builtin/bash.ts +155 -0
  26. package/src/core/src/tools/builtin/bash.txt +7 -0
  27. package/src/core/src/tools/builtin/file-cache.ts +39 -0
  28. package/src/core/src/tools/builtin/finish.ts +12 -0
  29. package/src/core/src/tools/builtin/finish.txt +10 -0
  30. package/src/core/src/tools/builtin/fs/cd.ts +19 -0
  31. package/src/core/src/tools/builtin/fs/cd.txt +5 -0
  32. package/src/core/src/tools/builtin/fs/index.ts +20 -0
  33. package/src/core/src/tools/builtin/fs/ls.ts +72 -0
  34. package/src/core/src/tools/builtin/fs/ls.txt +8 -0
  35. package/src/core/src/tools/builtin/fs/pwd.ts +17 -0
  36. package/src/core/src/tools/builtin/fs/pwd.txt +5 -0
  37. package/src/core/src/tools/builtin/fs/read.ts +119 -0
  38. package/src/core/src/tools/builtin/fs/read.txt +8 -0
  39. package/src/core/src/tools/builtin/fs/tree.ts +149 -0
  40. package/src/core/src/tools/builtin/fs/tree.txt +11 -0
  41. package/src/core/src/tools/builtin/fs/util.ts +95 -0
  42. package/src/core/src/tools/builtin/fs/write.ts +106 -0
  43. package/src/core/src/tools/builtin/fs/write.txt +11 -0
  44. package/src/core/src/tools/builtin/git.commit.txt +6 -0
  45. package/src/core/src/tools/builtin/git.diff.txt +5 -0
  46. package/src/core/src/tools/builtin/git.status.txt +5 -0
  47. package/src/core/src/tools/builtin/git.ts +151 -0
  48. package/src/core/src/tools/builtin/glob.ts +128 -0
  49. package/src/core/src/tools/builtin/glob.txt +10 -0
  50. package/src/core/src/tools/builtin/grep.ts +136 -0
  51. package/src/core/src/tools/builtin/grep.txt +9 -0
  52. package/src/core/src/tools/builtin/ignore.ts +45 -0
  53. package/src/core/src/tools/builtin/patch/apply.ts +546 -0
  54. package/src/core/src/tools/builtin/patch/constants.ts +5 -0
  55. package/src/core/src/tools/builtin/patch/normalize.ts +31 -0
  56. package/src/core/src/tools/builtin/patch/parse-enveloped.ts +209 -0
  57. package/src/core/src/tools/builtin/patch/parse-unified.ts +231 -0
  58. package/src/core/src/tools/builtin/patch/parse.ts +28 -0
  59. package/src/core/src/tools/builtin/patch/text.ts +23 -0
  60. package/src/core/src/tools/builtin/patch/types.ts +82 -0
  61. package/src/core/src/tools/builtin/patch.ts +167 -0
  62. package/src/core/src/tools/builtin/patch.txt +207 -0
  63. package/src/core/src/tools/builtin/progress.ts +55 -0
  64. package/src/core/src/tools/builtin/progress.txt +7 -0
  65. package/src/core/src/tools/builtin/ripgrep.ts +125 -0
  66. package/src/core/src/tools/builtin/ripgrep.txt +7 -0
  67. package/src/core/src/tools/builtin/terminal.ts +300 -0
  68. package/src/core/src/tools/builtin/terminal.txt +93 -0
  69. package/src/core/src/tools/builtin/todos.ts +66 -0
  70. package/src/core/src/tools/builtin/todos.txt +7 -0
  71. package/src/core/src/tools/builtin/websearch.ts +250 -0
  72. package/src/core/src/tools/builtin/websearch.txt +12 -0
  73. package/src/core/src/tools/error.ts +67 -0
  74. package/src/core/src/tools/loader.ts +421 -0
  75. package/src/core/src/types/index.ts +11 -0
  76. package/src/core/src/types/types.ts +4 -0
  77. package/src/core/src/utils/ansi.ts +27 -0
  78. package/src/core/src/utils/debug.ts +40 -0
  79. package/src/core/src/utils/logger.ts +150 -0
  80. package/src/index.ts +313 -0
  81. package/src/prompts/src/agents/build.txt +89 -0
  82. package/src/prompts/src/agents/general.txt +15 -0
  83. package/src/prompts/src/agents/plan.txt +10 -0
  84. package/src/prompts/src/agents/research.txt +50 -0
  85. package/src/prompts/src/base.txt +24 -0
  86. package/src/prompts/src/debug.ts +104 -0
  87. package/src/prompts/src/index.ts +1 -0
  88. package/src/prompts/src/modes/oneshot.txt +9 -0
  89. package/src/prompts/src/providers/anthropic.txt +247 -0
  90. package/src/prompts/src/providers/anthropicSpoof.txt +1 -0
  91. package/src/prompts/src/providers/default.txt +466 -0
  92. package/src/prompts/src/providers/google.txt +230 -0
  93. package/src/prompts/src/providers/moonshot.txt +24 -0
  94. package/src/prompts/src/providers/openai.txt +414 -0
  95. package/src/prompts/src/providers.ts +143 -0
  96. package/src/providers/src/anthropic-caching.ts +202 -0
  97. package/src/providers/src/anthropic-oauth-client.ts +157 -0
  98. package/src/providers/src/authorization.ts +17 -0
  99. package/src/providers/src/catalog-manual.ts +135 -0
  100. package/src/providers/src/catalog-merged.ts +9 -0
  101. package/src/providers/src/catalog.ts +8329 -0
  102. package/src/providers/src/copilot-client.ts +39 -0
  103. package/src/providers/src/env.ts +31 -0
  104. package/src/providers/src/google-client.ts +16 -0
  105. package/src/providers/src/index.ts +75 -0
  106. package/src/providers/src/moonshot-client.ts +25 -0
  107. package/src/providers/src/oauth-models.ts +39 -0
  108. package/src/providers/src/openai-oauth-client.ts +108 -0
  109. package/src/providers/src/opencode-client.ts +64 -0
  110. package/src/providers/src/openrouter-client.ts +31 -0
  111. package/src/providers/src/pricing.ts +178 -0
  112. package/src/providers/src/setu-client.ts +643 -0
  113. package/src/providers/src/utils.ts +210 -0
  114. package/src/providers/src/validate.ts +39 -0
  115. package/src/providers/src/zai-client.ts +47 -0
  116. package/src/skills/index.ts +34 -0
  117. package/src/skills/loader.ts +152 -0
  118. package/src/skills/parser.ts +108 -0
  119. package/src/skills/tool.ts +87 -0
  120. package/src/skills/types.ts +41 -0
  121. package/src/skills/validator.ts +110 -0
  122. package/src/types/src/auth.ts +33 -0
  123. package/src/types/src/config.ts +36 -0
  124. package/src/types/src/index.ts +20 -0
  125. package/src/types/src/provider.ts +71 -0
@@ -0,0 +1,300 @@
1
+ import { tool, type Tool } from 'ai';
2
+ import { z } from 'zod/v3';
3
+ import DESCRIPTION from './terminal.txt' with { type: 'text' };
4
+ import { createToolError } from '../error.ts';
5
+ import type { TerminalManager } from '../../terminals/index.ts';
6
+ import type { TerminalStatus } from '../../terminals/terminal.ts';
7
+ import { normalizeTerminalLine } from '../../utils/ansi.ts';
8
+
9
+ function shellQuote(segment: string): string {
10
+ if (/^[a-zA-Z0-9._-]+$/.test(segment)) {
11
+ return segment;
12
+ }
13
+ return `'${segment.replace(/'/g, `'\\''`)}'`;
14
+ }
15
+
16
+ function formatShellCommand(parts: string[]): string {
17
+ return parts.map(shellQuote).join(' ');
18
+ }
19
+
20
+ function normalizePath(p: string) {
21
+ const parts = p.replace(/\\/g, '/').split('/');
22
+ const stack: string[] = [];
23
+ for (const part of parts) {
24
+ if (!part || part === '.') continue;
25
+ if (part === '..') stack.pop();
26
+ else stack.push(part);
27
+ }
28
+ return `/${stack.join('/')}`;
29
+ }
30
+
31
+ function resolveSafePath(projectRoot: string, p: string) {
32
+ const root = normalizePath(projectRoot);
33
+ const abs = normalizePath(`${root}/${p || '.'}`);
34
+ if (!(abs === root || abs.startsWith(`${root}/`))) {
35
+ throw new Error(`cwd escapes project root: ${p}`);
36
+ }
37
+ return abs;
38
+ }
39
+
40
+ export function buildTerminalTool(
41
+ projectRoot: string,
42
+ terminalManager: TerminalManager,
43
+ ): {
44
+ name: string;
45
+ tool: Tool;
46
+ } {
47
+ const terminal = tool({
48
+ description: DESCRIPTION,
49
+ inputSchema: z.object({
50
+ operation: z
51
+ .enum(['start', 'read', 'write', 'interrupt', 'list', 'kill'])
52
+ .describe('Operation to perform'),
53
+
54
+ command: z.string().optional().describe('For start: Command to run'),
55
+ args: z
56
+ .array(z.string())
57
+ .optional()
58
+ .describe('For start: Command arguments'),
59
+ shell: z
60
+ .boolean()
61
+ .default(true)
62
+ .describe(
63
+ 'For start: Launch inside interactive shell and optionally run command',
64
+ ),
65
+ purpose: z
66
+ .string()
67
+ .optional()
68
+ .describe('For start: Description of what this terminal is for'),
69
+ title: z
70
+ .string()
71
+ .optional()
72
+ .describe(
73
+ 'For start: Short name shown in the UI (defaults to purpose)',
74
+ ),
75
+ cwd: z
76
+ .string()
77
+ .default('.')
78
+ .describe('For start: Working directory relative to project root'),
79
+
80
+ terminalId: z
81
+ .string()
82
+ .optional()
83
+ .describe('For read/write/kill: Terminal ID'),
84
+
85
+ lines: z
86
+ .number()
87
+ .default(100)
88
+ .optional()
89
+ .describe('For read: Number of lines to read from end'),
90
+ raw: z
91
+ .boolean()
92
+ .optional()
93
+ .describe(
94
+ 'For read: Include raw output with ANSI escape sequences (default false)',
95
+ ),
96
+
97
+ input: z
98
+ .string()
99
+ .optional()
100
+ .describe('For write: String to write to stdin'),
101
+ }),
102
+ execute: async (params) => {
103
+ try {
104
+ const { operation } = params;
105
+
106
+ switch (operation) {
107
+ case 'start': {
108
+ const runInShell = params.shell;
109
+
110
+ if (!params.command && !runInShell) {
111
+ return createToolError('command is required for start operation');
112
+ }
113
+ if (!params.purpose) {
114
+ return createToolError('purpose is required for start operation');
115
+ }
116
+
117
+ const cwd = resolveSafePath(projectRoot, params.cwd);
118
+
119
+ const shellPath = process.env.SHELL || '/bin/sh';
120
+
121
+ let command = params.command ?? shellPath;
122
+ let args = params.args ?? [];
123
+ let initialCommand: string | null = null;
124
+
125
+ if (runInShell) {
126
+ command = shellPath;
127
+ args = ['-i'];
128
+ const providedCommand = params.command;
129
+ const providedArgs = params.args ?? [];
130
+
131
+ if (providedCommand || providedArgs.length > 0) {
132
+ if (providedArgs.length === 0 && providedCommand) {
133
+ // Command already contains spaces; treat as full shell snippet
134
+ initialCommand = providedCommand;
135
+ } else {
136
+ const commandParts = [
137
+ providedCommand,
138
+ ...providedArgs,
139
+ ].filter((part): part is string => Boolean(part));
140
+ if (commandParts.length > 0) {
141
+ initialCommand = formatShellCommand(commandParts);
142
+ }
143
+ }
144
+ }
145
+ }
146
+
147
+ const term = terminalManager.create({
148
+ command,
149
+ args,
150
+ cwd,
151
+ purpose: params.purpose,
152
+ title: params.title,
153
+ createdBy: 'llm',
154
+ });
155
+
156
+ if (initialCommand) {
157
+ queueMicrotask(() => {
158
+ term.write(`${initialCommand}\n`);
159
+ });
160
+ }
161
+
162
+ return {
163
+ ok: true,
164
+ terminalId: term.id,
165
+ pid: term.pid,
166
+ purpose: term.purpose,
167
+ command: params.command ?? command,
168
+ args: params.args || [],
169
+ shell: runInShell,
170
+ title: term.title,
171
+ message: `Started: ${params.command ?? command}${params.args ? ` ${params.args.join(' ')}` : ''}`,
172
+ };
173
+ }
174
+
175
+ case 'read': {
176
+ if (!params.terminalId) {
177
+ return createToolError(
178
+ 'terminalId is required for read operation',
179
+ );
180
+ }
181
+
182
+ const term = terminalManager.get(params.terminalId);
183
+ if (!term) {
184
+ return createToolError(`Terminal ${params.terminalId} not found`);
185
+ }
186
+
187
+ const output = term.read(params.lines);
188
+ const normalized = output.map(normalizeTerminalLine);
189
+ const joined = normalized.join('\n');
190
+ const text = joined.split(String.fromCharCode(0)).join('');
191
+
192
+ const response: {
193
+ ok: true;
194
+ terminalId: string;
195
+ output: string[];
196
+ status: TerminalStatus;
197
+ exitCode: number | undefined;
198
+ lines: number;
199
+ text: string;
200
+ rawOutput?: string[];
201
+ } = {
202
+ ok: true,
203
+ terminalId: term.id,
204
+ output: normalized,
205
+ status: term.status,
206
+ exitCode: term.exitCode,
207
+ lines: normalized.length,
208
+ text,
209
+ };
210
+
211
+ if (params.raw) {
212
+ response.rawOutput = output;
213
+ }
214
+
215
+ return response;
216
+ }
217
+
218
+ case 'write': {
219
+ if (!params.terminalId) {
220
+ return createToolError(
221
+ 'terminalId is required for write operation',
222
+ );
223
+ }
224
+ if (!params.input) {
225
+ return createToolError('input is required for write operation');
226
+ }
227
+
228
+ const term = terminalManager.get(params.terminalId);
229
+ if (!term) {
230
+ return createToolError(`Terminal ${params.terminalId} not found`);
231
+ }
232
+
233
+ term.write(params.input);
234
+
235
+ return {
236
+ ok: true,
237
+ terminalId: term.id,
238
+ message: `Wrote ${params.input.length} characters to terminal`,
239
+ };
240
+ }
241
+
242
+ case 'interrupt': {
243
+ if (!params.terminalId) {
244
+ return createToolError(
245
+ 'terminalId is required for interrupt operation',
246
+ );
247
+ }
248
+
249
+ const term = terminalManager.get(params.terminalId);
250
+ if (!term) {
251
+ return createToolError(`Terminal ${params.terminalId} not found`);
252
+ }
253
+
254
+ term.write('\u0003');
255
+
256
+ return {
257
+ ok: true,
258
+ terminalId: term.id,
259
+ message: 'Sent SIGINT (Ctrl+C) to terminal',
260
+ };
261
+ }
262
+
263
+ case 'list': {
264
+ const terminals = terminalManager.list();
265
+
266
+ return {
267
+ ok: true,
268
+ terminals: terminals.map((t) => t.toJSON()),
269
+ count: terminals.length,
270
+ };
271
+ }
272
+
273
+ case 'kill': {
274
+ if (!params.terminalId) {
275
+ return createToolError(
276
+ 'terminalId is required for kill operation',
277
+ );
278
+ }
279
+
280
+ await terminalManager.kill(params.terminalId);
281
+
282
+ return {
283
+ ok: true,
284
+ terminalId: params.terminalId,
285
+ message: `Killed terminal ${params.terminalId}`,
286
+ };
287
+ }
288
+
289
+ default:
290
+ return createToolError(`Unknown operation: ${operation}`);
291
+ }
292
+ } catch (error) {
293
+ const message = error instanceof Error ? error.message : String(error);
294
+ return createToolError(`Terminal operation failed: ${message}`);
295
+ }
296
+ },
297
+ });
298
+
299
+ return { name: 'terminal', tool: terminal };
300
+ }
@@ -0,0 +1,93 @@
1
+ - Manage persistent terminals for long-running processes (dev servers, watchers, build processes)
2
+ - Returns terminal information and output
3
+ - Supports creating, reading, writing, listing, and killing terminals
4
+
5
+ ## Operations
6
+
7
+ ### start
8
+ - Spawns a new persistent terminal (interactive shell by default)
9
+ - Returns terminal ID for future operations
10
+ - Use for processes that need to stay alive (dev servers, watchers, logs)
11
+ - Before starting, call `terminal(operation: "list")` to see if a matching service is already running
12
+ - Provide a clear `purpose` or `title` (e.g. "web dev server port 9100") so humans can recognize it
13
+ - Parameters:
14
+ - command (optional when `shell` is true): Command to run
15
+ - purpose (required): Description of what this terminal is for
16
+ - title (optional): Short UI label shown beside the terminal
17
+ - cwd (optional): Working directory relative to project root (default: '.')
18
+ - args (optional): Array of command arguments
19
+ - shell (optional, default: true): Launch an interactive shell and run the command via stdin. Set to `false` to spawn the process directly.
20
+
21
+ ### read
22
+ - Read output from a terminal's buffer (last N lines)
23
+ - Strips ANSI escape codes by default so responses are easy to read
24
+ - Parameters:
25
+ - terminalId (required): Terminal ID from start operation
26
+ - lines (optional): Number of lines to read from end (default: 100)
27
+ - raw (optional): Include `rawOutput` array with ANSI escape sequences (default: false)
28
+ - Returns sanitized output lines, combined `text`, status, and exit code
29
+
30
+ ### write
31
+ - Write input to a terminal's stdin
32
+ - Useful for interactive commands or sending signals
33
+ - Parameters:
34
+ - terminalId (required): Terminal ID
35
+ - input (required): String to write to stdin
36
+
37
+ ### interrupt
38
+ - Sends SIGINT (Ctrl+C) to the terminal without closing the PTY
39
+ - Useful for stopping dev servers or watchers while keeping the shell alive
40
+ - Parameters:
41
+ - terminalId (required): Terminal ID
42
+
43
+ ### list
44
+ - List all active terminals
45
+ - Returns array of terminal metadata (id, purpose, status, pid, uptime)
46
+ - No parameters required
47
+
48
+ ### kill
49
+ - Kill a running terminal
50
+ - Sends SIGTERM by default
51
+ - Parameters:
52
+ - terminalId (required): Terminal ID to kill
53
+
54
+ ## When to Use Terminal vs Bash
55
+
56
+ ### Use terminal for:
57
+ - Dev servers: npm run dev, bun dev
58
+ - File watchers: bun test --watch, nodemon
59
+ - Build watchers: bun build --watch
60
+ - Log tailing: tail -f logs/app.log
61
+ - Background services: docker compose up
62
+ - Any process that needs to stay alive and produce continuous output
63
+
64
+ ### Use bash for:
65
+ - Status checks: git status, ls, ps
66
+ - One-off commands: mkdir, rm, curl
67
+ - Quick scripts: bun run build, git commit
68
+ - File operations: cat, grep, sed
69
+ - Short-lived commands with immediate output
70
+
71
+ ## Example Workflow
72
+
73
+ 1. Start dev server:
74
+ terminal(operation: "start", command: "npm", args: ["run", "dev"], purpose: "dev server")
75
+ → Returns { terminalId: "term-abc123", pid: 12345 }
76
+
77
+ 2. Later, check for errors:
78
+ terminal(operation: "read", terminalId: "term-abc123", lines: 50)
79
+ → Returns last 50 lines of output
80
+
81
+ 3. Kill when done:
82
+ terminal(operation: "kill", terminalId: "term-abc123")
83
+
84
+ ## Notes
85
+
86
+ - Terminals persist across multiple LLM turns (unlike bash commands)
87
+ - Maximum 10 terminals per session
88
+ - Exited terminals auto-cleanup after 5 minutes
89
+ - Output is buffered (last 500 lines kept)
90
+ - Both user-created and LLM-created terminals are visible
91
+ - You can read from user-created terminals to understand context
92
+ - Prefer `read` over `start` when you only need status — avoid duplicating services that already exist
93
+ - Mention running terminals (purpose, command, port) in your responses so humans know what is active
@@ -0,0 +1,66 @@
1
+ import { tool, type Tool } from 'ai';
2
+ import { z } from 'zod/v3';
3
+ import DESCRIPTION from './todos.txt' with { type: 'text' };
4
+
5
+ const STATUS_ENUM = z.enum([
6
+ 'pending',
7
+ 'in_progress',
8
+ 'completed',
9
+ 'cancelled',
10
+ ]);
11
+
12
+ const TODO_SCHEMA = z
13
+ .union([
14
+ z.string().min(1, 'Todo steps must be non-empty'),
15
+ z.object({
16
+ step: z.string().min(1, 'Todo steps must be non-empty'),
17
+ status: STATUS_ENUM.optional(),
18
+ }),
19
+ ])
20
+ .describe('Todo item');
21
+
22
+ type TodoItemInput = z.infer<typeof TODO_SCHEMA>;
23
+
24
+ function normalizeItems(
25
+ raw: TodoItemInput[],
26
+ ): Array<{ step: string; status: z.infer<typeof STATUS_ENUM> }> {
27
+ const normalized = raw.map((item) => {
28
+ if (typeof item === 'string') {
29
+ return { step: item.trim(), status: 'pending' as const };
30
+ }
31
+ const step = item.step.trim();
32
+ const status = item.status ?? 'pending';
33
+ return { step, status };
34
+ });
35
+
36
+ const filtered = normalized.filter((item) => item.step.length > 0);
37
+ if (!filtered.length) {
38
+ throw new Error('At least one todo item is required');
39
+ }
40
+
41
+ const inProgressCount = filtered.filter(
42
+ (item) => item.status === 'in_progress',
43
+ ).length;
44
+ if (inProgressCount > 1) {
45
+ throw new Error('Only one todo item may be marked as in_progress');
46
+ }
47
+
48
+ return filtered;
49
+ }
50
+
51
+ export const updateTodosTool: Tool = tool({
52
+ description: DESCRIPTION,
53
+ inputSchema: z.object({
54
+ todos: z
55
+ .array(TODO_SCHEMA)
56
+ .min(1)
57
+ .describe('The complete list of todo items'),
58
+ note: z
59
+ .string()
60
+ .optional()
61
+ .describe('Optional note or context for the update'),
62
+ }),
63
+ async execute({ todos, note }: { todos: TodoItemInput[]; note?: string }) {
64
+ return { items: normalizeItems(todos), note };
65
+ },
66
+ });
@@ -0,0 +1,7 @@
1
+ - Create and manage a list of subtasks for complex requests
2
+ - Displays ordered todo items with status and optional note to the user
3
+
4
+ Usage tips:
5
+ - Keep descriptions concise (imperative verbs)
6
+ - Update the todos whenever scope changes or new tasks emerge
7
+ - Mark items as in_progress when starting, completed when done
@@ -0,0 +1,250 @@
1
+ import { tool, type Tool } from 'ai';
2
+ import { z } from 'zod/v3';
3
+ import DESCRIPTION from './websearch.txt' with { type: 'text' };
4
+ import { createToolError, type ToolResponse } from '../error.ts';
5
+
6
+ export function buildWebSearchTool(): {
7
+ name: string;
8
+ tool: Tool;
9
+ } {
10
+ const websearch = tool({
11
+ description: DESCRIPTION,
12
+ inputSchema: z
13
+ .object({
14
+ url: z
15
+ .string()
16
+ .optional()
17
+ .describe(
18
+ 'URL to fetch content from (mutually exclusive with query)',
19
+ ),
20
+ query: z
21
+ .string()
22
+ .optional()
23
+ .describe(
24
+ 'Search query to search the web (mutually exclusive with url)',
25
+ ),
26
+ maxLength: z
27
+ .number()
28
+ .optional()
29
+ .default(50000)
30
+ .describe(
31
+ 'Maximum content length to return (default: 50000 characters)',
32
+ ),
33
+ })
34
+ .strict()
35
+ .refine((data) => (data.url ? !data.query : !!data.query), {
36
+ message: 'Must provide either url or query, but not both',
37
+ }),
38
+ async execute({
39
+ url,
40
+ query,
41
+ maxLength,
42
+ }: {
43
+ url?: string;
44
+ query?: string;
45
+ maxLength?: number;
46
+ }): Promise<
47
+ ToolResponse<
48
+ | {
49
+ url: string;
50
+ content: string;
51
+ contentLength: number;
52
+ truncated: boolean;
53
+ contentType: string;
54
+ }
55
+ | {
56
+ query: string;
57
+ results: Array<{ title: string; url: string; snippet: string }>;
58
+ count: number;
59
+ }
60
+ >
61
+ > {
62
+ const maxLen = maxLength ?? 50000;
63
+
64
+ if (url) {
65
+ // Fetch URL content
66
+ try {
67
+ const response = await fetch(url, {
68
+ headers: {
69
+ 'User-Agent':
70
+ 'Mozilla/5.0 (compatible; otto-bot/1.0; +https://github.com/anthropics/otto)',
71
+ Accept:
72
+ 'text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,*/*;q=0.7',
73
+ },
74
+ redirect: 'follow',
75
+ signal: AbortSignal.timeout(30000), // 30 second timeout
76
+ });
77
+
78
+ if (!response.ok) {
79
+ throw new Error(
80
+ `HTTP error! status: ${response.status} ${response.statusText}`,
81
+ );
82
+ }
83
+
84
+ const contentType = response.headers.get('content-type') || '';
85
+ let content = '';
86
+
87
+ if (
88
+ contentType.includes('text/') ||
89
+ contentType.includes('application/json') ||
90
+ contentType.includes('application/xml') ||
91
+ contentType.includes('application/xhtml')
92
+ ) {
93
+ content = await response.text();
94
+ } else {
95
+ return createToolError(
96
+ `Unsupported content type: ${contentType}. Only text-based content can be fetched.`,
97
+ 'unsupported',
98
+ { contentType },
99
+ );
100
+ }
101
+
102
+ // Strip HTML tags for better readability (basic cleaning)
103
+ const cleanContent = content
104
+ .replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
105
+ .replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, '')
106
+ .replace(/<[^>]+>/g, ' ')
107
+ .replace(/\s+/g, ' ')
108
+ .trim();
109
+
110
+ const truncated = cleanContent.slice(0, maxLen);
111
+ const wasTruncated = cleanContent.length > maxLen;
112
+
113
+ return {
114
+ ok: true,
115
+ url,
116
+ content: truncated,
117
+ contentLength: cleanContent.length,
118
+ truncated: wasTruncated,
119
+ contentType,
120
+ };
121
+ } catch (error) {
122
+ const errorMessage =
123
+ error instanceof Error ? error.message : String(error);
124
+ return createToolError(
125
+ `Failed to fetch URL: ${errorMessage}`,
126
+ 'execution',
127
+ { url },
128
+ );
129
+ }
130
+ }
131
+
132
+ if (query) {
133
+ // Web search functionality
134
+ // Use DuckDuckGo's HTML search (doesn't require API key)
135
+ try {
136
+ const searchUrl = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`;
137
+ const response = await fetch(searchUrl, {
138
+ headers: {
139
+ 'User-Agent':
140
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
141
+ Accept: 'text/html',
142
+ },
143
+ redirect: 'follow',
144
+ signal: AbortSignal.timeout(30000),
145
+ });
146
+
147
+ if (!response.ok) {
148
+ throw new Error(`Search failed: ${response.status}`);
149
+ }
150
+
151
+ const html = await response.text();
152
+
153
+ // Parse DuckDuckGo results (basic parsing)
154
+ const results: Array<{
155
+ title: string;
156
+ url: string;
157
+ snippet: string;
158
+ }> = [];
159
+
160
+ // Match result blocks
161
+ const resultPattern =
162
+ /<a[^>]+class="result__a"[^>]+href="([^"]+)"[^>]*>([^<]+)<\/a>[\s\S]*?<a[^>]+class="result__snippet"[^>]*>([\s\S]*?)<\/a>/gi;
163
+
164
+ let match: RegExpExecArray | null = null;
165
+ match = resultPattern.exec(html);
166
+ while (match !== null && results.length < 10) {
167
+ const url = match[1]?.trim();
168
+ const title = match[2]?.trim();
169
+ let snippet = match[3]?.trim();
170
+
171
+ if (url && title) {
172
+ // Clean snippet
173
+ snippet = snippet
174
+ ?.replace(/<[^>]+>/g, '')
175
+ .replace(/\s+/g, ' ')
176
+ .trim();
177
+
178
+ results.push({
179
+ title,
180
+ url,
181
+ snippet: snippet || '',
182
+ });
183
+ }
184
+ match = resultPattern.exec(html);
185
+ }
186
+
187
+ // Fallback: simpler pattern if the above doesn't work
188
+ if (results.length === 0) {
189
+ const simplePattern =
190
+ /<a[^>]+rel="nofollow"[^>]+href="([^"]+)"[^>]*>([^<]+)<\/a>/gi;
191
+ match = simplePattern.exec(html);
192
+ while (match !== null && results.length < 10) {
193
+ const url = match[1]?.trim();
194
+ const title = match[2]?.trim();
195
+ if (url && title && url.startsWith('http')) {
196
+ results.push({
197
+ title,
198
+ url,
199
+ snippet: '',
200
+ });
201
+ }
202
+ match = simplePattern.exec(html);
203
+ }
204
+ }
205
+
206
+ if (results.length === 0) {
207
+ return createToolError(
208
+ 'No search results found. The search service may have changed its format or blocked the request.',
209
+ 'execution',
210
+ {
211
+ query,
212
+ suggestion:
213
+ 'Try using the url parameter to fetch a specific webpage instead.',
214
+ },
215
+ );
216
+ }
217
+
218
+ return {
219
+ ok: true,
220
+ query,
221
+ results,
222
+ count: results.length,
223
+ };
224
+ } catch (error) {
225
+ const errorMessage =
226
+ error instanceof Error ? error.message : String(error);
227
+ return createToolError(
228
+ `Search failed: ${errorMessage}`,
229
+ 'execution',
230
+ {
231
+ query,
232
+ suggestion:
233
+ 'Search services may be temporarily unavailable. Try using the url parameter to fetch a specific webpage instead.',
234
+ },
235
+ );
236
+ }
237
+ }
238
+
239
+ return createToolError(
240
+ 'Must provide either url or query parameter',
241
+ 'validation',
242
+ {
243
+ suggestion: 'Provide either a url to fetch or a query to search',
244
+ },
245
+ );
246
+ },
247
+ });
248
+
249
+ return { name: 'websearch', tool: websearch };
250
+ }