mirror of
https://github.com/aljazceru/opencode.git
synced 2025-12-30 06:04:20 +01:00
more tools
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
137
js/src/tool/fetch.ts
Normal file
137
js/src/tool/fetch.ts
Normal file
@@ -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);
|
||||
}
|
||||
345
js/src/tool/grep.ts
Normal file
345
js/src/tool/grep.ts
Normal file
@@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
export * from "./ls";
|
||||
|
||||
@@ -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) {
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user