diff options
| author | Dax Raad <[email protected]> | 2025-05-26 14:09:17 -0400 |
|---|---|---|
| committer | Dax Raad <[email protected]> | 2025-05-26 14:09:17 -0400 |
| commit | 80555f13e052443dc9dc67811bf782a3146de512 (patch) | |
| tree | 9219001cf50a23032ad968fc036053dfd223b65e /js/src | |
| parent | 113c49457fd6e37d517e2d212e2e6eb21084b4fb (diff) | |
| download | opencode-80555f13e052443dc9dc67811bf782a3146de512.tar.gz opencode-80555f13e052443dc9dc67811bf782a3146de512.zip | |
more tools
Diffstat (limited to 'js/src')
| -rw-r--r-- | js/src/app/index.ts | 2 | ||||
| -rw-r--r-- | js/src/session/session.ts | 3 | ||||
| -rw-r--r-- | js/src/tool/fetch.ts | 137 | ||||
| -rw-r--r-- | js/src/tool/grep.ts | 345 | ||||
| -rw-r--r-- | js/src/tool/index.ts | 5 | ||||
| -rw-r--r-- | js/src/tool/ls.ts | 74 |
6 files changed, 520 insertions, 46 deletions
diff --git a/js/src/app/index.ts b/js/src/app/index.ts index 2db8a36f7..363c398d8 100644 --- a/js/src/app/index.ts +++ b/js/src/app/index.ts @@ -3,7 +3,6 @@ import { AppPath } from "./path"; import { Log } from "../util/log"; import { Context } from "../util/context"; import { Config } from "./config"; -import { Share } from "../share/share"; export namespace App { const log = Log.create({ service: "app" }); @@ -35,7 +34,6 @@ export namespace App { get root() { return input.directory; }, - service<T extends (app: any) => any>(service: any, init: T) {}, }; return result; diff --git a/js/src/session/session.ts b/js/src/session/session.ts index 8f486d580..06a929270 100644 --- a/js/src/session/session.ts +++ b/js/src/session/session.ts @@ -6,6 +6,7 @@ import { Storage } from "../storage/storage"; import { Log } from "../util/log"; import { convertToModelMessages, + stepCountIs, streamText, type TextUIPart, type ToolInvocationUIPart, @@ -169,7 +170,7 @@ export namespace Session { const model = await LLM.findModel("claude-sonnet-4-20250514"); const result = streamText({ - maxSteps: 1000, + stopWhen: stepCountIs(1000), messages: convertToModelMessages(msgs), temperature: 0, tools, diff --git a/js/src/tool/fetch.ts b/js/src/tool/fetch.ts new file mode 100644 index 000000000..741e1897f --- /dev/null +++ b/js/src/tool/fetch.ts @@ -0,0 +1,137 @@ +import { z } from "zod"; +import { Tool } from "./tool"; +import { JSDOM } from "jsdom"; +import TurndownService from "turndown"; + +const MAX_RESPONSE_SIZE = 5 * 1024 * 1024; // 5MB +const DEFAULT_TIMEOUT = 30 * 1000; // 30 seconds +const MAX_TIMEOUT = 120 * 1000; // 2 minutes + +const DESCRIPTION = `Fetches content from a URL and returns it in the specified format. + +WHEN TO USE THIS TOOL: +- Use when you need to download content from a URL +- Helpful for retrieving documentation, API responses, or web content +- Useful for getting external information to assist with tasks + +HOW TO USE: +- Provide the URL to fetch content from +- Specify the desired output format (text, markdown, or html) +- Optionally set a timeout for the request + +FEATURES: +- Supports three output formats: text, markdown, and html +- Automatically handles HTTP redirects +- Sets reasonable timeouts to prevent hanging +- Validates input parameters before making requests + +LIMITATIONS: +- Maximum response size is 5MB +- Only supports HTTP and HTTPS protocols +- Cannot handle authentication or cookies +- Some websites may block automated requests + +TIPS: +- Use text format for plain text content or simple API responses +- Use markdown format for content that should be rendered with formatting +- Use html format when you need the raw HTML structure +- Set appropriate timeouts for potentially slow websites`; + +export const Fetch = Tool.define({ + name: "fetch", + description: DESCRIPTION, + parameters: z.object({ + url: z.string().describe("The URL to fetch content from"), + format: z + .enum(["text", "markdown", "html"]) + .describe( + "The format to return the content in (text, markdown, or html)", + ), + timeout: z + .number() + .min(0) + .max(MAX_TIMEOUT / 1000) + .describe("Optional timeout in seconds (max 120)") + .optional(), + }), + async execute(params, opts) { + // Validate URL + if ( + !params.url.startsWith("http://") && + !params.url.startsWith("https://") + ) { + throw new Error("URL must start with http:// or https://"); + } + + const timeout = Math.min( + (params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, + MAX_TIMEOUT, + ); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + if (opts?.abortSignal) { + opts.abortSignal.addEventListener("abort", () => controller.abort()); + } + + const response = await fetch(params.url, { + signal: controller.signal, + headers: { + "User-Agent": "opencode/1.0", + }, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`Request failed with status code: ${response.status}`); + } + + // Check content length + const contentLength = response.headers.get("content-length"); + if (contentLength && parseInt(contentLength) > MAX_RESPONSE_SIZE) { + throw new Error("Response too large (exceeds 5MB limit)"); + } + + const arrayBuffer = await response.arrayBuffer(); + if (arrayBuffer.byteLength > MAX_RESPONSE_SIZE) { + throw new Error("Response too large (exceeds 5MB limit)"); + } + + const content = new TextDecoder().decode(arrayBuffer); + const contentType = response.headers.get("content-type") || ""; + + switch (params.format) { + case "text": + if (contentType.includes("text/html")) { + const text = extractTextFromHTML(content); + return { output: text }; + } + return { output: content }; + + case "markdown": + if (contentType.includes("text/html")) { + const markdown = convertHTMLToMarkdown(content); + return { output: markdown }; + } + return { output: "```\n" + content + "\n```" }; + + case "html": + return { output: content }; + + default: + return { output: content }; + } + }, +}); + +function extractTextFromHTML(html: string): string { + const dom = new JSDOM(html); + const text = dom.window.document.body?.textContent || ""; + return text.replace(/\s+/g, " ").trim(); +} + +function convertHTMLToMarkdown(html: string): string { + const turndownService = new TurndownService(); + return turndownService.turndown(html); +} diff --git a/js/src/tool/grep.ts b/js/src/tool/grep.ts new file mode 100644 index 000000000..ff5dbdc64 --- /dev/null +++ b/js/src/tool/grep.ts @@ -0,0 +1,345 @@ +import { z } from "zod"; +import { Tool } from "./tool"; +import { App } from "../app"; +import { spawn } from "child_process"; +import { promises as fs } from "fs"; +import path from "path"; + +const DESCRIPTION = `Fast content search tool that finds files containing specific text or patterns, returning matching file paths sorted by modification time (newest first). + +WHEN TO USE THIS TOOL: +- Use when you need to find files containing specific text or patterns +- Great for searching code bases for function names, variable declarations, or error messages +- Useful for finding all files that use a particular API or pattern + +HOW TO USE: +- Provide a regex pattern to search for within file contents +- Set literal_text=true if you want to search for the exact text with special characters (recommended for non-regex users) +- Optionally specify a starting directory (defaults to current working directory) +- Optionally provide an include pattern to filter which files to search +- Results are sorted with most recently modified files first + +REGEX PATTERN SYNTAX (when literal_text=false): +- Supports standard regular expression syntax +- 'function' searches for the literal text "function" +- 'log\\..*Error' finds text starting with "log." and ending with "Error" +- 'import\\s+.*\\s+from' finds import statements in JavaScript/TypeScript + +COMMON INCLUDE PATTERN EXAMPLES: +- '*.js' - Only search JavaScript files +- '*.{ts,tsx}' - Only search TypeScript files +- '*.go' - Only search Go files + +LIMITATIONS: +- Results are limited to 100 files (newest first) +- Performance depends on the number of files being searched +- Very large binary files may be skipped +- Hidden files (starting with '.') are skipped + +TIPS: +- For faster, more targeted searches, first use Glob to find relevant files, then use Grep +- When doing iterative exploration that may require multiple rounds of searching, consider using the Agent tool instead +- Always check if results are truncated and refine your search pattern if needed +- Use literal_text=true when searching for exact text containing special characters like dots, parentheses, etc.`; + +interface GrepMatch { + path: string; + modTime: number; + lineNum: number; + lineText: string; +} + +function escapeRegexPattern(pattern: string): string { + const specialChars = [ + "\\", + ".", + "+", + "*", + "?", + "(", + ")", + "[", + "]", + "{", + "}", + "^", + "$", + "|", + ]; + let escaped = pattern; + + for (const char of specialChars) { + escaped = escaped.replaceAll(char, "\\" + char); + } + + return escaped; +} + +function globToRegex(glob: string): string { + let regexPattern = glob.replaceAll(".", "\\."); + regexPattern = regexPattern.replaceAll("*", ".*"); + regexPattern = regexPattern.replaceAll("?", "."); + + // Handle {a,b,c} patterns + regexPattern = regexPattern.replace(/\{([^}]+)\}/g, (match, inner) => { + return "(" + inner.replace(/,/g, "|") + ")"; + }); + + return regexPattern; +} + +async function searchWithRipgrep( + pattern: string, + searchPath: string, + include?: string, +): Promise<GrepMatch[]> { + return new Promise((resolve, reject) => { + const args = ["-n", pattern]; + if (include) { + args.push("--glob", include); + } + args.push(searchPath); + + const rg = spawn("rg", args); + let output = ""; + let errorOutput = ""; + + rg.stdout.on("data", (data) => { + output += data.toString(); + }); + + rg.stderr.on("data", (data) => { + errorOutput += data.toString(); + }); + + rg.on("close", async (code) => { + if (code === 1) { + // No matches found + resolve([]); + return; + } + + if (code !== 0) { + reject(new Error(`ripgrep failed: ${errorOutput}`)); + return; + } + + const lines = output.trim().split("\n"); + const matches: GrepMatch[] = []; + + for (const line of lines) { + if (!line) continue; + + // Parse ripgrep output format: file:line:content + const parts = line.split(":", 3); + if (parts.length < 3) continue; + + const filePath = parts[0]; + const lineNum = parseInt(parts[1], 10); + const lineText = parts[2]; + + try { + const stats = await fs.stat(filePath); + matches.push({ + path: filePath, + modTime: stats.mtime.getTime(), + lineNum, + lineText, + }); + } catch { + // Skip files we can't access + continue; + } + } + + resolve(matches); + }); + + rg.on("error", (err) => { + reject(err); + }); + }); +} + +async function searchFilesWithRegex( + pattern: string, + rootPath: string, + include?: string, +): Promise<GrepMatch[]> { + const matches: GrepMatch[] = []; + const regex = new RegExp(pattern); + + let includePattern: RegExp | undefined; + if (include) { + const regexPattern = globToRegex(include); + includePattern = new RegExp(regexPattern); + } + + async function walkDir(dir: string) { + if (matches.length >= 200) return; + + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + if (matches.length >= 200) break; + + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + // Skip hidden directories + if (entry.name.startsWith(".")) continue; + await walkDir(fullPath); + } else if (entry.isFile()) { + // Skip hidden files + if (entry.name.startsWith(".")) continue; + + if (includePattern && !includePattern.test(fullPath)) { + continue; + } + + try { + const content = await fs.readFile(fullPath, "utf-8"); + const lines = content.split("\n"); + + for (let i = 0; i < lines.length; i++) { + if (regex.test(lines[i])) { + const stats = await fs.stat(fullPath); + matches.push({ + path: fullPath, + modTime: stats.mtime.getTime(), + lineNum: i + 1, + lineText: lines[i], + }); + break; // Only first match per file + } + } + } catch { + // Skip files we can't read + continue; + } + } + } + } catch { + // Skip directories we can't read + return; + } + } + + await walkDir(rootPath); + return matches; +} + +async function searchFiles( + pattern: string, + rootPath: string, + include?: string, + limit: number = 100, +): Promise<{ matches: GrepMatch[]; truncated: boolean }> { + let matches: GrepMatch[]; + + try { + matches = await searchWithRipgrep(pattern, rootPath, include); + } catch { + matches = await searchFilesWithRegex(pattern, rootPath, include); + } + + // Sort by modification time (newest first) + matches.sort((a, b) => b.modTime - a.modTime); + + const truncated = matches.length > limit; + if (truncated) { + matches = matches.slice(0, limit); + } + + return { matches, truncated }; +} + +export const grep = Tool.define({ + name: "grep", + description: DESCRIPTION, + parameters: z.object({ + pattern: z + .string() + .describe("The regex pattern to search for in file contents"), + path: z + .string() + .describe( + "The directory to search in. Defaults to the current working directory.", + ) + .optional(), + include: z + .string() + .describe( + 'File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")', + ) + .optional(), + literal_text: z + .boolean() + .describe( + "If true, the pattern will be treated as literal text with special regex characters escaped. Default is false.", + ) + .optional(), + }), + async execute(params) { + if (!params.pattern) { + throw new Error("pattern is required"); + } + + const app = await App.use(); + const searchPath = params.path || app.root; + + // If literal_text is true, escape the pattern + const searchPattern = params.literal_text + ? escapeRegexPattern(params.pattern) + : params.pattern; + + const { matches, truncated } = await searchFiles( + searchPattern, + searchPath, + params.include, + 100, + ); + + let output: string; + if (matches.length === 0) { + output = "No files found"; + } else { + const lines = [`Found ${matches.length} matches`]; + + let currentFile = ""; + for (const match of matches) { + if (currentFile !== match.path) { + if (currentFile !== "") { + lines.push(""); + } + currentFile = match.path; + lines.push(`${match.path}:`); + } + if (match.lineNum > 0) { + lines.push(` Line ${match.lineNum}: ${match.lineText}`); + } else { + lines.push(` ${match.path}`); + } + } + + if (truncated) { + lines.push(""); + lines.push( + "(Results are truncated. Consider using a more specific path or pattern.)", + ); + } + + output = lines.join("\n"); + } + + return { + metadata: { + matches: matches.length, + truncated, + }, + output, + }; + }, +}); + diff --git a/js/src/tool/index.ts b/js/src/tool/index.ts index fd74048fb..d7027c529 100644 --- a/js/src/tool/index.ts +++ b/js/src/tool/index.ts @@ -1,6 +1,7 @@ -export * from "./tool"; export * from "./bash"; export * from "./edit"; +export * from "./fetch"; export * from "./glob"; +export * from "./grep"; export * from "./view"; -export * from "./ls";
\ No newline at end of file +export * from "./ls"; diff --git a/js/src/tool/ls.ts b/js/src/tool/ls.ts index 4862601ec..20b8a92d0 100644 --- a/js/src/tool/ls.ts +++ b/js/src/tool/ls.ts @@ -65,9 +65,8 @@ export const ls = Tool.define({ searchPath = path.join(app.root, searchPath); } - try { - await fs.promises.stat(searchPath); - } catch (err) { + const stat = await fs.promises.stat(searchPath).catch(() => null); + if (!stat) { return { metadata: {}, output: `Path does not exist: ${searchPath}`, @@ -88,7 +87,7 @@ export const ls = Tool.define({ return { metadata: { - numberOfFiles: files.length, + count: files.length, truncated, }, output, @@ -110,40 +109,38 @@ async function listDirectory( return; } - try { - const entries = await fs.promises.readdir(dir, { withFileTypes: true }); + const entries = await fs.promises + .readdir(dir, { withFileTypes: true }) + .catch(() => []); - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); - if (shouldSkip(fullPath, ignorePatterns)) { - continue; + if (shouldSkip(fullPath, ignorePatterns)) { + continue; + } + + if (entry.isDirectory()) { + if (fullPath !== initialPath) { + results.push(fullPath + path.sep); } - if (entry.isDirectory()) { - if (fullPath !== initialPath) { - results.push(fullPath + path.sep); - } - - if (results.length < limit) { - await walk(fullPath); - } else { - truncated = true; - return; - } - } else if (entry.isFile()) { - if (fullPath !== initialPath) { - results.push(fullPath); - } - - if (results.length >= limit) { - truncated = true; - return; - } + if (results.length < limit) { + await walk(fullPath); + } else { + truncated = true; + return; + } + } else if (entry.isFile()) { + if (fullPath !== initialPath) { + results.push(fullPath); + } + + if (results.length >= limit) { + truncated = true; + return; } } - } catch (err) { - // Skip directories we don't have permission to access } } @@ -200,13 +197,9 @@ function shouldSkip(filePath: string, ignorePatterns: string[]): boolean { } for (const pattern of ignorePatterns) { - try { - const glob = new Bun.Glob(pattern); - if (glob.match(base)) { - return true; - } - } catch (err) { - // Skip invalid patterns + const glob = new Bun.Glob(pattern); + if (glob.match(base)) { + return true; } } @@ -272,7 +265,7 @@ function printTree(tree: TreeNode[], rootPath: string): string { let result = `- ${rootPath}${path.sep}\n`; for (const node of tree) { - printNode(node, 1, result); + result = printNode(node, 1, result); } return result; @@ -296,4 +289,3 @@ function printNode(node: TreeNode, level: number, result: string): string { return result; } - |
