mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-03-31 06:12:26 +00:00
sync
This commit is contained in:
199
packages/opencode/src/tool/bash.ts
Normal file
199
packages/opencode/src/tool/bash.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { z } from "zod";
|
||||
import { Tool } from "./tool";
|
||||
|
||||
const MAX_OUTPUT_LENGTH = 30000;
|
||||
const BANNED_COMMANDS = [
|
||||
"alias",
|
||||
"curl",
|
||||
"curlie",
|
||||
"wget",
|
||||
"axel",
|
||||
"aria2c",
|
||||
"nc",
|
||||
"telnet",
|
||||
"lynx",
|
||||
"w3m",
|
||||
"links",
|
||||
"httpie",
|
||||
"xh",
|
||||
"http-prompt",
|
||||
"chrome",
|
||||
"firefox",
|
||||
"safari",
|
||||
];
|
||||
const DEFAULT_TIMEOUT = 1 * 60 * 1000;
|
||||
const MAX_TIMEOUT = 10 * 60 * 1000;
|
||||
|
||||
const DESCRIPTION = `Executes a given bash command in a persistent shell session with optional timeout, ensuring proper handling and security measures.
|
||||
|
||||
Before executing the command, please follow these steps:
|
||||
|
||||
1. Directory Verification:
|
||||
- If the command will create new directories or files, first use the LS tool to verify the parent directory exists and is the correct location
|
||||
- For example, before running "mkdir foo/bar", first use LS to check that "foo" exists and is the intended parent directory
|
||||
|
||||
2. Security Check:
|
||||
- For security and to limit the threat of a prompt injection attack, some commands are limited or banned. If you use a disallowed command, you will receive an error message explaining the restriction. Explain the error to the User.
|
||||
- Verify that the command is not one of the banned commands: ${BANNED_COMMANDS.join(", ")}.
|
||||
|
||||
3. Command Execution:
|
||||
- After ensuring proper quoting, execute the command.
|
||||
- Capture the output of the command.
|
||||
|
||||
4. Output Processing:
|
||||
- If the output exceeds ${MAX_OUTPUT_LENGTH} characters, output will be truncated before being returned to you.
|
||||
- Prepare the output for display to the user.
|
||||
|
||||
5. Return Result:
|
||||
- Provide the processed output of the command.
|
||||
- If any errors occurred during execution, include those in the output.
|
||||
|
||||
Usage notes:
|
||||
- The command argument is required.
|
||||
- You can specify an optional timeout in milliseconds (up to 600000ms / 10 minutes). If not specified, commands will timeout after 30 minutes.
|
||||
- VERY IMPORTANT: You MUST avoid using search commands like 'find' and 'grep'. Instead use Grep, Glob, or Agent tools to search. You MUST avoid read tools like 'cat', 'head', 'tail', and 'ls', and use FileRead and LS tools to read files.
|
||||
- When issuing multiple commands, use the ';' or '&&' operator to separate them. DO NOT use newlines (newlines are ok in quoted strings).
|
||||
- IMPORTANT: All commands share the same shell session. Shell state (environment variables, virtual environments, current directory, etc.) persist between commands. For example, if you set an environment variable as part of a command, the environment variable will persist for subsequent commands.
|
||||
- Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of 'cd'. You may use 'cd' if the User explicitly requests it.
|
||||
<good-example>
|
||||
pytest /foo/bar/tests
|
||||
</good-example>
|
||||
<bad-example>
|
||||
cd /foo/bar && pytest tests
|
||||
</bad-example>
|
||||
|
||||
# Committing changes with git
|
||||
|
||||
When the user asks you to create a new git commit, follow these steps carefully:
|
||||
|
||||
1. Start with a single message that contains exactly three tool_use blocks that do the following (it is VERY IMPORTANT that you send these tool_use blocks in a single message, otherwise it will feel slow to the user!):
|
||||
- Run a git status command to see all untracked files.
|
||||
- Run a git diff command to see both staged and unstaged changes that will be committed.
|
||||
- Run a git log command to see recent commit messages, so that you can follow this repository's commit message style.
|
||||
|
||||
2. Use the git context at the start of this conversation to determine which files are relevant to your commit. Add relevant untracked files to the staging area. Do not commit files that were already modified at the start of this conversation, if they are not relevant to your commit.
|
||||
|
||||
3. Analyze all staged changes (both previously staged and newly added) and draft a commit message. Wrap your analysis process in <commit_analysis> tags:
|
||||
|
||||
<commit_analysis>
|
||||
- List the files that have been changed or added
|
||||
- Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.)
|
||||
- Brainstorm the purpose or motivation behind these changes
|
||||
- Do not use tools to explore code, beyond what is available in the git context
|
||||
- Assess the impact of these changes on the overall project
|
||||
- Check for any sensitive information that shouldn't be committed
|
||||
- Draft a concise (1-2 sentences) commit message that focuses on the "why" rather than the "what"
|
||||
- Ensure your language is clear, concise, and to the point
|
||||
- Ensure the message accurately reflects the changes and their purpose (i.e. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.)
|
||||
- Ensure the message is not generic (avoid words like "Update" or "Fix" without context)
|
||||
- Review the draft message to ensure it accurately reflects the changes and their purpose
|
||||
</commit_analysis>
|
||||
|
||||
4. Create the commit with a message ending with:
|
||||
🤖 Generated with opencode
|
||||
Co-Authored-By: opencode <noreply@opencode.ai>
|
||||
|
||||
- In order to ensure good formatting, ALWAYS pass the commit message via a HEREDOC, a la this example:
|
||||
<example>
|
||||
git commit -m "$(cat <<'EOF'
|
||||
Commit message here.
|
||||
|
||||
🤖 Generated with opencode
|
||||
Co-Authored-By: opencode <noreply@opencode.ai>
|
||||
EOF
|
||||
)"
|
||||
</example>
|
||||
|
||||
5. If the commit fails due to pre-commit hook changes, retry the commit ONCE to include these automated changes. If it fails again, it usually means a pre-commit hook is preventing the commit. If the commit succeeds but you notice that files were modified by the pre-commit hook, you MUST amend your commit to include them.
|
||||
|
||||
6. Finally, run git status to make sure the commit succeeded.
|
||||
|
||||
Important notes:
|
||||
- When possible, combine the "git add" and "git commit" commands into a single "git commit -am" command, to speed things up
|
||||
- However, be careful not to stage files (e.g. with 'git add .') for commits that aren't part of the change, they may have untracked files they want to keep around, but not commit.
|
||||
- NEVER update the git config
|
||||
- DO NOT push to the remote repository
|
||||
- IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported.
|
||||
- If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit
|
||||
- Ensure your commit message is meaningful and concise. It should explain the purpose of the changes, not just describe them.
|
||||
- Return an empty response - the user will see the git output directly
|
||||
|
||||
# Creating pull requests
|
||||
Use the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a Github URL use the gh command to get the information needed.
|
||||
|
||||
IMPORTANT: When the user asks you to create a pull request, follow these steps carefully:
|
||||
|
||||
1. Understand the current state of the branch. Remember to send a single message that contains multiple tool_use blocks (it is VERY IMPORTANT that you do this in a single message, otherwise it will feel slow to the user!):
|
||||
- Run a git status command to see all untracked files.
|
||||
- Run a git diff command to see both staged and unstaged changes that will be committed.
|
||||
- Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote
|
||||
- Run a git log command and 'git diff main...HEAD' to understand the full commit history for the current branch (from the time it diverged from the 'main' branch.)
|
||||
|
||||
2. Create new branch if needed
|
||||
|
||||
3. Commit changes if needed
|
||||
|
||||
4. Push to remote with -u flag if needed
|
||||
|
||||
5. Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (not just the latest commit, but all commits that will be included in the pull request!), and draft a pull request summary. Wrap your analysis process in <pr_analysis> tags:
|
||||
|
||||
<pr_analysis>
|
||||
- List the commits since diverging from the main branch
|
||||
- Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.)
|
||||
- Brainstorm the purpose or motivation behind these changes
|
||||
- Assess the impact of these changes on the overall project
|
||||
- Do not use tools to explore code, beyond what is available in the git context
|
||||
- Check for any sensitive information that shouldn't be committed
|
||||
- Draft a concise (1-2 bullet points) pull request summary that focuses on the "why" rather than the "what"
|
||||
- Ensure the summary accurately reflects all changes since diverging from the main branch
|
||||
- Ensure your language is clear, concise, and to the point
|
||||
- Ensure the summary accurately reflects the changes and their purpose (ie. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.)
|
||||
- Ensure the summary is not generic (avoid words like "Update" or "Fix" without context)
|
||||
- Review the draft summary to ensure it accurately reflects the changes and their purpose
|
||||
</pr_analysis>
|
||||
|
||||
6. Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.
|
||||
<example>
|
||||
gh pr create --title "the pr title" --body "$(cat <<'EOF'
|
||||
## Summary
|
||||
<1-3 bullet points>
|
||||
|
||||
## Test plan
|
||||
[Checklist of TODOs for testing the pull request...]
|
||||
|
||||
🤖 Generated with opencode
|
||||
EOF
|
||||
)"
|
||||
</example>
|
||||
|
||||
Important:
|
||||
- Return an empty response - the user will see the gh output directly
|
||||
- Never update git config`;
|
||||
|
||||
export const bash = Tool.define({
|
||||
name: "opencode.bash",
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
command: z.string(),
|
||||
timeout: z
|
||||
.number()
|
||||
.min(0)
|
||||
.max(MAX_TIMEOUT)
|
||||
.describe("Optional timeout in milliseconds")
|
||||
.optional(),
|
||||
}),
|
||||
async execute(params) {
|
||||
const timeout = Math.min(params.timeout ?? DEFAULT_TIMEOUT, MAX_TIMEOUT);
|
||||
if (BANNED_COMMANDS.some((item) => params.command.startsWith(item)))
|
||||
throw new Error(`Command '${params.command}' is not allowed`);
|
||||
|
||||
const process = Bun.spawnSync({
|
||||
cmd: ["bash", "-c", params.command],
|
||||
maxBuffer: MAX_OUTPUT_LENGTH,
|
||||
timeout: timeout,
|
||||
});
|
||||
return {
|
||||
output: process.stdout.toString("utf-8"),
|
||||
};
|
||||
},
|
||||
});
|
||||
136
packages/opencode/src/tool/edit.ts
Normal file
136
packages/opencode/src/tool/edit.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { z } from "zod";
|
||||
import * as path from "path";
|
||||
import { Tool } from "./tool";
|
||||
import { FileTimes } from "./util/file-times";
|
||||
import { LSP } from "../lsp";
|
||||
|
||||
const DESCRIPTION = `Edits files by replacing text, creating new files, or deleting content. For moving or renaming files, use the Bash tool with the 'mv' command instead. For larger file edits, use the FileWrite tool to overwrite files.
|
||||
|
||||
Before using this tool:
|
||||
|
||||
1. Use the FileRead tool to understand the file's contents and context
|
||||
|
||||
2. Verify the directory path is correct (only applicable when creating new files):
|
||||
- Use the LS tool to verify the parent directory exists and is the correct location
|
||||
|
||||
To make a file edit, provide the following:
|
||||
1. file_path: The relative path to the file to modify (must be relative, not absolute)
|
||||
2. old_string: The text to replace (must be unique within the file, and must match the file contents exactly, including all whitespace and indentation)
|
||||
3. new_string: The edited text to replace the old_string
|
||||
|
||||
Special cases:
|
||||
- To create a new file: provide file_path and new_string, leave old_string empty
|
||||
- To delete content: provide file_path and old_string, leave new_string empty
|
||||
|
||||
The tool will replace ONE occurrence of old_string with new_string in the specified file.
|
||||
|
||||
CRITICAL REQUIREMENTS FOR USING THIS TOOL:
|
||||
|
||||
1. UNIQUENESS: The old_string MUST uniquely identify the specific instance you want to change. This means:
|
||||
- Include AT LEAST 3-5 lines of context BEFORE the change point
|
||||
- Include AT LEAST 3-5 lines of context AFTER the change point
|
||||
- Include all whitespace, indentation, and surrounding code exactly as it appears in the file
|
||||
|
||||
2. SINGLE INSTANCE: This tool can only change ONE instance at a time. If you need to change multiple instances:
|
||||
- Make separate calls to this tool for each instance
|
||||
- Each call must uniquely identify its specific instance using extensive context
|
||||
|
||||
3. VERIFICATION: Before using this tool:
|
||||
- Check how many instances of the target text exist in the file
|
||||
- If multiple instances exist, gather enough context to uniquely identify each one
|
||||
- Plan separate tool calls for each instance
|
||||
|
||||
WARNING: If you do not follow these requirements:
|
||||
- The tool will fail if old_string matches multiple locations
|
||||
- The tool will fail if old_string doesn't match exactly (including whitespace)
|
||||
- You may change the wrong instance if you don't include enough context
|
||||
|
||||
When making edits:
|
||||
- Ensure the edit results in idiomatic, correct code
|
||||
- Do not leave the code in a broken state
|
||||
- Always use relative file paths
|
||||
|
||||
Remember: when making multiple file edits in a row to the same file, you should prefer to send all edits in a single message with multiple calls to this tool, rather than multiple messages with a single call each.`;
|
||||
|
||||
export const edit = Tool.define({
|
||||
name: "opencode.edit",
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
filePath: z.string().describe("The absolute path to the file to modify"),
|
||||
oldString: z.string().describe("The text to replace"),
|
||||
newString: z.string().describe("The text to replace it with"),
|
||||
}),
|
||||
async execute(params) {
|
||||
if (!params.filePath) {
|
||||
throw new Error("filePath is required");
|
||||
}
|
||||
|
||||
let filePath = params.filePath;
|
||||
if (!path.isAbsolute(filePath)) {
|
||||
filePath = path.join(process.cwd(), filePath);
|
||||
}
|
||||
|
||||
await (async () => {
|
||||
if (params.oldString === "") {
|
||||
await Bun.write(filePath, params.newString);
|
||||
return;
|
||||
}
|
||||
|
||||
const read = FileTimes.get(filePath);
|
||||
if (!read)
|
||||
throw new Error(
|
||||
`You must read the file ${filePath} before editing it. Use the View tool first`,
|
||||
);
|
||||
const file = Bun.file(filePath);
|
||||
if (!(await file.exists())) throw new Error(`File ${filePath} not found`);
|
||||
const stats = await file.stat();
|
||||
if (stats.isDirectory())
|
||||
throw new Error(`Path is a directory, not a file: ${filePath}`);
|
||||
if (stats.mtime.getTime() > read.getTime())
|
||||
throw new Error(
|
||||
`File ${filePath} has been modified since it was last read.\nLast modification: ${read.toISOString()}\nLast read: ${stats.mtime.toISOString()}\n\nPlease read the file again before modifying it.`,
|
||||
);
|
||||
|
||||
const content = await file.text();
|
||||
const index = content.indexOf(params.oldString);
|
||||
if (index === -1)
|
||||
throw new Error(
|
||||
`oldString not found in file. Make sure it matches exactly, including whitespace and line breaks`,
|
||||
);
|
||||
const lastIndex = content.lastIndexOf(params.oldString);
|
||||
if (index !== lastIndex)
|
||||
throw new Error(
|
||||
`oldString appears multiple times in the file. Please provide more context to ensure a unique match`,
|
||||
);
|
||||
|
||||
const newContent =
|
||||
content.substring(0, index) +
|
||||
params.newString +
|
||||
content.substring(index + params.oldString.length);
|
||||
|
||||
await file.write(newContent);
|
||||
})();
|
||||
|
||||
FileTimes.write(filePath);
|
||||
FileTimes.read(filePath);
|
||||
|
||||
let output = "";
|
||||
await LSP.file(filePath);
|
||||
const diagnostics = await LSP.diagnostics();
|
||||
for (const [file, issues] of Object.entries(diagnostics)) {
|
||||
if (issues.length === 0) continue;
|
||||
if (file === filePath) {
|
||||
output += `\nThis file has errors, please fix\n<file_diagnostics>\n${issues.map(LSP.Diagnostic.pretty).join("\n")}\n</file_diagnostics>\n`;
|
||||
continue;
|
||||
}
|
||||
output += `\n<project_diagnostics>\n${file}\n${issues.map(LSP.Diagnostic.pretty).join("\n")}\n</project_diagnostics>\n`;
|
||||
}
|
||||
|
||||
return {
|
||||
metadata: {
|
||||
diagnostics,
|
||||
},
|
||||
output,
|
||||
};
|
||||
},
|
||||
});
|
||||
137
packages/opencode/src/tool/fetch.ts
Normal file
137
packages/opencode/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: "opencode.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);
|
||||
}
|
||||
96
packages/opencode/src/tool/glob.ts
Normal file
96
packages/opencode/src/tool/glob.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { z } from "zod";
|
||||
import { Tool } from "./tool";
|
||||
import { App } from "../app/app";
|
||||
|
||||
const DESCRIPTION = `Fast file pattern matching tool that finds files by name and pattern, returning matching paths sorted by modification time (newest first).
|
||||
|
||||
WHEN TO USE THIS TOOL:
|
||||
- Use when you need to find files by name patterns or extensions
|
||||
- Great for finding specific file types across a directory structure
|
||||
- Useful for discovering files that match certain naming conventions
|
||||
|
||||
HOW TO USE:
|
||||
- Provide a glob pattern to match against file paths
|
||||
- Optionally specify a starting directory (defaults to current working directory)
|
||||
- Results are sorted with most recently modified files first
|
||||
|
||||
GLOB PATTERN SYNTAX:
|
||||
- '*' matches any sequence of non-separator characters
|
||||
- '**' matches any sequence of characters, including separators
|
||||
- '?' matches any single non-separator character
|
||||
- '[...]' matches any character in the brackets
|
||||
- '[!...]' matches any character not in the brackets
|
||||
|
||||
COMMON PATTERN EXAMPLES:
|
||||
- '*.js' - Find all JavaScript files in the current directory
|
||||
- '**/*.js' - Find all JavaScript files in any subdirectory
|
||||
- 'src/**/*.{ts,tsx}' - Find all TypeScript files in the src directory
|
||||
- '*.{html,css,js}' - Find all HTML, CSS, and JS files
|
||||
|
||||
LIMITATIONS:
|
||||
- Results are limited to 100 files (newest first)
|
||||
- Does not search file contents (use Grep tool for that)
|
||||
- Hidden files (starting with '.') are skipped
|
||||
|
||||
TIPS:
|
||||
- For the most useful results, combine with the Grep tool: first find files with Glob, then search their contents with 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`;
|
||||
|
||||
export const glob = Tool.define({
|
||||
name: "opencode.glob",
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
pattern: z.string().describe("The glob pattern to match files against"),
|
||||
path: z
|
||||
.string()
|
||||
.describe(
|
||||
"The directory to search in. Defaults to the current working directory.",
|
||||
)
|
||||
.optional(),
|
||||
}),
|
||||
async execute(params) {
|
||||
const app = await App.use();
|
||||
const search = params.path || app.root;
|
||||
const limit = 100;
|
||||
const glob = new Bun.Glob(params.pattern);
|
||||
const files = [];
|
||||
let truncated = false;
|
||||
for await (const file of glob.scan({ cwd: search })) {
|
||||
if (files.length >= limit) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
const stats = await Bun.file(file)
|
||||
.stat()
|
||||
.then((x) => x.mtime.getTime())
|
||||
.catch(() => 0);
|
||||
files.push({
|
||||
path: file,
|
||||
mtime: stats,
|
||||
});
|
||||
}
|
||||
files.sort((a, b) => b.mtime - a.mtime);
|
||||
|
||||
const output = [];
|
||||
if (files.length === 0) output.push("No files found");
|
||||
if (files.length > 0) {
|
||||
output.push(...files.map((f) => f.path));
|
||||
if (truncated) {
|
||||
output.push("");
|
||||
output.push(
|
||||
"(Results are truncated. Consider using a more specific path or pattern.)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
metadata: {
|
||||
count: files.length,
|
||||
truncated,
|
||||
},
|
||||
output: output.join("\n"),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
345
packages/opencode/src/tool/grep.ts
Normal file
345
packages/opencode/src/tool/grep.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
import { z } from "zod";
|
||||
import { Tool } from "./tool";
|
||||
import { App } from "../app/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, (_, 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: "opencode.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(),
|
||||
literalText: 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 literalText is true, escape the pattern
|
||||
const searchPattern = params.literalText
|
||||
? escapeRegexPattern(params.pattern)
|
||||
: params.pattern;
|
||||
|
||||
const { matches, truncated } = await searchFiles(
|
||||
searchPattern,
|
||||
searchPath,
|
||||
params.include,
|
||||
100,
|
||||
);
|
||||
|
||||
if (matches.length === 0) {
|
||||
return {
|
||||
metadata: { matches: 0, truncated },
|
||||
output: "No files found"
|
||||
};
|
||||
}
|
||||
|
||||
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.)",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
metadata: {
|
||||
matches: matches.length,
|
||||
truncated,
|
||||
},
|
||||
output: lines.join("\n"),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
9
packages/opencode/src/tool/index.ts
Normal file
9
packages/opencode/src/tool/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export * from "./bash";
|
||||
export * from "./edit";
|
||||
export * from "./fetch";
|
||||
export * from "./glob";
|
||||
export * from "./grep";
|
||||
export * from "./view";
|
||||
export * from "./ls";
|
||||
export * from "./lsp-diagnostics";
|
||||
export * from "./lsp-hover";
|
||||
96
packages/opencode/src/tool/ls.ts
Normal file
96
packages/opencode/src/tool/ls.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { z } from "zod";
|
||||
import { Tool } from "./tool";
|
||||
import { App } from "../app/app";
|
||||
import * as path from "path";
|
||||
|
||||
const IGNORE_PATTERNS = [
|
||||
"node_modules/",
|
||||
"__pycache__/",
|
||||
".git/",
|
||||
"dist/",
|
||||
"build/",
|
||||
"target/",
|
||||
"vendor/",
|
||||
"bin/",
|
||||
"obj/",
|
||||
".idea/",
|
||||
".vscode/",
|
||||
];
|
||||
|
||||
export const ls = Tool.define({
|
||||
name: "opencode.ls",
|
||||
description: "List directory contents",
|
||||
parameters: z.object({
|
||||
path: z.string().optional(),
|
||||
ignore: z.array(z.string()).optional(),
|
||||
}),
|
||||
async execute(params) {
|
||||
const app = await App.use();
|
||||
const searchPath = path.resolve(app.root, params.path || ".");
|
||||
|
||||
const glob = new Bun.Glob("**/*");
|
||||
const files = [];
|
||||
|
||||
for await (const file of glob.scan({ cwd: searchPath })) {
|
||||
if (file.startsWith(".") || IGNORE_PATTERNS.some((p) => file.includes(p)))
|
||||
continue;
|
||||
if (params.ignore?.some((pattern) => new Bun.Glob(pattern).match(file)))
|
||||
continue;
|
||||
files.push(file);
|
||||
if (files.length >= 1000) break;
|
||||
}
|
||||
|
||||
// Build directory structure
|
||||
const dirs = new Set<string>();
|
||||
const filesByDir = new Map<string, string[]>();
|
||||
|
||||
for (const file of files) {
|
||||
const dir = path.dirname(file);
|
||||
const parts = dir === "." ? [] : dir.split("/");
|
||||
|
||||
// Add all parent directories
|
||||
for (let i = 0; i <= parts.length; i++) {
|
||||
const dirPath = i === 0 ? "." : parts.slice(0, i).join("/");
|
||||
dirs.add(dirPath);
|
||||
}
|
||||
|
||||
// Add file to its directory
|
||||
if (!filesByDir.has(dir)) filesByDir.set(dir, []);
|
||||
filesByDir.get(dir)!.push(path.basename(file));
|
||||
}
|
||||
|
||||
function renderDir(dirPath: string, depth: number): string {
|
||||
const indent = " ".repeat(depth);
|
||||
let output = "";
|
||||
|
||||
if (depth > 0) {
|
||||
output += `${indent}${path.basename(dirPath)}/\n`;
|
||||
}
|
||||
|
||||
const childIndent = " ".repeat(depth + 1);
|
||||
const children = Array.from(dirs)
|
||||
.filter((d) => path.dirname(d) === dirPath && d !== dirPath)
|
||||
.sort();
|
||||
|
||||
// Render subdirectories first
|
||||
for (const child of children) {
|
||||
output += renderDir(child, depth + 1);
|
||||
}
|
||||
|
||||
// Render files
|
||||
const files = filesByDir.get(dirPath) || [];
|
||||
for (const file of files.sort()) {
|
||||
output += `${childIndent}${file}\n`;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
const output = `${searchPath}/\n` + renderDir(".", 0);
|
||||
|
||||
return {
|
||||
metadata: { count: files.length, truncated: files.length >= 1000 },
|
||||
output,
|
||||
};
|
||||
},
|
||||
});
|
||||
53
packages/opencode/src/tool/lsp-diagnostics.ts
Normal file
53
packages/opencode/src/tool/lsp-diagnostics.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { z } from "zod";
|
||||
import { Tool } from "./tool";
|
||||
import path from "path";
|
||||
import { LSP } from "../lsp";
|
||||
import { App } from "../app/app";
|
||||
|
||||
export const LspDiagnosticTool = Tool.define({
|
||||
name: "opencode.lsp_diagnostic",
|
||||
description: `Get diagnostics for a file and/or project.
|
||||
|
||||
WHEN TO USE THIS TOOL:
|
||||
- Use when you need to check for errors or warnings in your code
|
||||
- Helpful for debugging and ensuring code quality
|
||||
- Good for getting a quick overview of issues in a file or project
|
||||
|
||||
HOW TO USE:
|
||||
- Provide a path to a file to get diagnostics for that file
|
||||
- Results are displayed in a structured format with severity levels
|
||||
|
||||
FEATURES:
|
||||
- Displays errors, warnings, and hints
|
||||
- Groups diagnostics by severity
|
||||
- Provides detailed information about each diagnostic
|
||||
|
||||
LIMITATIONS:
|
||||
- Results are limited to the diagnostics provided by the LSP clients
|
||||
- May not cover all possible issues in the code
|
||||
- Does not provide suggestions for fixing issues
|
||||
|
||||
TIPS:
|
||||
- Use in conjunction with other tools for a comprehensive code review
|
||||
- Combine with the LSP client for real-time diagnostics`,
|
||||
parameters: z.object({
|
||||
path: z.string().describe("The path to the file to get diagnostics."),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
const app = await App.use();
|
||||
const normalized = path.isAbsolute(args.path)
|
||||
? args.path
|
||||
: path.join(app.root, args.path);
|
||||
await LSP.file(normalized);
|
||||
const diagnostics = await LSP.diagnostics();
|
||||
const file = diagnostics[normalized];
|
||||
return {
|
||||
metadata: {
|
||||
diagnostics,
|
||||
},
|
||||
output: file?.length
|
||||
? file.map(LSP.Diagnostic.pretty).join("\n")
|
||||
: "No errors found",
|
||||
};
|
||||
},
|
||||
});
|
||||
38
packages/opencode/src/tool/lsp-hover.ts
Normal file
38
packages/opencode/src/tool/lsp-hover.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { z } from "zod";
|
||||
import { Tool } from "./tool";
|
||||
import path from "path";
|
||||
import { LSP } from "../lsp";
|
||||
import { App } from "../app/app";
|
||||
|
||||
export const LspHoverTool = Tool.define({
|
||||
name: "opencode.lsp_hover",
|
||||
description: `
|
||||
Looks up hover information for a given position in a source file using the Language Server Protocol (LSP).
|
||||
This includes type information, documentation, or symbol details at the specified line and character.
|
||||
Useful for providing code insights, explanations, or context-aware assistance based on the user's current cursor location.
|
||||
`,
|
||||
parameters: z.object({
|
||||
file: z.string().describe("The path to the file to get diagnostics."),
|
||||
line: z.number().describe("The line number to get diagnostics."),
|
||||
character: z.number().describe("The character number to get diagnostics."),
|
||||
}),
|
||||
execute: async (args) => {
|
||||
console.log(args);
|
||||
const app = await App.use();
|
||||
const file = path.isAbsolute(args.file)
|
||||
? args.file
|
||||
: path.join(app.root, args.file);
|
||||
await LSP.file(file);
|
||||
const result = await LSP.hover({
|
||||
...args,
|
||||
file,
|
||||
});
|
||||
console.log(result);
|
||||
return {
|
||||
metadata: {
|
||||
result,
|
||||
},
|
||||
output: JSON.stringify(result, null, 2),
|
||||
};
|
||||
},
|
||||
});
|
||||
420
packages/opencode/src/tool/patch.ts
Normal file
420
packages/opencode/src/tool/patch.ts
Normal file
@@ -0,0 +1,420 @@
|
||||
import { z } from "zod";
|
||||
import * as path from "path";
|
||||
import * as fs from "fs/promises";
|
||||
import { Tool } from "./tool";
|
||||
import { FileTimes } from "./util/file-times";
|
||||
|
||||
const DESCRIPTION = `Applies a patch to multiple files in one operation. This tool is useful for making coordinated changes across multiple files.
|
||||
|
||||
The patch text must follow this format:
|
||||
*** Begin Patch
|
||||
*** Update File: /path/to/file
|
||||
@@ Context line (unique within the file)
|
||||
Line to keep
|
||||
-Line to remove
|
||||
+Line to add
|
||||
Line to keep
|
||||
*** Add File: /path/to/new/file
|
||||
+Content of the new file
|
||||
+More content
|
||||
*** Delete File: /path/to/file/to/delete
|
||||
*** End Patch
|
||||
|
||||
Before using this tool:
|
||||
1. Use the FileRead tool to understand the files' contents and context
|
||||
2. Verify all file paths are correct (use the LS tool)
|
||||
|
||||
CRITICAL REQUIREMENTS FOR USING THIS TOOL:
|
||||
|
||||
1. UNIQUENESS: Context lines MUST uniquely identify the specific sections you want to change
|
||||
2. PRECISION: All whitespace, indentation, and surrounding code must match exactly
|
||||
3. VALIDATION: Ensure edits result in idiomatic, correct code
|
||||
4. PATHS: Always use absolute file paths (starting with /)
|
||||
|
||||
The tool will apply all changes in a single atomic operation.`;
|
||||
|
||||
const PatchParams = z.object({
|
||||
patchText: z
|
||||
.string()
|
||||
.describe("The full patch text that describes all changes to be made"),
|
||||
});
|
||||
|
||||
interface PatchResponseMetadata {
|
||||
changed: string[];
|
||||
additions: number;
|
||||
removals: number;
|
||||
}
|
||||
|
||||
interface Change {
|
||||
type: "add" | "update" | "delete";
|
||||
old_content?: string;
|
||||
new_content?: string;
|
||||
}
|
||||
|
||||
interface Commit {
|
||||
changes: Record<string, Change>;
|
||||
}
|
||||
|
||||
interface PatchOperation {
|
||||
type: "update" | "add" | "delete";
|
||||
filePath: string;
|
||||
hunks?: PatchHunk[];
|
||||
content?: string;
|
||||
}
|
||||
|
||||
interface PatchHunk {
|
||||
contextLine: string;
|
||||
changes: PatchChange[];
|
||||
}
|
||||
|
||||
interface PatchChange {
|
||||
type: "keep" | "remove" | "add";
|
||||
content: string;
|
||||
}
|
||||
|
||||
function identifyFilesNeeded(patchText: string): string[] {
|
||||
const files: string[] = [];
|
||||
const lines = patchText.split("\n");
|
||||
for (const line of lines) {
|
||||
if (
|
||||
line.startsWith("*** Update File:") ||
|
||||
line.startsWith("*** Delete File:")
|
||||
) {
|
||||
const filePath = line.split(":", 2)[1]?.trim();
|
||||
if (filePath) files.push(filePath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function identifyFilesAdded(patchText: string): string[] {
|
||||
const files: string[] = [];
|
||||
const lines = patchText.split("\n");
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("*** Add File:")) {
|
||||
const filePath = line.split(":", 2)[1]?.trim();
|
||||
if (filePath) files.push(filePath);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function textToPatch(
|
||||
patchText: string,
|
||||
_currentFiles: Record<string, string>,
|
||||
): [PatchOperation[], number] {
|
||||
const operations: PatchOperation[] = [];
|
||||
const lines = patchText.split("\n");
|
||||
let i = 0;
|
||||
let fuzz = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
if (line.startsWith("*** Update File:")) {
|
||||
const filePath = line.split(":", 2)[1]?.trim();
|
||||
if (!filePath) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const hunks: PatchHunk[] = [];
|
||||
i++;
|
||||
|
||||
while (i < lines.length && !lines[i].startsWith("***")) {
|
||||
if (lines[i].startsWith("@@")) {
|
||||
const contextLine = lines[i].substring(2).trim();
|
||||
const changes: PatchChange[] = [];
|
||||
i++;
|
||||
|
||||
while (
|
||||
i < lines.length &&
|
||||
!lines[i].startsWith("@@") &&
|
||||
!lines[i].startsWith("***")
|
||||
) {
|
||||
const changeLine = lines[i];
|
||||
if (changeLine.startsWith(" ")) {
|
||||
changes.push({ type: "keep", content: changeLine.substring(1) });
|
||||
} else if (changeLine.startsWith("-")) {
|
||||
changes.push({
|
||||
type: "remove",
|
||||
content: changeLine.substring(1),
|
||||
});
|
||||
} else if (changeLine.startsWith("+")) {
|
||||
changes.push({ type: "add", content: changeLine.substring(1) });
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
hunks.push({ contextLine, changes });
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
operations.push({ type: "update", filePath, hunks });
|
||||
} else if (line.startsWith("*** Add File:")) {
|
||||
const filePath = line.split(":", 2)[1]?.trim();
|
||||
if (!filePath) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
let content = "";
|
||||
i++;
|
||||
|
||||
while (i < lines.length && !lines[i].startsWith("***")) {
|
||||
if (lines[i].startsWith("+")) {
|
||||
content += lines[i].substring(1) + "\n";
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
operations.push({ type: "add", filePath, content: content.slice(0, -1) });
|
||||
} else if (line.startsWith("*** Delete File:")) {
|
||||
const filePath = line.split(":", 2)[1]?.trim();
|
||||
if (filePath) {
|
||||
operations.push({ type: "delete", filePath });
|
||||
}
|
||||
i++;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return [operations, fuzz];
|
||||
}
|
||||
|
||||
function patchToCommit(
|
||||
operations: PatchOperation[],
|
||||
currentFiles: Record<string, string>,
|
||||
): Commit {
|
||||
const changes: Record<string, Change> = {};
|
||||
|
||||
for (const op of operations) {
|
||||
if (op.type === "delete") {
|
||||
changes[op.filePath] = {
|
||||
type: "delete",
|
||||
old_content: currentFiles[op.filePath] || "",
|
||||
};
|
||||
} else if (op.type === "add") {
|
||||
changes[op.filePath] = {
|
||||
type: "add",
|
||||
new_content: op.content || "",
|
||||
};
|
||||
} else if (op.type === "update" && op.hunks) {
|
||||
const originalContent = currentFiles[op.filePath] || "";
|
||||
const lines = originalContent.split("\n");
|
||||
|
||||
for (const hunk of op.hunks) {
|
||||
const contextIndex = lines.findIndex((line) =>
|
||||
line.includes(hunk.contextLine),
|
||||
);
|
||||
if (contextIndex === -1) {
|
||||
throw new Error(`Context line not found: ${hunk.contextLine}`);
|
||||
}
|
||||
|
||||
let currentIndex = contextIndex;
|
||||
for (const change of hunk.changes) {
|
||||
if (change.type === "keep") {
|
||||
currentIndex++;
|
||||
} else if (change.type === "remove") {
|
||||
lines.splice(currentIndex, 1);
|
||||
} else if (change.type === "add") {
|
||||
lines.splice(currentIndex, 0, change.content);
|
||||
currentIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
changes[op.filePath] = {
|
||||
type: "update",
|
||||
old_content: originalContent,
|
||||
new_content: lines.join("\n"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { changes };
|
||||
}
|
||||
|
||||
function generateDiff(
|
||||
oldContent: string,
|
||||
newContent: string,
|
||||
filePath: string,
|
||||
): [string, number, number] {
|
||||
// Mock implementation - would need actual diff generation
|
||||
const lines1 = oldContent.split("\n");
|
||||
const lines2 = newContent.split("\n");
|
||||
const additions = Math.max(0, lines2.length - lines1.length);
|
||||
const removals = Math.max(0, lines1.length - lines2.length);
|
||||
return [`--- ${filePath}\n+++ ${filePath}\n`, additions, removals];
|
||||
}
|
||||
|
||||
async function applyCommit(
|
||||
commit: Commit,
|
||||
writeFile: (path: string, content: string) => Promise<void>,
|
||||
deleteFile: (path: string) => Promise<void>,
|
||||
): Promise<void> {
|
||||
for (const [filePath, change] of Object.entries(commit.changes)) {
|
||||
if (change.type === "delete") {
|
||||
await deleteFile(filePath);
|
||||
} else if (change.new_content !== undefined) {
|
||||
await writeFile(filePath, change.new_content);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const patch = Tool.define({
|
||||
name: "opencode.patch",
|
||||
description: DESCRIPTION,
|
||||
parameters: PatchParams,
|
||||
execute: async (params) => {
|
||||
if (!params.patchText) {
|
||||
throw new Error("patchText is required");
|
||||
}
|
||||
|
||||
// Identify all files needed for the patch and verify they've been read
|
||||
const filesToRead = identifyFilesNeeded(params.patchText);
|
||||
for (const filePath of filesToRead) {
|
||||
let absPath = filePath;
|
||||
if (!path.isAbsolute(absPath)) {
|
||||
absPath = path.resolve(process.cwd(), absPath);
|
||||
}
|
||||
|
||||
if (!FileTimes.get(absPath)) {
|
||||
throw new Error(
|
||||
`you must read the file ${filePath} before patching it. Use the FileRead tool first`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await fs.stat(absPath);
|
||||
if (stats.isDirectory()) {
|
||||
throw new Error(`path is a directory, not a file: ${absPath}`);
|
||||
}
|
||||
|
||||
const lastRead = FileTimes.get(absPath);
|
||||
if (lastRead && stats.mtime > lastRead) {
|
||||
throw new Error(
|
||||
`file ${absPath} has been modified since it was last read (mod time: ${stats.mtime.toISOString()}, last read: ${lastRead.toISOString()})`,
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.code === "ENOENT") {
|
||||
throw new Error(`file not found: ${absPath}`);
|
||||
}
|
||||
throw new Error(`failed to access file: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for new files to ensure they don't already exist
|
||||
const filesToAdd = identifyFilesAdded(params.patchText);
|
||||
for (const filePath of filesToAdd) {
|
||||
let absPath = filePath;
|
||||
if (!path.isAbsolute(absPath)) {
|
||||
absPath = path.resolve(process.cwd(), absPath);
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.stat(absPath);
|
||||
throw new Error(`file already exists and cannot be added: ${absPath}`);
|
||||
} catch (error: any) {
|
||||
if (error.code !== "ENOENT") {
|
||||
throw new Error(`failed to check file: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load all required files
|
||||
const currentFiles: Record<string, string> = {};
|
||||
for (const filePath of filesToRead) {
|
||||
let absPath = filePath;
|
||||
if (!path.isAbsolute(absPath)) {
|
||||
absPath = path.resolve(process.cwd(), absPath);
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(absPath, "utf-8");
|
||||
currentFiles[filePath] = content;
|
||||
} catch (error: any) {
|
||||
throw new Error(`failed to read file ${absPath}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Process the patch
|
||||
const [patch, fuzz] = textToPatch(params.patchText, currentFiles);
|
||||
if (fuzz > 3) {
|
||||
throw new Error(
|
||||
`patch contains fuzzy matches (fuzz level: ${fuzz}). Please make your context lines more precise`,
|
||||
);
|
||||
}
|
||||
|
||||
// Convert patch to commit
|
||||
const commit = patchToCommit(patch, currentFiles);
|
||||
|
||||
// Apply the changes to the filesystem
|
||||
await applyCommit(
|
||||
commit,
|
||||
async (filePath: string, content: string) => {
|
||||
let absPath = filePath;
|
||||
if (!path.isAbsolute(absPath)) {
|
||||
absPath = path.resolve(process.cwd(), absPath);
|
||||
}
|
||||
|
||||
// Create parent directories if needed
|
||||
const dir = path.dirname(absPath);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(absPath, content, "utf-8");
|
||||
},
|
||||
async (filePath: string) => {
|
||||
let absPath = filePath;
|
||||
if (!path.isAbsolute(absPath)) {
|
||||
absPath = path.resolve(process.cwd(), absPath);
|
||||
}
|
||||
await fs.unlink(absPath);
|
||||
},
|
||||
);
|
||||
|
||||
// Calculate statistics
|
||||
const changedFiles: string[] = [];
|
||||
let totalAdditions = 0;
|
||||
let totalRemovals = 0;
|
||||
|
||||
for (const [filePath, change] of Object.entries(commit.changes)) {
|
||||
let absPath = filePath;
|
||||
if (!path.isAbsolute(absPath)) {
|
||||
absPath = path.resolve(process.cwd(), absPath);
|
||||
}
|
||||
changedFiles.push(absPath);
|
||||
|
||||
const oldContent = change.old_content || "";
|
||||
const newContent = change.new_content || "";
|
||||
|
||||
// Calculate diff statistics
|
||||
const [, additions, removals] = generateDiff(
|
||||
oldContent,
|
||||
newContent,
|
||||
filePath,
|
||||
);
|
||||
totalAdditions += additions;
|
||||
totalRemovals += removals;
|
||||
|
||||
// Record file operations
|
||||
FileTimes.write(absPath);
|
||||
FileTimes.read(absPath);
|
||||
}
|
||||
|
||||
const result = `Patch applied successfully. ${changedFiles.length} files changed, ${totalAdditions} additions, ${totalRemovals} removals`;
|
||||
const output = result;
|
||||
|
||||
return {
|
||||
metadata: {
|
||||
changed: changedFiles,
|
||||
additions: totalAdditions,
|
||||
removals: totalRemovals,
|
||||
} satisfies PatchResponseMetadata,
|
||||
output,
|
||||
};
|
||||
},
|
||||
});
|
||||
61
packages/opencode/src/tool/tool.ts
Normal file
61
packages/opencode/src/tool/tool.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { tool, type Tool as AITool } from "ai";
|
||||
import { Log } from "../util/log";
|
||||
|
||||
const log = Log.create({ service: "tool" });
|
||||
|
||||
export namespace Tool {
|
||||
export interface Metadata<
|
||||
Properties extends Record<string, any> = Record<string, any>,
|
||||
> {
|
||||
properties: Properties;
|
||||
time: {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
}
|
||||
export function define<
|
||||
Params,
|
||||
Output extends { metadata?: any; output: any },
|
||||
Name extends string,
|
||||
>(
|
||||
input: AITool<Params, Output> & {
|
||||
name: Name;
|
||||
},
|
||||
) {
|
||||
return tool({
|
||||
...input,
|
||||
execute: async (params, opts) => {
|
||||
log.info("invoking", {
|
||||
id: opts.toolCallId,
|
||||
name: input.name,
|
||||
...params,
|
||||
});
|
||||
try {
|
||||
const start = Date.now();
|
||||
const result = await input.execute!(params, opts);
|
||||
const metadata: Metadata<Output["metadata"]> = {
|
||||
...result.metadata,
|
||||
time: {
|
||||
start,
|
||||
end: Date.now(),
|
||||
},
|
||||
};
|
||||
return {
|
||||
metadata,
|
||||
output: result.output,
|
||||
};
|
||||
} catch (e: any) {
|
||||
log.error("error", {
|
||||
msg: e.toString(),
|
||||
});
|
||||
return {
|
||||
metadata: {
|
||||
error: true,
|
||||
},
|
||||
output: "An error occurred: " + e.toString(),
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
20
packages/opencode/src/tool/util/file-times.ts
Normal file
20
packages/opencode/src/tool/util/file-times.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { App } from "../../app/app";
|
||||
|
||||
export namespace FileTimes {
|
||||
export const state = App.state("tool.filetimes", () => ({
|
||||
read: new Map<string, Date>(),
|
||||
write: new Map<string, Date>(),
|
||||
}));
|
||||
|
||||
export function read(filePath: string) {
|
||||
state().read.set(filePath, new Date());
|
||||
}
|
||||
|
||||
export function write(filePath: string) {
|
||||
state().write.set(filePath, new Date());
|
||||
}
|
||||
|
||||
export function get(filePath: string): Date | null {
|
||||
return state().read.get(filePath) || null;
|
||||
}
|
||||
}
|
||||
152
packages/opencode/src/tool/view.ts
Normal file
152
packages/opencode/src/tool/view.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { z } from "zod";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { Tool } from "./tool";
|
||||
import { LSP } from "../lsp";
|
||||
import { FileTimes } from "./util/file-times";
|
||||
|
||||
const MAX_READ_SIZE = 250 * 1024;
|
||||
const DEFAULT_READ_LIMIT = 2000;
|
||||
const MAX_LINE_LENGTH = 2000;
|
||||
|
||||
const DESCRIPTION = `File viewing tool that reads and displays the contents of files with line numbers, allowing you to examine code, logs, or text data.
|
||||
|
||||
WHEN TO USE THIS TOOL:
|
||||
- Use when you need to read the contents of a specific file
|
||||
- Helpful for examining source code, configuration files, or log files
|
||||
- Perfect for looking at text-based file formats
|
||||
|
||||
HOW TO USE:
|
||||
- Provide the path to the file you want to view
|
||||
- Optionally specify an offset to start reading from a specific line
|
||||
- Optionally specify a limit to control how many lines are read
|
||||
|
||||
FEATURES:
|
||||
- Displays file contents with line numbers for easy reference
|
||||
- Can read from any position in a file using the offset parameter
|
||||
- Handles large files by limiting the number of lines read
|
||||
- Automatically truncates very long lines for better display
|
||||
- Suggests similar file names when the requested file isn't found
|
||||
|
||||
LIMITATIONS:
|
||||
- Maximum file size is 250KB
|
||||
- Default reading limit is 2000 lines
|
||||
- Lines longer than 2000 characters are truncated
|
||||
- Cannot display binary files or images
|
||||
- Images can be identified but not displayed
|
||||
|
||||
TIPS:
|
||||
- Use with Glob tool to first find files you want to view
|
||||
- For code exploration, first use Grep to find relevant files, then View to examine them
|
||||
- When viewing large files, use the offset parameter to read specific sections`;
|
||||
|
||||
export const view = Tool.define({
|
||||
name: "opencode.view",
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
filePath: z.string().describe("The path to the file to read"),
|
||||
offset: z
|
||||
.number()
|
||||
.describe("The line number to start reading from (0-based)")
|
||||
.optional(),
|
||||
limit: z
|
||||
.number()
|
||||
.describe("The number of lines to read (defaults to 2000)")
|
||||
.optional(),
|
||||
}),
|
||||
async execute(params) {
|
||||
let filePath = params.filePath;
|
||||
if (!path.isAbsolute(filePath)) {
|
||||
filePath = path.join(process.cwd(), filePath);
|
||||
}
|
||||
|
||||
const file = Bun.file(filePath);
|
||||
if (!(await file.exists())) {
|
||||
const dir = path.dirname(filePath);
|
||||
const base = path.basename(filePath);
|
||||
|
||||
const dirEntries = fs.readdirSync(dir);
|
||||
const suggestions = dirEntries
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.toLowerCase().includes(base.toLowerCase()) ||
|
||||
base.toLowerCase().includes(entry.toLowerCase()),
|
||||
)
|
||||
.map((entry) => path.join(dir, entry))
|
||||
.slice(0, 3);
|
||||
|
||||
if (suggestions.length > 0) {
|
||||
throw new Error(
|
||||
`File not found: ${filePath}\n\nDid you mean one of these?\n${suggestions.join("\n")}`,
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`File not found: ${filePath}`);
|
||||
}
|
||||
const stats = await file.stat();
|
||||
|
||||
if (stats.size > MAX_READ_SIZE)
|
||||
throw new Error(
|
||||
`File is too large (${stats.size} bytes). Maximum size is ${MAX_READ_SIZE} bytes`,
|
||||
);
|
||||
const limit = params.limit ?? DEFAULT_READ_LIMIT;
|
||||
const offset = params.offset || 0;
|
||||
const isImage = isImageFile(filePath);
|
||||
if (isImage)
|
||||
throw new Error(
|
||||
`This is an image file of type: ${isImage}\nUse a different tool to process images`,
|
||||
);
|
||||
const lines = await file.text().then((text) => text.split("\n"));
|
||||
const raw = lines.slice(offset, offset + limit).map((line) => {
|
||||
return line.length > MAX_LINE_LENGTH
|
||||
? line.substring(0, MAX_LINE_LENGTH) + "..."
|
||||
: line;
|
||||
});
|
||||
const content = raw.map((line, index) => {
|
||||
return `${(index + offset + 1).toString().padStart(5, "0")}| ${line}`;
|
||||
});
|
||||
const preview = raw.slice(0, 20).join("\n");
|
||||
|
||||
let output = "<file>\n";
|
||||
output += content.join("\n");
|
||||
|
||||
if (lines.length > offset + content.length) {
|
||||
output += `\n\n(File has more lines. Use 'offset' parameter to read beyond line ${
|
||||
offset + content.length
|
||||
})`;
|
||||
}
|
||||
output += "\n</file>";
|
||||
|
||||
// just warms the lsp client
|
||||
LSP.file(filePath);
|
||||
FileTimes.read(filePath);
|
||||
|
||||
return {
|
||||
output,
|
||||
metadata: {
|
||||
preview,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function isImageFile(filePath: string): string | false {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
switch (ext) {
|
||||
case ".jpg":
|
||||
case ".jpeg":
|
||||
return "JPEG";
|
||||
case ".png":
|
||||
return "PNG";
|
||||
case ".gif":
|
||||
return "GIF";
|
||||
case ".bmp":
|
||||
return "BMP";
|
||||
case ".svg":
|
||||
return "SVG";
|
||||
case ".webp":
|
||||
return "WebP";
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user