summaryrefslogtreecommitdiffhomepage
path: root/js/src
diff options
context:
space:
mode:
authorDax Raad <[email protected]>2025-05-26 14:09:17 -0400
committerDax Raad <[email protected]>2025-05-26 14:09:17 -0400
commit80555f13e052443dc9dc67811bf782a3146de512 (patch)
tree9219001cf50a23032ad968fc036053dfd223b65e /js/src
parent113c49457fd6e37d517e2d212e2e6eb21084b4fb (diff)
downloadopencode-80555f13e052443dc9dc67811bf782a3146de512.tar.gz
opencode-80555f13e052443dc9dc67811bf782a3146de512.zip
more tools
Diffstat (limited to 'js/src')
-rw-r--r--js/src/app/index.ts2
-rw-r--r--js/src/session/session.ts3
-rw-r--r--js/src/tool/fetch.ts137
-rw-r--r--js/src/tool/grep.ts345
-rw-r--r--js/src/tool/index.ts5
-rw-r--r--js/src/tool/ls.ts74
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;
}
-