min-agent 0.1.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.
@@ -0,0 +1,146 @@
1
+ import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync, writeFileSync } from "fs"
2
+ import path from "path"
3
+ import { randomBytes } from "crypto"
4
+ import { getConfigDir } from "./config.js"
5
+
6
+ /** Default limits (aligned with common agent tooling practice). */
7
+ export const TOOL_OUTPUT_MAX_LINES = 2000
8
+ export const TOOL_OUTPUT_MAX_BYTES = 50 * 1024
9
+
10
+ export type TruncateDirection = "head" | "tail"
11
+
12
+ export interface TruncateToolOutputOptions {
13
+ maxLines?: number
14
+ maxBytes?: number
15
+ direction?: TruncateDirection
16
+ }
17
+
18
+ export interface TruncateToolOutputResult {
19
+ content: string
20
+ truncated: boolean
21
+ outputPath?: string
22
+ }
23
+
24
+ const RETENTION_MS = 7 * 24 * 60 * 60 * 1000
25
+
26
+ function toolOutputDir(): string {
27
+ return path.join(getConfigDir(), "tool-output")
28
+ }
29
+
30
+ function cleanupOldToolOutputs(dir: string): void {
31
+ if (!existsSync(dir)) return
32
+ const now = Date.now()
33
+ try {
34
+ for (const f of readdirSync(dir)) {
35
+ if (!f.startsWith("tool-") || !f.endsWith(".txt")) continue
36
+ const p = path.join(dir, f)
37
+ try {
38
+ if (now - statSync(p).mtimeMs > RETENTION_MS) unlinkSync(p)
39
+ } catch {
40
+ /* ignore */
41
+ }
42
+ }
43
+ } catch {
44
+ /* ignore */
45
+ }
46
+ }
47
+
48
+ /** Write full text to ~/.min-agent/tool-output/ and return absolute path. */
49
+ export function writeFullToolOutput(fullText: string): string {
50
+ const dir = toolOutputDir()
51
+ mkdirSync(dir, { recursive: true })
52
+ cleanupOldToolOutputs(dir)
53
+ const name = `tool-${Date.now()}-${randomBytes(4).toString("hex")}.txt`
54
+ const filePath = path.join(dir, name)
55
+ writeFileSync(filePath, fullText, "utf-8")
56
+ return filePath
57
+ }
58
+
59
+ const hint = (filePath: string) =>
60
+ `The tool output was truncated. Full output saved to: ${filePath}\nUse the read tool with startLine/endLine, or grep, to inspect further.`
61
+
62
+ /** Keep end of text within line/byte limits (good for shell logs). */
63
+ export function tailPreview(text: string, maxLines: number, maxBytes: number): { text: string; cut: boolean } {
64
+ const lines = text.split("\n")
65
+ const totalBytes = Buffer.byteLength(text, "utf-8")
66
+ if (lines.length <= maxLines && totalBytes <= maxBytes) {
67
+ return { text, cut: false }
68
+ }
69
+
70
+ const out: string[] = []
71
+ let bytes = 0
72
+ for (let i = lines.length - 1; i >= 0 && out.length < maxLines; i--) {
73
+ const size = Buffer.byteLength(lines[i], "utf-8") + (out.length > 0 ? 1 : 0)
74
+ if (bytes + size > maxBytes) {
75
+ if (out.length === 0) {
76
+ const buf = Buffer.from(lines[i], "utf-8")
77
+ let start = buf.length - maxBytes
78
+ if (start < 0) start = 0
79
+ while (start < buf.length && (buf[start] & 0xc0) === 0x80) start++
80
+ out.unshift(buf.subarray(start).toString("utf-8"))
81
+ }
82
+ break
83
+ }
84
+ out.unshift(lines[i])
85
+ bytes += size
86
+ }
87
+ return { text: out.join("\n"), cut: true }
88
+ }
89
+
90
+ /** Keep start of text within line/byte limits (good for files / HTTP bodies). */
91
+ export function headPreview(text: string, maxLines: number, maxBytes: number): { text: string; cut: boolean } {
92
+ const lines = text.split("\n")
93
+ const totalBytes = Buffer.byteLength(text, "utf-8")
94
+ if (lines.length <= maxLines && totalBytes <= maxBytes) {
95
+ return { text, cut: false }
96
+ }
97
+
98
+ const out: string[] = []
99
+ let bytes = 0
100
+ for (let i = 0; i < lines.length && out.length < maxLines; i++) {
101
+ const size = Buffer.byteLength(lines[i], "utf-8") + (i > 0 ? 1 : 0)
102
+ if (bytes + size > maxBytes) {
103
+ if (out.length === 0) {
104
+ const buf = Buffer.from(lines[i], "utf-8")
105
+ let end = Math.min(maxBytes, buf.length)
106
+ while (end > 0 && (buf[end - 1] & 0xc0) === 0x80) end--
107
+ out.push(buf.subarray(0, end).toString("utf-8"))
108
+ }
109
+ break
110
+ }
111
+ out.push(lines[i])
112
+ bytes += size
113
+ }
114
+ return { text: out.join("\n"), cut: true }
115
+ }
116
+
117
+ /**
118
+ * If text exceeds limits, write full text to disk and return a preview + path hint.
119
+ * Otherwise returns the original string.
120
+ */
121
+ export function truncateToolOutput(
122
+ text: string,
123
+ options: TruncateToolOutputOptions = {},
124
+ ): TruncateToolOutputResult {
125
+ const maxLines = options.maxLines ?? TOOL_OUTPUT_MAX_LINES
126
+ const maxBytes = options.maxBytes ?? TOOL_OUTPUT_MAX_BYTES
127
+ const direction = options.direction ?? "head"
128
+
129
+ const lines = text.split("\n")
130
+ const totalBytes = Buffer.byteLength(text, "utf-8")
131
+ if (lines.length <= maxLines && totalBytes <= maxBytes) {
132
+ return { content: text, truncated: false }
133
+ }
134
+
135
+ const filePath = writeFullToolOutput(text)
136
+ const preview =
137
+ direction === "tail" ? tailPreview(text, maxLines, maxBytes).text : headPreview(text, maxLines, maxBytes).text
138
+
139
+ const header = "...output truncated...\n\n"
140
+ const content =
141
+ direction === "tail"
142
+ ? `${header}${hint(filePath)}\n\n${preview}`
143
+ : `${preview}\n\n${header}${hint(filePath)}`
144
+
145
+ return { content, truncated: true, outputPath: filePath }
146
+ }
@@ -0,0 +1,108 @@
1
+ import { spawn } from "child_process"
2
+ import { tool, jsonSchema } from "ai"
3
+ import { confirm, isDangerousCommand, isAutoApprove } from "../confirm.js"
4
+ import { truncateToolOutput } from "../tool-output.js"
5
+
6
+ type BashInput = { command: string; timeout?: number }
7
+
8
+ /** Hard cap for in-memory collection before killing the process (avoid OOM on huge stdout). */
9
+ const COLLECT_HARD_CAP_BYTES = 16 * 1024 * 1024
10
+
11
+ function runCommand(command: string, cwd: string, timeoutMs: number): Promise<{
12
+ code: number | null
13
+ output: string
14
+ killedByTimeout: boolean
15
+ killedByCap: boolean
16
+ }> {
17
+ return new Promise((resolve, reject) => {
18
+ const child = spawn(command, {
19
+ shell: true,
20
+ cwd,
21
+ env: process.env as NodeJS.ProcessEnv,
22
+ stdio: ["ignore", "pipe", "pipe"],
23
+ })
24
+ const outChunks: Buffer[] = []
25
+ const errChunks: Buffer[] = []
26
+ let total = 0
27
+ let killedCap = false
28
+ let killedTimeout = false
29
+
30
+ const timer = setTimeout(() => {
31
+ killedTimeout = true
32
+ child.kill("SIGTERM")
33
+ setTimeout(() => child.kill("SIGKILL"), 2000).unref()
34
+ }, timeoutMs)
35
+
36
+ const push = (buf: Buffer, arr: Buffer[]) => {
37
+ total += buf.length
38
+ if (total > COLLECT_HARD_CAP_BYTES && !killedCap) {
39
+ killedCap = true
40
+ child.kill("SIGKILL")
41
+ return
42
+ }
43
+ arr.push(buf)
44
+ }
45
+
46
+ child.stdout?.on("data", (b: Buffer) => push(b, outChunks))
47
+ child.stderr?.on("data", (b: Buffer) => push(b, errChunks))
48
+
49
+ child.on("error", (err) => {
50
+ clearTimeout(timer)
51
+ reject(err)
52
+ })
53
+
54
+ child.on("close", (code) => {
55
+ clearTimeout(timer)
56
+ const stdout = Buffer.concat(outChunks).toString("utf-8")
57
+ const stderr = Buffer.concat(errChunks).toString("utf-8")
58
+ let combined = stdout.replace(/\s+$/, "")
59
+ if (stderr) combined += (combined ? "\n" : "") + stderr.replace(/\s+$/, "")
60
+
61
+ if (killedCap) {
62
+ combined +=
63
+ `\n\n[bash] Output collection stopped: exceeded ${COLLECT_HARD_CAP_BYTES} bytes in-memory cap (process was killed). Prefer redirecting to a file (e.g. > out.txt) then read with startLine/endLine.`
64
+ } else if (killedTimeout) {
65
+ combined += `\n\n[bash] Command exceeded timeout ${timeoutMs} ms (process terminated).`
66
+ }
67
+
68
+ resolve({
69
+ code,
70
+ output: combined || "(no output)",
71
+ killedByTimeout: killedTimeout,
72
+ killedByCap: killedCap,
73
+ })
74
+ })
75
+ })
76
+ }
77
+
78
+ export const bashTool = tool<BashInput, string>({
79
+ description:
80
+ "Run a shell command. Use this for system operations, running builds, tests, git commands, etc. The command runs in the current working directory. Very long stdout/stderr is truncated: full output may be saved under ~/.min-agent/tool-output/ with a preview returned.",
81
+ inputSchema: jsonSchema<BashInput>({
82
+ type: "object",
83
+ properties: {
84
+ command: { type: "string", description: "The shell command to execute" },
85
+ timeout: { type: "number", description: "Timeout in milliseconds (default: 30000)" },
86
+ },
87
+ required: ["command"],
88
+ }),
89
+ execute: async ({ command, timeout }) => {
90
+ if (!isAutoApprove() && isDangerousCommand(command)) {
91
+ const approved = await confirm(`Execute dangerous command: ${command}`)
92
+ if (!approved) return "Command rejected by user."
93
+ }
94
+
95
+ const timeoutMs = timeout ?? 30000
96
+ try {
97
+ const { code, output, killedByTimeout, killedByCap } = await runCommand(command, process.cwd(), timeoutMs)
98
+ let body = output
99
+ if (code !== 0 && code !== null && !killedByTimeout && !killedByCap) {
100
+ body = `Exit code ${code}\n${body}`
101
+ }
102
+ const { content } = truncateToolOutput(body, { direction: "tail" })
103
+ return content.trim() || "(no output)"
104
+ } catch (err: any) {
105
+ return `Error: ${err.message ?? String(err)}`
106
+ }
107
+ },
108
+ })
@@ -0,0 +1,65 @@
1
+ import { tool, jsonSchema } from "ai"
2
+ import { readFileSync, writeFileSync, existsSync } from "fs"
3
+ import path from "path"
4
+ import { confirm, isAutoApprove } from "../confirm.js"
5
+
6
+ type EditInput = {
7
+ filePath: string
8
+ oldText: string
9
+ newText: string
10
+ }
11
+
12
+ export const editTool = tool<EditInput, string>({
13
+ description:
14
+ "Edit a file by replacing a specific text block with new content. The oldText must match exactly (including whitespace and indentation). Use this for precise edits instead of rewriting entire files.",
15
+ inputSchema: jsonSchema<EditInput>({
16
+ type: "object",
17
+ properties: {
18
+ filePath: { type: "string", description: "Path to the file to edit (relative to cwd or absolute)" },
19
+ oldText: { type: "string", description: "The exact text to find and replace (must match exactly)" },
20
+ newText: { type: "string", description: "The new text to replace it with" },
21
+ },
22
+ required: ["filePath", "oldText", "newText"],
23
+ }),
24
+ execute: async ({ filePath, oldText, newText }) => {
25
+ const resolved = path.resolve(process.cwd(), filePath)
26
+
27
+ if (!existsSync(resolved)) {
28
+ return `Error: File not found: ${filePath}`
29
+ }
30
+
31
+ const content = readFileSync(resolved, "utf-8")
32
+ const occurrences = content.split(oldText).length - 1
33
+
34
+ if (occurrences === 0) {
35
+ // Try to help: show nearby content
36
+ const lines = content.split("\n")
37
+ const searchLines = oldText.split("\n")
38
+ const firstLine = searchLines[0].trim()
39
+ const nearbyIdx = lines.findIndex((l) => l.includes(firstLine))
40
+ if (nearbyIdx >= 0) {
41
+ const context = lines.slice(Math.max(0, nearbyIdx - 1), nearbyIdx + 3).join("\n")
42
+ return `Error: oldText not found exactly. Found similar content near line ${nearbyIdx + 1}:\n${context}\n\nMake sure whitespace and indentation match exactly.`
43
+ }
44
+ return `Error: oldText not found in ${filePath}. Make sure the text matches exactly including whitespace.`
45
+ }
46
+
47
+ if (occurrences > 1) {
48
+ return `Error: oldText found ${occurrences} times in ${filePath}. Please provide more context to make the match unique.`
49
+ }
50
+
51
+ // Confirm edit
52
+ if (!isAutoApprove()) {
53
+ const preview = oldText.length > 80 ? oldText.slice(0, 80) + "..." : oldText
54
+ const approved = await confirm(`Edit ${filePath}: replace "${preview}"`)
55
+ if (!approved) return "Edit rejected by user."
56
+ }
57
+
58
+ const updated = content.replace(oldText, newText)
59
+ writeFileSync(resolved, updated, "utf-8")
60
+
61
+ const oldLines = oldText.split("\n").length
62
+ const newLines = newText.split("\n").length
63
+ return `Edited ${filePath}: replaced ${oldLines} line(s) with ${newLines} line(s)`
64
+ },
65
+ })
@@ -0,0 +1,37 @@
1
+ import { tool, jsonSchema } from "ai"
2
+ import { globSync } from "glob"
3
+ import { truncateToolOutput } from "../tool-output.js"
4
+
5
+ type GlobInput = { pattern: string; cwd?: string }
6
+
7
+ export const globTool = tool<GlobInput, string>({
8
+ description:
9
+ "Find files matching a glob pattern. Returns a list of file paths. Use this to discover project structure and find files.",
10
+ inputSchema: jsonSchema<GlobInput>({
11
+ type: "object",
12
+ properties: {
13
+ pattern: { type: "string", description: "Glob pattern to match (e.g. 'src/**/*.ts', '*.json')" },
14
+ cwd: { type: "string", description: "Directory to search in (defaults to working directory)" },
15
+ },
16
+ required: ["pattern"],
17
+ }),
18
+ execute: async ({ pattern, cwd }) => {
19
+ try {
20
+ const matches = globSync(pattern, {
21
+ cwd: cwd ?? process.cwd(),
22
+ ignore: ["**/node_modules/**", "**/.git/**"],
23
+ nodir: true,
24
+ })
25
+ if (matches.length === 0) return "No files found matching pattern"
26
+ let text: string
27
+ if (matches.length > 100) {
28
+ text = matches.slice(0, 100).join("\n") + `\n\n... (${matches.length - 100} more files)`
29
+ } else {
30
+ text = matches.join("\n")
31
+ }
32
+ return truncateToolOutput(text, { direction: "head" }).content
33
+ } catch (err: any) {
34
+ return `Error: ${err.message}`
35
+ }
36
+ },
37
+ })
@@ -0,0 +1,37 @@
1
+ import { tool, jsonSchema } from "ai"
2
+ import { execSync } from "child_process"
3
+ import { truncateToolOutput } from "../tool-output.js"
4
+
5
+ type GrepInput = { pattern: string; path?: string; include?: string }
6
+
7
+ export const grepTool = tool<GrepInput, string>({
8
+ description:
9
+ "Search for a pattern in files using grep. Returns matching lines with file paths and line numbers. Use this to find code references, usages, and definitions.",
10
+ inputSchema: jsonSchema<GrepInput>({
11
+ type: "object",
12
+ properties: {
13
+ pattern: { type: "string", description: "The regex pattern to search for" },
14
+ path: { type: "string", description: "File or directory path to search in (defaults to current directory)" },
15
+ include: { type: "string", description: "File pattern to include (e.g. '*.ts')" },
16
+ },
17
+ required: ["pattern"],
18
+ }),
19
+ execute: async ({ pattern, path: searchPath, include }) => {
20
+ const target = searchPath ?? "."
21
+ const includeFlag = include ? `--include='${include}'` : ""
22
+ const cmd = `grep -rn ${includeFlag} --color=never -E '${pattern.replace(/'/g, "'\\''")}' '${target}' 2>/dev/null | head -50`
23
+ try {
24
+ const output = execSync(cmd, {
25
+ encoding: "utf-8",
26
+ cwd: process.cwd(),
27
+ timeout: 10000,
28
+ maxBuffer: 512 * 1024,
29
+ })
30
+ const text = output.trim() || "No matches found"
31
+ return truncateToolOutput(text, { direction: "head" }).content
32
+ } catch (err: any) {
33
+ if (err.status === 1) return "No matches found"
34
+ return `Error: ${err.message}`
35
+ }
36
+ },
37
+ })
@@ -0,0 +1,21 @@
1
+ import { bashTool } from "./bash.js"
2
+ import { readTool } from "./read.js"
3
+ import { writeTool } from "./write.js"
4
+ import { editTool } from "./edit.js"
5
+ import { globTool } from "./glob.js"
6
+ import { grepTool } from "./grep.js"
7
+ import { webSearchTool } from "./web_search.js"
8
+ import { webFetchTool } from "./web_fetch.js"
9
+
10
+ export function createTools() {
11
+ return {
12
+ bash: bashTool,
13
+ read: readTool,
14
+ write: writeTool,
15
+ edit: editTool,
16
+ glob: globTool,
17
+ grep: grepTool,
18
+ web_search: webSearchTool,
19
+ web_fetch: webFetchTool,
20
+ }
21
+ }
@@ -0,0 +1,38 @@
1
+ import { tool, jsonSchema } from "ai"
2
+ import { readFileSync, statSync } from "fs"
3
+ import path from "path"
4
+ import { truncateToolOutput } from "../tool-output.js"
5
+
6
+ type ReadInput = { filePath: string; startLine?: number; endLine?: number }
7
+
8
+ export const readTool = tool<ReadInput, string>({
9
+ description:
10
+ "Read the contents of a file. Returns the file content as text. Use this to understand code, check configurations, etc.",
11
+ inputSchema: jsonSchema<ReadInput>({
12
+ type: "object",
13
+ properties: {
14
+ filePath: { type: "string", description: "Path to the file to read (relative to cwd or absolute)" },
15
+ startLine: { type: "number", description: "Start line number (1-indexed)" },
16
+ endLine: { type: "number", description: "End line number (1-indexed, inclusive)" },
17
+ },
18
+ required: ["filePath"],
19
+ }),
20
+ execute: async ({ filePath, startLine, endLine }) => {
21
+ const resolved = path.resolve(process.cwd(), filePath)
22
+ try {
23
+ const stat = statSync(resolved)
24
+ if (stat.isDirectory()) return `Error: ${filePath} is a directory, not a file`
25
+ const content = readFileSync(resolved, "utf-8")
26
+ if (startLine || endLine) {
27
+ const lines = content.split("\n")
28
+ const start = (startLine ?? 1) - 1
29
+ const end = endLine ?? lines.length
30
+ const slice = lines.slice(start, end).join("\n")
31
+ return truncateToolOutput(slice, { direction: "head" }).content
32
+ }
33
+ return truncateToolOutput(content, { direction: "head" }).content
34
+ } catch (err: any) {
35
+ return `Error reading file: ${err.message}`
36
+ }
37
+ },
38
+ })
@@ -0,0 +1,87 @@
1
+ import { tool, jsonSchema } from "ai"
2
+ import { truncateToolOutput } from "../tool-output.js"
3
+
4
+ type WebFetchInput = { url: string; method?: string }
5
+
6
+ const FIRECRAWL_BASE = "https://fireclawl.xc.lonae.com"
7
+
8
+ export const webFetchTool = tool<WebFetchInput, string>({
9
+ description:
10
+ "Fetch content from a URL. Use this to access web pages, APIs, or any HTTP resource. Returns the response body as text (or markdown for HTML pages). Supports JavaScript-rendered SPA pages via fallback.",
11
+ inputSchema: jsonSchema<WebFetchInput>({
12
+ type: "object",
13
+ properties: {
14
+ url: { type: "string", description: "The URL to fetch" },
15
+ method: { type: "string", description: "HTTP method (default: GET)" },
16
+ },
17
+ required: ["url"],
18
+ }),
19
+ execute: async ({ url, method }) => {
20
+ // First try: direct fetch
21
+ const directResult = await directFetch(url, method)
22
+
23
+ // If we got meaningful content, return it
24
+ if (directResult && hasContent(directResult)) {
25
+ return directResult
26
+ }
27
+
28
+ // Fallback: use Firecrawl for SPA/dynamic pages
29
+ const firecrawlResult = await firecrawlFetch(url)
30
+ if (firecrawlResult) return firecrawlResult
31
+
32
+ // Return whatever we got from direct fetch
33
+ return directResult || "Failed to fetch content from URL"
34
+ },
35
+ })
36
+
37
+ async function directFetch(url: string, method?: string): Promise<string | null> {
38
+ try {
39
+ const response = await fetch(url, {
40
+ method: method ?? "GET",
41
+ headers: {
42
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
43
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
44
+ },
45
+ signal: AbortSignal.timeout(15000),
46
+ })
47
+ if (!response.ok) return `HTTP ${response.status}`
48
+ const text = await response.text()
49
+ return truncateToolOutput(text, { direction: "head" }).content
50
+ } catch (err: any) {
51
+ return null
52
+ }
53
+ }
54
+
55
+ async function firecrawlFetch(url: string): Promise<string | null> {
56
+ try {
57
+ const response = await fetch(`${FIRECRAWL_BASE}/v1/scrape`, {
58
+ method: "POST",
59
+ headers: { "Content-Type": "application/json" },
60
+ body: JSON.stringify({
61
+ url,
62
+ formats: ["markdown"],
63
+ onlyMainContent: true,
64
+ waitFor: 3000,
65
+ timeout: 30000,
66
+ }),
67
+ signal: AbortSignal.timeout(35000),
68
+ })
69
+ if (!response.ok) return null
70
+ const data = await response.json() as any
71
+ const markdown = data?.data?.markdown || data?.data?.content
72
+ if (!markdown) return null
73
+ return truncateToolOutput(markdown, { direction: "head" }).content
74
+ } catch {
75
+ return null
76
+ }
77
+ }
78
+
79
+ function hasContent(html: string): boolean {
80
+ // Check if the response has meaningful content (not just an empty SPA shell)
81
+ if (html.length < 200) return false
82
+ // SPA shells typically have very little text content outside of script tags
83
+ const withoutScripts = html.replace(/<script[\s\S]*?<\/script>/gi, "")
84
+ const textContent = withoutScripts.replace(/<[^>]*>/g, "").trim()
85
+ // If after removing scripts and tags there's less than 100 chars, it's likely an empty shell
86
+ return textContent.length > 100
87
+ }
@@ -0,0 +1,42 @@
1
+ import { tool, jsonSchema } from "ai"
2
+ import { truncateToolOutput } from "../tool-output.js"
3
+
4
+ type WebSearchInput = { query: string; categories?: string }
5
+
6
+ const SEARXNG_BASE = "https://searxng.xc.lonae.com"
7
+
8
+ export const webSearchTool = tool<WebSearchInput, string>({
9
+ description:
10
+ "Search the web for information. Returns search results with titles, URLs, and snippets. Use this when you need current information, news, documentation, or answers that require up-to-date knowledge.",
11
+ inputSchema: jsonSchema<WebSearchInput>({
12
+ type: "object",
13
+ properties: {
14
+ query: { type: "string", description: "The search query" },
15
+ categories: { type: "string", description: "Search categories: general, news, images, science, it (default: general)" },
16
+ },
17
+ required: ["query"],
18
+ }),
19
+ execute: async ({ query, categories }) => {
20
+ try {
21
+ const params = new URLSearchParams({
22
+ q: query,
23
+ format: "json",
24
+ categories: categories ?? "general",
25
+ })
26
+ const response = await fetch(`${SEARXNG_BASE}/search?${params}`, {
27
+ headers: { "Accept": "application/json" },
28
+ signal: AbortSignal.timeout(15000),
29
+ })
30
+ if (!response.ok) return `Search error: HTTP ${response.status}`
31
+ const data = await response.json() as any
32
+ const results = (data.results ?? []).slice(0, 10)
33
+ if (results.length === 0) return "No search results found. Try a different query."
34
+ const text = results
35
+ .map((r: any, i: number) => `${i + 1}. ${r.title}\n ${r.url}\n ${r.content ?? ""}`)
36
+ .join("\n\n")
37
+ return truncateToolOutput(text, { direction: "head" }).content
38
+ } catch (err: any) {
39
+ return `Search error: ${err.message}`
40
+ }
41
+ },
42
+ })
@@ -0,0 +1,36 @@
1
+ import { tool, jsonSchema } from "ai"
2
+ import { writeFileSync, mkdirSync, existsSync } from "fs"
3
+ import path from "path"
4
+ import { confirm, isAutoApprove } from "../confirm.js"
5
+
6
+ type WriteInput = { filePath: string; content: string }
7
+
8
+ export const writeTool = tool<WriteInput, string>({
9
+ description:
10
+ "Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Creates parent directories as needed.",
11
+ inputSchema: jsonSchema<WriteInput>({
12
+ type: "object",
13
+ properties: {
14
+ filePath: { type: "string", description: "Path to the file to write (relative to cwd or absolute)" },
15
+ content: { type: "string", description: "The content to write to the file" },
16
+ },
17
+ required: ["filePath", "content"],
18
+ }),
19
+ execute: async ({ filePath, content }) => {
20
+ const resolved = path.resolve(process.cwd(), filePath)
21
+
22
+ // Confirm overwriting existing files
23
+ if (!isAutoApprove() && existsSync(resolved)) {
24
+ const approved = await confirm(`Overwrite existing file: ${filePath}`)
25
+ if (!approved) return "Write rejected by user."
26
+ }
27
+
28
+ try {
29
+ mkdirSync(path.dirname(resolved), { recursive: true })
30
+ writeFileSync(resolved, content, "utf-8")
31
+ return `Written ${content.length} bytes to ${filePath}`
32
+ } catch (err: any) {
33
+ return `Error writing file: ${err.message}`
34
+ }
35
+ },
36
+ })
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "esModuleInterop": true,
7
+ "strict": true,
8
+ "skipLibCheck": true,
9
+ "outDir": "dist",
10
+ "rootDir": "src",
11
+ "declaration": true,
12
+ "resolveJsonModule": true
13
+ },
14
+ "include": ["src/**/*"]
15
+ }