novacode 0.2.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.
- package/LICENSE +201 -0
- package/README.md +89 -0
- package/package.json +56 -0
- package/src/agent/agent.ts +87 -0
- package/src/agent/loop.ts +218 -0
- package/src/agent/prompt.ts +50 -0
- package/src/commands/compact.ts +28 -0
- package/src/commands/index.ts +62 -0
- package/src/commands/models.ts +86 -0
- package/src/commands/providers.ts +222 -0
- package/src/commands/session.ts +40 -0
- package/src/config/providers.ts +199 -0
- package/src/config/store.ts +67 -0
- package/src/main.ts +169 -0
- package/src/onboarding/wizard.ts +58 -0
- package/src/provider/gemini.ts +254 -0
- package/src/provider/openai.ts +218 -0
- package/src/provider/registry.ts +62 -0
- package/src/provider/stream.ts +77 -0
- package/src/session/compact.ts +126 -0
- package/src/session/store.ts +206 -0
- package/src/tools/fs.ts +195 -0
- package/src/tools/git.ts +82 -0
- package/src/tools/index.ts +33 -0
- package/src/tools/search.ts +252 -0
- package/src/tools/shell.ts +89 -0
- package/src/tools/web.ts +239 -0
- package/src/tui/app.tsx +517 -0
- package/src/tui/markdown.ts +62 -0
- package/src/tui/print.ts +75 -0
- package/src/types.ts +233 -0
- package/src/util.ts +88 -0
package/src/tools/git.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git tools for executing safe repository operations programmatically.
|
|
3
|
+
*/
|
|
4
|
+
import type { Tool, ToolResult } from "../types.ts"
|
|
5
|
+
import { textPart } from "../util.ts"
|
|
6
|
+
|
|
7
|
+
export function gitTool(cwd: string): Tool {
|
|
8
|
+
return {
|
|
9
|
+
def: {
|
|
10
|
+
name: "git",
|
|
11
|
+
description:
|
|
12
|
+
"Execute safe, non-interactive git commands (status, diff, log, add, commit) in the repository.",
|
|
13
|
+
parameters: {
|
|
14
|
+
type: "object",
|
|
15
|
+
properties: {
|
|
16
|
+
action: {
|
|
17
|
+
type: "string",
|
|
18
|
+
enum: ["status", "diff", "log", "add", "commit"],
|
|
19
|
+
description: "The git action to execute",
|
|
20
|
+
},
|
|
21
|
+
args: {
|
|
22
|
+
type: "array",
|
|
23
|
+
description: "Optional additional arguments or file paths for the git action",
|
|
24
|
+
items: { type: "string" },
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
required: ["action"],
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
async execute(args, signal): Promise<ToolResult> {
|
|
31
|
+
const action = args.action as string
|
|
32
|
+
const extraArgs = (args.args as string[]) || []
|
|
33
|
+
|
|
34
|
+
const allowed = new Set(["status", "diff", "log", "add", "commit"])
|
|
35
|
+
if (!allowed.has(action)) {
|
|
36
|
+
return {
|
|
37
|
+
content: [textPart(`Error: Git action '${action}' is not supported.`)],
|
|
38
|
+
isError: true,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const cmd = ["git", action, ...extraArgs]
|
|
44
|
+
const proc = Bun.spawn(cmd, {
|
|
45
|
+
cwd,
|
|
46
|
+
stdout: "pipe",
|
|
47
|
+
stderr: "pipe",
|
|
48
|
+
env: { ...process.env, PAGER: "cat" },
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const onAbort = () => proc.kill()
|
|
52
|
+
signal?.addEventListener("abort", onAbort, { once: true })
|
|
53
|
+
|
|
54
|
+
const exitCode = await proc.exited
|
|
55
|
+
signal?.removeEventListener("abort", onAbort)
|
|
56
|
+
|
|
57
|
+
const stdout = await new Response(proc.stdout).text()
|
|
58
|
+
const stderr = await new Response(proc.stderr).text()
|
|
59
|
+
|
|
60
|
+
// Prevent context window blowout by truncating very large outputs
|
|
61
|
+
const MAX = 50_000
|
|
62
|
+
let out = ""
|
|
63
|
+
if (stdout) out += stdout.slice(0, MAX)
|
|
64
|
+
if (stderr) {
|
|
65
|
+
if (out) out += "\n"
|
|
66
|
+
out += stderr.slice(0, MAX - out.length)
|
|
67
|
+
}
|
|
68
|
+
if (out.length >= MAX) out += "\n…truncated"
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
content: [textPart(out || "(no output)")],
|
|
72
|
+
isError: exitCode !== 0,
|
|
73
|
+
}
|
|
74
|
+
} catch (e) {
|
|
75
|
+
return {
|
|
76
|
+
content: [textPart(`Error running git: ${(e as Error).message}`)],
|
|
77
|
+
isError: true,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Tool } from "../types.ts"
|
|
2
|
+
import { editTool, readTool, writeTool } from "./fs.ts"
|
|
3
|
+
import { gitTool } from "./git.ts"
|
|
4
|
+
import { globTool, grepTool, lsTool, treeTool } from "./search.ts"
|
|
5
|
+
import { bashTool } from "./shell.ts"
|
|
6
|
+
import { webFetchTool, webSearchTool } from "./web.ts"
|
|
7
|
+
|
|
8
|
+
export function getAllTools(cwd: string): Tool[] {
|
|
9
|
+
return [
|
|
10
|
+
readTool(cwd),
|
|
11
|
+
writeTool(cwd),
|
|
12
|
+
editTool(cwd),
|
|
13
|
+
bashTool(cwd),
|
|
14
|
+
globTool(cwd),
|
|
15
|
+
grepTool(cwd),
|
|
16
|
+
lsTool(cwd),
|
|
17
|
+
treeTool(cwd),
|
|
18
|
+
gitTool(cwd),
|
|
19
|
+
webSearchTool(),
|
|
20
|
+
webFetchTool(),
|
|
21
|
+
]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getDefaultTools(cwd: string): Tool[] {
|
|
25
|
+
return [
|
|
26
|
+
readTool(cwd),
|
|
27
|
+
writeTool(cwd),
|
|
28
|
+
editTool(cwd),
|
|
29
|
+
bashTool(cwd),
|
|
30
|
+
webSearchTool(),
|
|
31
|
+
webFetchTool(),
|
|
32
|
+
]
|
|
33
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search tools for finding files and content.
|
|
3
|
+
* Uses 'rg' (ripgrep) if available, falling back to a pure JS implementation.
|
|
4
|
+
*/
|
|
5
|
+
import { readdir } from "node:fs/promises"
|
|
6
|
+
import { relative, resolve } from "node:path"
|
|
7
|
+
import { glob } from "glob"
|
|
8
|
+
import type { Tool, ToolResult } from "../types.ts"
|
|
9
|
+
|
|
10
|
+
import { textPart } from "../util.ts"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Tool for finding files by glob pattern.
|
|
14
|
+
*/
|
|
15
|
+
export function globTool(cwd: string): Tool {
|
|
16
|
+
return {
|
|
17
|
+
def: {
|
|
18
|
+
name: "glob",
|
|
19
|
+
description: "Find files by glob pattern (e.g. **/*.ts, src/**/*.test.ts).",
|
|
20
|
+
parameters: {
|
|
21
|
+
type: "object",
|
|
22
|
+
properties: {
|
|
23
|
+
pattern: { type: "string", description: "Glob pattern (e.g. **/*.ts)" },
|
|
24
|
+
path: { type: "string", description: "Directory to search in (default .)" },
|
|
25
|
+
nocase: { type: "boolean", description: "Case-insensitive search (default false)" },
|
|
26
|
+
},
|
|
27
|
+
required: ["pattern"],
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
async execute(args): Promise<ToolResult> {
|
|
31
|
+
try {
|
|
32
|
+
const rawPath = (args.path as string) || "."
|
|
33
|
+
const dir = resolve(cwd, rawPath)
|
|
34
|
+
if (dir !== cwd && !dir.startsWith(`${cwd}/`)) {
|
|
35
|
+
throw new Error(`Path outside project: ${rawPath}`)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const pattern = args.pattern as string
|
|
39
|
+
const nocase = !!args.nocase
|
|
40
|
+
const files = await glob(pattern, { cwd: dir, nocase })
|
|
41
|
+
const sliced = files.slice(0, 500)
|
|
42
|
+
const relSearchPath = relative(cwd, dir)
|
|
43
|
+
const prefix = relSearchPath ? `${relSearchPath}/` : ""
|
|
44
|
+
const relFiles = sliced.map((f) => prefix + f)
|
|
45
|
+
const out = relFiles.length > 0 ? relFiles.join("\n") : "No files found"
|
|
46
|
+
return { content: [textPart(out)], isError: false }
|
|
47
|
+
} catch (e) {
|
|
48
|
+
return {
|
|
49
|
+
content: [textPart(`Error: ${(e as Error).message}`)],
|
|
50
|
+
isError: true,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Tool for searching file contents using regex.
|
|
59
|
+
*/
|
|
60
|
+
export function grepTool(cwd: string): Tool {
|
|
61
|
+
return {
|
|
62
|
+
def: {
|
|
63
|
+
name: "grep",
|
|
64
|
+
description:
|
|
65
|
+
"Search file contents with a regex pattern. Returns matching lines with file paths and line numbers.",
|
|
66
|
+
parameters: {
|
|
67
|
+
type: "object",
|
|
68
|
+
properties: {
|
|
69
|
+
pattern: { type: "string", description: "Regex pattern to search for" },
|
|
70
|
+
path: { type: "string", description: "Directory or file to search in (default .)" },
|
|
71
|
+
glob: { type: "string", description: "File filter glob (e.g. *.ts)" },
|
|
72
|
+
},
|
|
73
|
+
required: ["pattern"],
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
async execute(args, signal): Promise<ToolResult> {
|
|
77
|
+
try {
|
|
78
|
+
const rawPath = (args.path as string) || "."
|
|
79
|
+
const dir = resolve(cwd, rawPath)
|
|
80
|
+
if (dir !== cwd && !dir.startsWith(`${cwd}/`)) {
|
|
81
|
+
throw new Error(`Path outside project: ${rawPath}`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const pattern = args.pattern as string
|
|
85
|
+
const globFilter = args.glob as string | undefined
|
|
86
|
+
const relSearchPath = relative(cwd, dir) || "."
|
|
87
|
+
|
|
88
|
+
// rg is 10-100x faster than our JS fallback, but isn't always installed
|
|
89
|
+
try {
|
|
90
|
+
const cmd = ["rg", "--line-number", "--max-count", "200"]
|
|
91
|
+
if (globFilter) cmd.push(`--glob=${globFilter}`)
|
|
92
|
+
cmd.push("--", pattern, relSearchPath)
|
|
93
|
+
|
|
94
|
+
const proc = Bun.spawn(cmd, {
|
|
95
|
+
cwd,
|
|
96
|
+
stdout: "pipe",
|
|
97
|
+
stderr: "pipe",
|
|
98
|
+
})
|
|
99
|
+
signal?.addEventListener("abort", () => proc.kill(), { once: true })
|
|
100
|
+
const exitCode = await proc.exited
|
|
101
|
+
signal?.removeEventListener("abort", () => proc.kill())
|
|
102
|
+
|
|
103
|
+
if (exitCode === 0) {
|
|
104
|
+
const out = await new Response(proc.stdout).text()
|
|
105
|
+
const lines = out.split("\n").slice(0, 200).join("\n")
|
|
106
|
+
return { content: [textPart(lines || "No matches")], isError: false }
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
// rg not available, fall through
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Pure JS fallback when rg is not available
|
|
113
|
+
const files = await glob(globFilter || "**/*", { cwd: dir })
|
|
114
|
+
const prefix = relSearchPath === "." ? "" : `${relSearchPath}/`
|
|
115
|
+
const re = new RegExp(pattern, "i")
|
|
116
|
+
const matches: string[] = []
|
|
117
|
+
for (const file of files.slice(0, 500)) {
|
|
118
|
+
if (signal?.aborted) break
|
|
119
|
+
try {
|
|
120
|
+
const content = await Bun.file(resolve(dir, file)).text()
|
|
121
|
+
const lines = content.split("\n")
|
|
122
|
+
for (let i = 0; i < lines.length && matches.length < 200; i++) {
|
|
123
|
+
const line = lines[i]
|
|
124
|
+
if (line && re.test(line)) matches.push(`${prefix}${file}:${i + 1}:${line}`)
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
// Skip binary/unreadable files silently
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
content: [textPart(matches.join("\n") || "No matches")],
|
|
132
|
+
isError: false,
|
|
133
|
+
}
|
|
134
|
+
} catch (e) {
|
|
135
|
+
return {
|
|
136
|
+
content: [textPart(`Error: ${(e as Error).message}`)],
|
|
137
|
+
isError: true,
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Tool for listing directory entries.
|
|
146
|
+
*/
|
|
147
|
+
export function lsTool(cwd: string): Tool {
|
|
148
|
+
return {
|
|
149
|
+
def: {
|
|
150
|
+
name: "ls",
|
|
151
|
+
description: "List directory contents.",
|
|
152
|
+
parameters: {
|
|
153
|
+
type: "object",
|
|
154
|
+
properties: {
|
|
155
|
+
path: { type: "string", description: "Directory to list (default .)" },
|
|
156
|
+
},
|
|
157
|
+
required: [],
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
async execute(args): Promise<ToolResult> {
|
|
161
|
+
try {
|
|
162
|
+
const dir = resolve(cwd, (args.path as string) || ".")
|
|
163
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
164
|
+
const lines = entries.map((e) => {
|
|
165
|
+
const suffix = e.isDirectory() ? "/" : e.isSymbolicLink() ? "@" : ""
|
|
166
|
+
return `${e.name}${suffix}`
|
|
167
|
+
})
|
|
168
|
+
return { content: [textPart(lines.join("\n") || "(empty)")], isError: false }
|
|
169
|
+
} catch (e) {
|
|
170
|
+
return {
|
|
171
|
+
content: [textPart(`Error: ${(e as Error).message}`)],
|
|
172
|
+
isError: true,
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Tool for visualizing a truncated directory tree.
|
|
181
|
+
*/
|
|
182
|
+
export function treeTool(cwd: string): Tool {
|
|
183
|
+
return {
|
|
184
|
+
def: {
|
|
185
|
+
name: "tree",
|
|
186
|
+
description:
|
|
187
|
+
"Print a visual directory tree structure, ignoring common ignored folders like node_modules and .git.",
|
|
188
|
+
parameters: {
|
|
189
|
+
type: "object",
|
|
190
|
+
properties: {
|
|
191
|
+
path: { type: "string", description: "Directory to start tree from (default .)" },
|
|
192
|
+
depth: { type: "number", description: "Maximum depth to traverse (default 3)" },
|
|
193
|
+
},
|
|
194
|
+
required: [],
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
async execute(args): Promise<ToolResult> {
|
|
198
|
+
try {
|
|
199
|
+
const startDir = resolve(cwd, (args.path as string) || ".")
|
|
200
|
+
const maxDepth = Number(args.depth ?? 3) || 3
|
|
201
|
+
|
|
202
|
+
const ignoreList = new Set([
|
|
203
|
+
".git",
|
|
204
|
+
"node_modules",
|
|
205
|
+
"dist",
|
|
206
|
+
"build",
|
|
207
|
+
".svelte-kit",
|
|
208
|
+
".next",
|
|
209
|
+
"out",
|
|
210
|
+
".scannerwork",
|
|
211
|
+
"coverage",
|
|
212
|
+
])
|
|
213
|
+
|
|
214
|
+
async function walk(dir: string, currentDepth: number, prefix: string): Promise<string> {
|
|
215
|
+
if (currentDepth > maxDepth) return ""
|
|
216
|
+
let result = ""
|
|
217
|
+
|
|
218
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
219
|
+
const sorted = entries
|
|
220
|
+
.filter((e) => !ignoreList.has(e.name))
|
|
221
|
+
.sort((a, b) => {
|
|
222
|
+
if (a.isDirectory() && !b.isDirectory()) return -1
|
|
223
|
+
if (!a.isDirectory() && b.isDirectory()) return 1
|
|
224
|
+
return a.name.localeCompare(b.name)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
228
|
+
const entry = sorted[i]!
|
|
229
|
+
const isLast = i === sorted.length - 1
|
|
230
|
+
const connector = isLast ? "└── " : "├── "
|
|
231
|
+
const childPrefix = prefix + (isLast ? " " : "│ ")
|
|
232
|
+
|
|
233
|
+
result += `${prefix}${connector}${entry.name}${entry.isDirectory() ? "/" : ""}\n`
|
|
234
|
+
|
|
235
|
+
if (entry.isDirectory()) {
|
|
236
|
+
result += await walk(resolve(dir, entry.name), currentDepth + 1, childPrefix)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return result
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const treeText = await walk(startDir, 1, "")
|
|
243
|
+
return { content: [textPart(treeText || "(empty)")], isError: false }
|
|
244
|
+
} catch (e) {
|
|
245
|
+
return {
|
|
246
|
+
content: [textPart(`Error: ${(e as Error).message}`)],
|
|
247
|
+
isError: true,
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool for executing shell commands within the project root.
|
|
3
|
+
* Supports timeouts and output truncation to protect the context window.
|
|
4
|
+
*/
|
|
5
|
+
import type { Tool, ToolResult } from "../types.ts"
|
|
6
|
+
|
|
7
|
+
import { textPart } from "../util.ts"
|
|
8
|
+
|
|
9
|
+
export function bashTool(cwd: string): Tool {
|
|
10
|
+
return {
|
|
11
|
+
def: {
|
|
12
|
+
name: "bash",
|
|
13
|
+
description:
|
|
14
|
+
"Execute a shell command. Returns stdout and stderr. Timeout after N seconds (default 120).",
|
|
15
|
+
parameters: {
|
|
16
|
+
type: "object",
|
|
17
|
+
properties: {
|
|
18
|
+
command: { type: "string", description: "Shell command to run" },
|
|
19
|
+
timeout: { type: "number", description: "Timeout in seconds (default 120)" },
|
|
20
|
+
},
|
|
21
|
+
required: ["command"],
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
async execute(args, signal): Promise<ToolResult> {
|
|
25
|
+
const command = args.command as string
|
|
26
|
+
const timeoutMs = (Number(args.timeout) || 120) * 1000
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const proc = Bun.spawn(["sh", "-c", command], {
|
|
30
|
+
cwd,
|
|
31
|
+
stdout: "pipe",
|
|
32
|
+
stderr: "pipe",
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
// Start reading pipes immediately so they don't block the process
|
|
36
|
+
const stdoutPromise = new Response(proc.stdout).text()
|
|
37
|
+
const stderrPromise = new Response(proc.stderr).text()
|
|
38
|
+
|
|
39
|
+
// Track whether we killed it vs normal exit, so the output reflects the cause
|
|
40
|
+
let killed = false
|
|
41
|
+
const timer = setTimeout(() => {
|
|
42
|
+
killed = true
|
|
43
|
+
proc.kill(9) // SIGKILL to be more aggressive against orphans
|
|
44
|
+
}, timeoutMs)
|
|
45
|
+
|
|
46
|
+
const onAbort = () => {
|
|
47
|
+
killed = true
|
|
48
|
+
proc.kill(9)
|
|
49
|
+
}
|
|
50
|
+
signal?.addEventListener("abort", onAbort, { once: true })
|
|
51
|
+
|
|
52
|
+
const exitCode = await proc.exited
|
|
53
|
+
clearTimeout(timer)
|
|
54
|
+
signal?.removeEventListener("abort", onAbort)
|
|
55
|
+
|
|
56
|
+
// After exitCode, pipes should close. We give them a tiny grace period
|
|
57
|
+
// to avoid hanging on orphans.
|
|
58
|
+
const stdout = await Promise.race([
|
|
59
|
+
stdoutPromise,
|
|
60
|
+
new Promise<string>((r) => setTimeout(() => r(""), 500)),
|
|
61
|
+
])
|
|
62
|
+
const stderr = await Promise.race([
|
|
63
|
+
stderrPromise,
|
|
64
|
+
new Promise<string>((r) => setTimeout(() => r(""), 500)),
|
|
65
|
+
])
|
|
66
|
+
|
|
67
|
+
// Prevent context-window blowout from noisy commands
|
|
68
|
+
const MAX = 50_000
|
|
69
|
+
let out = ""
|
|
70
|
+
if (stdout) out += stdout.slice(0, MAX)
|
|
71
|
+
if (stderr) {
|
|
72
|
+
if (out) out += "\n"
|
|
73
|
+
out += stderr.slice(0, MAX - out.length)
|
|
74
|
+
}
|
|
75
|
+
if (out.length >= MAX) out += `\n…truncated`
|
|
76
|
+
|
|
77
|
+
if (killed) out += `\n[timeout after ${timeoutMs / 1000}s]`
|
|
78
|
+
out += `\n[exit ${exitCode}]`
|
|
79
|
+
|
|
80
|
+
return { content: [textPart(out)], isError: exitCode !== 0 || killed }
|
|
81
|
+
} catch (e) {
|
|
82
|
+
return {
|
|
83
|
+
content: [textPart(`Error: ${(e as Error).message}`)],
|
|
84
|
+
isError: true,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
}
|
package/src/tools/web.ts
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web tools for searching and fetching internet content.
|
|
3
|
+
* Uses DuckDuckGo HTML for search (no API key needed) and Bun's built-in
|
|
4
|
+
* fetch for reading URLs.
|
|
5
|
+
*/
|
|
6
|
+
import type { Tool, ToolResult } from "../types.ts"
|
|
7
|
+
import { textPart } from "../util.ts"
|
|
8
|
+
|
|
9
|
+
const MAX_CONTENT = 50_000
|
|
10
|
+
|
|
11
|
+
// Minimal HTML → plaintext: strips tags, decodes entities, collapses whitespace
|
|
12
|
+
function htmlToText(html: string): string {
|
|
13
|
+
let text = html
|
|
14
|
+
// Remove HTML comments
|
|
15
|
+
.replace(/<!--[\s\S]*?-->/g, "")
|
|
16
|
+
// Remove script and style blocks entirely
|
|
17
|
+
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
|
18
|
+
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
|
19
|
+
// Keep link hrefs visible (supports single, double, or no quotes)
|
|
20
|
+
.replace(/<a[^>]*href=["']?([^"'>\s]*)["']?[^>]*>([\s\S]*?)<\/a>/gi, "[$2]($1)")
|
|
21
|
+
// Block-level tags → newlines
|
|
22
|
+
.replace(/<\/?(p|div|br|h[1-6]|li|tr|blockquote|pre|hr)[^>]*>/gi, "\n")
|
|
23
|
+
// Remove remaining tags
|
|
24
|
+
.replace(/<[^>]+>/g, "")
|
|
25
|
+
// Decode common HTML entities (both named and numeric, single/double quotes)
|
|
26
|
+
.replace(/&/g, "&")
|
|
27
|
+
.replace(/</g, "<")
|
|
28
|
+
.replace(/>/g, ">")
|
|
29
|
+
.replace(/"/g, '"')
|
|
30
|
+
.replace(/"/g, '"')
|
|
31
|
+
.replace(/"/g, '"')
|
|
32
|
+
.replace(/'/g, "'")
|
|
33
|
+
.replace(/'/g, "'")
|
|
34
|
+
.replace(/'/g, "'")
|
|
35
|
+
.replace(/“/g, '"')
|
|
36
|
+
.replace(/”/g, '"')
|
|
37
|
+
.replace(/‘/g, "'")
|
|
38
|
+
.replace(/’/g, "'")
|
|
39
|
+
.replace(/ /g, " ")
|
|
40
|
+
// Collapse whitespace but keep paragraph breaks
|
|
41
|
+
.replace(/[ \t]+/g, " ")
|
|
42
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
43
|
+
.trim()
|
|
44
|
+
|
|
45
|
+
if (text.length > MAX_CONTENT) {
|
|
46
|
+
text = `${text.slice(0, MAX_CONTENT)}\n…truncated`
|
|
47
|
+
}
|
|
48
|
+
return text
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function webSearchTool(): Tool {
|
|
52
|
+
return {
|
|
53
|
+
def: {
|
|
54
|
+
name: "web_search",
|
|
55
|
+
description:
|
|
56
|
+
"Search the web using DuckDuckGo. Returns up to 10 results with titles, URLs, and snippets. Use this when you need information from the internet.",
|
|
57
|
+
parameters: {
|
|
58
|
+
type: "object",
|
|
59
|
+
properties: {
|
|
60
|
+
query: { type: "string", description: "Search query" },
|
|
61
|
+
},
|
|
62
|
+
required: ["query"],
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
async execute(args, signal): Promise<ToolResult> {
|
|
66
|
+
const query = args.query as string
|
|
67
|
+
if (!query.trim()) {
|
|
68
|
+
return { content: [textPart("Error: empty search query")], isError: true }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const url = `https://html.duckduckgo.com/html/?q=${encodeURIComponent(query)}`
|
|
73
|
+
const resp = await fetch(url, {
|
|
74
|
+
signal: signal ?? undefined,
|
|
75
|
+
headers: {
|
|
76
|
+
"User-Agent":
|
|
77
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
78
|
+
},
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
if (!resp.ok) {
|
|
82
|
+
return {
|
|
83
|
+
content: [textPart(`Search failed: HTTP ${resp.status}`)],
|
|
84
|
+
isError: true,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const html = await resp.text()
|
|
89
|
+
|
|
90
|
+
// Split HTML into blocks by result__body to isolate each search result safely
|
|
91
|
+
const blocks: string[] = []
|
|
92
|
+
const containerRegex = /<div[^>]*class="[^"]*result__body[^"]*"[^>]*>/gi
|
|
93
|
+
const indices: number[] = []
|
|
94
|
+
for (const match of html.matchAll(containerRegex)) {
|
|
95
|
+
if (match.index !== undefined) {
|
|
96
|
+
indices.push(match.index)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (indices.length > 0) {
|
|
101
|
+
for (let i = 0; i < indices.length; i++) {
|
|
102
|
+
const start = indices[i]!
|
|
103
|
+
const end = indices[i + 1] ?? html.length
|
|
104
|
+
blocks.push(html.slice(start, end))
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const results: string[] = []
|
|
109
|
+
const titleRegex = /<a[^>]*class="result__a"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/i
|
|
110
|
+
const snippetRegex = /<a[^>]*class="result__snippet"[^>]*>([\s\S]*?)<\/a>/i
|
|
111
|
+
|
|
112
|
+
for (const block of blocks) {
|
|
113
|
+
if (results.length >= 10) break
|
|
114
|
+
|
|
115
|
+
const titleMatch = titleRegex.exec(block)
|
|
116
|
+
if (!titleMatch) continue
|
|
117
|
+
|
|
118
|
+
const rawUrl = titleMatch[1]!
|
|
119
|
+
const title = htmlToText(titleMatch[2]!)
|
|
120
|
+
|
|
121
|
+
const snippetMatch = snippetRegex.exec(block)
|
|
122
|
+
const snippet = snippetMatch ? htmlToText(snippetMatch[1]!) : ""
|
|
123
|
+
|
|
124
|
+
// DuckDuckGo wraps URLs through a redirect; extract the actual URL
|
|
125
|
+
let cleanUrl = rawUrl
|
|
126
|
+
try {
|
|
127
|
+
// Prepend protocol/host if DuckDuckGo returns a relative path or protocol-relative URL
|
|
128
|
+
const urlToParse = rawUrl.startsWith("//")
|
|
129
|
+
? `https:${rawUrl}`
|
|
130
|
+
: rawUrl.startsWith("/")
|
|
131
|
+
? `https://duckduckgo.com${rawUrl}`
|
|
132
|
+
: rawUrl
|
|
133
|
+
const param = new URL(urlToParse).searchParams.get("uddg")
|
|
134
|
+
if (param) cleanUrl = param
|
|
135
|
+
} catch {
|
|
136
|
+
// Not a redirect URL, use as-is
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
results.push(`## ${title}\n${cleanUrl}\n${snippet}`)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (results.length === 0) {
|
|
143
|
+
return { content: [textPart("No results found.")], isError: false }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
content: [textPart(results.join("\n\n"))],
|
|
148
|
+
isError: false,
|
|
149
|
+
}
|
|
150
|
+
} catch (e) {
|
|
151
|
+
const msg = (e as Error).message
|
|
152
|
+
if (msg.includes("abort")) {
|
|
153
|
+
return { content: [textPart("Search aborted.")], isError: true }
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
content: [textPart(`Search error: ${msg}`)],
|
|
157
|
+
isError: true,
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function webFetchTool(): Tool {
|
|
165
|
+
return {
|
|
166
|
+
def: {
|
|
167
|
+
name: "web_fetch",
|
|
168
|
+
description:
|
|
169
|
+
"Fetch and read the content of a web page. Returns the page text with HTML tags stripped. Useful for reading documentation, articles, or API references.",
|
|
170
|
+
parameters: {
|
|
171
|
+
type: "object",
|
|
172
|
+
properties: {
|
|
173
|
+
url: { type: "string", description: "URL to fetch" },
|
|
174
|
+
},
|
|
175
|
+
required: ["url"],
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
async execute(args, signal): Promise<ToolResult> {
|
|
179
|
+
const url = args.url as string
|
|
180
|
+
if (!url.trim()) {
|
|
181
|
+
return { content: [textPart("Error: empty URL")], isError: true }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
new URL(url)
|
|
186
|
+
} catch {
|
|
187
|
+
return { content: [textPart(`Error: invalid URL: ${url}`)], isError: true }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
const resp = await fetch(url, {
|
|
192
|
+
signal: signal ?? undefined,
|
|
193
|
+
headers: {
|
|
194
|
+
"User-Agent":
|
|
195
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
196
|
+
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
197
|
+
},
|
|
198
|
+
redirect: "follow",
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
if (!resp.ok) {
|
|
202
|
+
return {
|
|
203
|
+
content: [textPart(`Fetch failed: HTTP ${resp.status} ${resp.statusText}`)],
|
|
204
|
+
isError: true,
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const contentType = (resp.headers.get("content-type") ?? "").toLowerCase()
|
|
209
|
+
const body = await resp.text()
|
|
210
|
+
|
|
211
|
+
// Sniff HTML: check content-type or if body has HTML markup signature
|
|
212
|
+
const isHtml =
|
|
213
|
+
contentType.includes("text/html") ||
|
|
214
|
+
contentType.includes("application/xhtml+xml") ||
|
|
215
|
+
body.trim().toLowerCase().startsWith("<!doctype html") ||
|
|
216
|
+
body.trim().toLowerCase().startsWith("<html")
|
|
217
|
+
|
|
218
|
+
if (isHtml) {
|
|
219
|
+
const text = htmlToText(body)
|
|
220
|
+
return { content: [textPart(text)], isError: false }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// For plain text, JSON, etc. return as-is (truncated if needed)
|
|
224
|
+
const truncated =
|
|
225
|
+
body.length > MAX_CONTENT ? `${body.slice(0, MAX_CONTENT)}\n…truncated` : body
|
|
226
|
+
return { content: [textPart(truncated)], isError: false }
|
|
227
|
+
} catch (e) {
|
|
228
|
+
const msg = (e as Error).message
|
|
229
|
+
if (msg.includes("abort")) {
|
|
230
|
+
return { content: [textPart("Fetch aborted.")], isError: true }
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
content: [textPart(`Fetch error: ${msg}`)],
|
|
234
|
+
isError: true,
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
}
|
|
239
|
+
}
|