This commit is contained in:
Dax Raad
2025-05-30 20:47:56 -04:00
parent 9a26b3058f
commit f3da73553c
178 changed files with 765 additions and 3382 deletions

3
packages/opencode/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules
dist
gen

View File

@@ -0,0 +1,15 @@
# js
To install dependencies:
```bash
bun install
```
To run:
```bash
bun run index.ts
```
This project was created using `bun init` in bun v1.2.12. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.

View File

@@ -0,0 +1,29 @@
#!/usr/bin/env node
import { createRequire } from "node:module"
const require = createRequire(import.meta.url)
import path from "path"
import { execFileSync } from "child_process"
let resolved = process.env.SST_BIN_PATH
if (!resolved) {
const name = `opencode-${process.platform}-${process.arch}`
const binary = process.platform === "win32" ? "opencode.exe" : "opencode"
try {
resolved = require.resolve(path.join(name, "bin", binary))
} catch (ex) {
console.error(
`It seems that your package manager failed to install the right version of the SST CLI for your platform. You can try manually installing the "${name}" package`,
)
process.exit(1)
}
}
process.on("SIGINT", () => {})
try {
execFileSync(resolved, process.argv.slice(2), {
stdio: "inherit",
})
} catch (ex) {
process.exit(1)
}

View File

@@ -0,0 +1,39 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "0.0.0",
"name": "opencode",
"type": "module",
"private": true,
"scripts": {},
"exports": {
"./*": [
"./src/*.ts",
"./src/*/index.ts"
]
},
"devDependencies": {
"@tsconfig/bun": "^1.0.7",
"@types/bun": "latest",
"@types/jsdom": "^21.1.7",
"@types/turndown": "^5.0.5"
},
"dependencies": {
"@flystorage/file-storage": "^1.1.0",
"@flystorage/local-fs": "^1.1.0",
"@hono/zod-validator": "^0.5.0",
"ai": "5.0.0-alpha.7",
"cac": "^6.7.14",
"decimal.js": "^10.5.0",
"env-paths": "^3.0.0",
"hono": "^4.7.10",
"hono-openapi": "^0.4.8",
"jsdom": "^26.1.0",
"remeda": "^2.22.3",
"ts-lsp-client": "^1.0.3",
"turndown": "^7.2.0",
"vscode-jsonrpc": "^8.2.1",
"vscode-languageclient": "8",
"zod": "^3.25.3",
"zod-openapi": "^4.2.4"
}
}

View File

@@ -0,0 +1,30 @@
// This is a dummy file for testing purposes
console.log('Hello, world!');
export function dummyFunction(): void {
console.log('This is a dummy function');
}
export function anotherDummyFunction(): string {
return 'This is another dummy function';
}
export function newDummyFunction(): number {
return 42;
}
export function extraDummyFunction(): boolean {
return true;
}
export function superDummyFunction(): void {
console.log('This is a super dummy function');
}
export function ultraDummyFunction(): object {
return { dummy: true };
}
export function megaDummyFunction(): Array<string> {
return ['dummy', 'mega', 'function'];
}

View File

@@ -0,0 +1,68 @@
#!/usr/bin/env bun
import { $ } from "bun"
import pkg from "../package.json"
const version = `0.0.0-${Date.now()}`
const ARCH = {
arm64: "arm64",
x64: "amd64",
}
const OS = {
linux: "linux",
darwin: "mac",
win32: "windows",
}
const targets = [
["linux", "arm64"],
["linux", "x64"],
["darwin", "x64"],
["darwin", "arm64"],
["windows", "x64"],
]
await $`rm -rf dist`
const optionalDependencies: Record<string, string> = {}
for (const [os, arch] of targets) {
console.log(`building ${os}-${arch}`)
const name = `${pkg.name}-${os}-${arch}`
await $`mkdir -p dist/${name}/bin`
await $`bun build --compile --minify --target=bun-${os}-${arch} --outfile=dist/${name}/bin/${pkg.name} ./src/index.ts`
await Bun.file(`dist/${name}/package.json`).write(
JSON.stringify(
{
name,
version,
os: [os],
cpu: [arch],
},
null,
2,
),
)
await $`cd dist/${name} && npm publish --access public --tag latest`
optionalDependencies[name] = version
}
await $`mkdir -p ./dist/${pkg.name}`
await $`cp -r ./bin ./dist/${pkg.name}/bin`
await Bun.file(`./dist/${pkg.name}/package.json`).write(
JSON.stringify(
{
name: pkg.name + "-ai",
bin: {
[pkg.name]: `./bin/${pkg.name}.mjs`,
},
version,
optionalDependencies,
},
null,
2,
),
)
await $`cd ./dist/${pkg.name} && npm publish --access public --tag latest`

View File

@@ -0,0 +1,78 @@
import fs from "fs/promises";
import { AppPath } from "./path";
import { Log } from "../util/log";
import { Context } from "../util/context";
export namespace App {
const log = Log.create({ service: "app" });
export type Info = Awaited<ReturnType<typeof create>>;
const ctx = Context.create<Info>("app");
async function create(input: { directory: string }) {
const dataDir = AppPath.data(input.directory);
await fs.mkdir(dataDir, { recursive: true });
await Log.file(input.directory);
log.info("created", { path: dataDir });
const services = new Map<
any,
{
state: any;
shutdown?: (input: any) => Promise<void>;
}
>();
const result = {
get services() {
return services;
},
get root() {
return input.directory;
},
};
return result;
}
export function state<State>(
key: any,
init: (app: Info) => State,
shutdown?: (state: Awaited<State>) => Promise<void>,
) {
return () => {
const app = ctx.use();
const services = app.services;
if (!services.has(key)) {
log.info("registering service", { name: key });
services.set(key, {
state: init(app),
shutdown: shutdown,
});
}
return services.get(key)?.state as State;
};
}
export async function use() {
return ctx.use();
}
export async function provide<T extends (app: Info) => any>(
input: { directory: string },
cb: T,
) {
const app = await create(input);
return ctx.provide(app, async () => {
const result = await cb(app);
for (const [key, entry] of app.services.entries()) {
log.info("shutdown", { name: key });
await entry.shutdown?.(await entry.state);
}
return result;
});
}
}

View File

@@ -0,0 +1,11 @@
import path from "path";
export namespace AppPath {
export function data(input: string) {
return path.join(input, ".opencode");
}
export function storage(input: string) {
return path.join(data(input), "storage");
}
}

View File

@@ -0,0 +1,28 @@
import path from "path";
import { Log } from "../util/log";
export namespace BunProc {
const log = Log.create({ service: "bun" });
export function run(
cmd: string[],
options?: Bun.SpawnOptions.OptionsObject<any, any, any>,
) {
const root =
process.argv0 !== "bun"
? path.resolve(process.cwd(), process.argv0)
: process.argv0;
log.info("running", {
cmd: [root, ...cmd],
options,
});
const result = Bun.spawnSync([root, ...cmd], {
...options,
argv0: "bun",
env: {
...process.env,
...options?.env,
},
});
return result;
}
}

View File

@@ -0,0 +1,101 @@
import { z, type ZodType } from "zod";
import { App } from "../app/app";
import { Log } from "../util/log";
export namespace Bus {
const log = Log.create({ service: "bus" });
type Subscription = (event: any) => void;
const state = App.state("bus", () => {
const subscriptions = new Map<any, Subscription[]>();
return {
subscriptions,
};
});
export type EventDefinition = ReturnType<typeof event>;
const registry = new Map<string, EventDefinition>();
export function event<Type extends string, Properties extends ZodType>(
type: Type,
properties: Properties,
) {
const result = {
type,
properties,
};
registry.set(type, result);
return result;
}
export function payloads() {
return z.discriminatedUnion(
"type",
registry
.entries()
.map(([type, def]) =>
z
.object({
type: z.literal(type),
properties: def.properties,
})
.openapi({
ref: "Event" + "." + def.type,
}),
)
.toArray() as any,
);
}
export function publish<Definition extends EventDefinition>(
def: Definition,
properties: z.output<Definition["properties"]>,
) {
const payload = {
type: def.type,
properties,
};
log.info("publishing", {
type: def.type,
});
for (const key of [def.type, "*"]) {
const match = state().subscriptions.get(key);
for (const sub of match ?? []) {
sub(payload);
}
}
}
export function subscribe<Definition extends EventDefinition>(
def: Definition,
callback: (event: {
type: Definition["type"];
properties: z.infer<Definition["properties"]>;
}) => void,
) {
return raw(def.type, callback);
}
export function subscribeAll(callback: (event: any) => void) {
return raw("*", callback);
}
function raw(type: string, callback: (event: any) => void) {
log.info("subscribing", { type });
const subscriptions = state().subscriptions;
let match = subscriptions.get(type) ?? [];
match.push(callback);
subscriptions.set(type, match);
return () => {
log.info("unsubscribing", { type });
const match = subscriptions.get(type);
if (!match) return;
const index = match.indexOf(callback);
if (index === -1) return;
match.splice(index, 1);
};
}
}

View File

@@ -0,0 +1,51 @@
import path from "path";
import { Log } from "../util/log";
import { z } from "zod";
import { App } from "../app/app";
import { Provider } from "../provider/provider";
export namespace Config {
const log = Log.create({ service: "config" });
export const state = App.state("config", async (app) => {
const result = await load(app.root);
return result;
});
export const Info = z
.object({
providers: Provider.Info.array().optional(),
})
.strict();
export type Info = z.output<typeof Info>;
export function get() {
return state();
}
async function load(directory: string) {
let result: Info = {};
for (const file of ["opencode.jsonc", "opencode.json"]) {
const resolved = path.join(directory, file);
log.info("searching", { path: resolved });
try {
result = await import(path.join(directory, file)).then((mod) =>
Info.parse(mod.default),
);
log.info("found", { path: resolved });
break;
} catch (e) {
if (e instanceof z.ZodError) {
for (const issue of e.issues) {
log.info(issue.message);
}
throw e;
}
continue;
}
}
log.info("loaded", result);
return result;
}
}

View File

@@ -0,0 +1,20 @@
import envpaths from "env-paths";
import fs from "fs/promises";
const paths = envpaths("opencode", {
suffix: "",
});
await Promise.all([
fs.mkdir(paths.config, { recursive: true }),
fs.mkdir(paths.cache, { recursive: true }),
]);
export namespace Global {
export function config() {
return paths.config;
}
export function cache() {
return paths.cache;
}
}

View File

@@ -0,0 +1,74 @@
import { z } from "zod";
import { randomBytes } from "crypto";
export namespace Identifier {
const prefixes = {
session: "ses",
message: "msg",
} as const;
export function schema(prefix: keyof typeof prefixes) {
return z.string().startsWith(prefixes[prefix]);
}
const LENGTH = 26;
// State for monotonic ID generation
let lastTimestamp = 0;
let counter = 0;
export function ascending(prefix: keyof typeof prefixes, given?: string) {
return generateID(prefix, false, given);
}
export function descending(prefix: keyof typeof prefixes, given?: string) {
return generateID(prefix, true, given);
}
function generateID(
prefix: keyof typeof prefixes,
descending: boolean,
given?: string,
): string {
if (!given) {
return generateNewID(prefix, descending);
}
if (!given.startsWith(prefixes[prefix])) {
throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`);
}
return given;
}
function generateNewID(
prefix: keyof typeof prefixes,
descending: boolean,
): string {
const currentTimestamp = Date.now();
if (currentTimestamp !== lastTimestamp) {
lastTimestamp = currentTimestamp;
counter = 0;
}
counter++;
let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter);
now = descending ? ~now : now;
const timeBytes = Buffer.alloc(6);
for (let i = 0; i < 6; i++) {
timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff));
}
const randLength = (LENGTH - 12) / 2;
const random = randomBytes(randLength);
return (
prefixes[prefix] +
"_" +
timeBytes.toString("hex") +
random.toString("hex")
);
}
}

View File

@@ -0,0 +1,85 @@
import "zod-openapi/extend"
import { App } from "./app/app"
import { Server } from "./server/server"
import fs from "fs/promises"
import path from "path"
import { Bus } from "./bus"
import { Session } from "./session/session"
import cac from "cac"
import { Share } from "./share/share"
import { Storage } from "./storage/storage"
import { LLM } from "./llm/llm"
import { Message } from "./session/message"
const cli = cac("opencode")
cli.command("", "Start the opencode in interactive mode").action(async () => {
await App.provide({ directory: process.cwd() }, async () => {
await Share.init()
Server.listen()
})
})
cli.command("generate", "Generate OpenAPI and event specs").action(async () => {
const specs = await Server.openapi()
const dir = "gen"
await fs.rmdir(dir, { recursive: true }).catch(() => {})
await fs.mkdir(dir, { recursive: true })
await Bun.write(
path.join(dir, "openapi.json"),
JSON.stringify(specs, null, 2),
)
})
cli
.command("run [...message]", "Run a chat message")
.option("--session <id>", "Session ID")
.action(async (message: string[], options) => {
await App.provide({ directory: process.cwd() }, async () => {
await Share.init()
const session = options.session
? await Session.get(options.session)
: await Session.create()
console.log("Session:", session.id)
Bus.subscribe(Message.Event.Updated, async (message) => {
console.log("Thinking...")
})
const unsub = Bus.subscribe(Session.Event.Updated, async (message) => {
if (message.properties.info.share?.url)
console.log("Share:", message.properties.info.share.url)
unsub()
})
const providers = await LLM.providers()
const providerID = Object.keys(providers)[0]
const modelID = providers[providerID].info.models[0].id
console.log("using", providerID, modelID)
const result = await Session.chat({
sessionID: session.id,
providerID,
modelID,
parts: [
{
type: "text",
text: message.join(" "),
},
],
})
for (const part of result.parts) {
if (part.type === "text") {
console.log("opencode:", part.text)
}
}
console.log({
cost: result.metadata.assistant?.cost,
tokens: result.metadata.assistant?.tokens,
})
})
})
cli.help()
cli.version("1.0.0")
cli.parse()

View File

@@ -0,0 +1,172 @@
import { App } from "../app/app"
import { Log } from "../util/log"
import { concat } from "remeda"
import path from "path"
import { Provider } from "../provider/provider"
import type { LanguageModel, Provider as ProviderInstance } from "ai"
import { NoSuchModelError } from "ai"
import { Config } from "../config/config"
import { BunProc } from "../bun"
import { Global } from "../global"
export namespace LLM {
const log = Log.create({ service: "llm" })
export class ModelNotFoundError extends Error {
constructor(public readonly model: string) {
super()
}
}
const NATIVE_PROVIDERS: Provider.Info[] = [
{
id: "anthropic",
name: "Anthropic",
models: [
{
id: "claude-sonnet-4-20250514",
name: "Claude Sonnet 4",
cost: {
input: 3.0 / 1_000_000,
output: 15.0 / 1_000_000,
inputCached: 3.75 / 1_000_000,
outputCached: 0.3 / 1_000_000,
},
contextWindow: 200_000,
maxOutputTokens: 50_000,
attachment: true,
},
],
},
{
id: "openai",
name: "OpenAI",
models: [
{
id: "codex-mini-latest",
name: "Codex Mini",
cost: {
input: 1.5 / 1_000_000,
inputCached: 0.375 / 1_000_000,
output: 6.0 / 1_000_000,
outputCached: 0.0 / 1_000_000,
},
contextWindow: 200_000,
maxOutputTokens: 100_000,
attachment: true,
reasoning: true,
},
],
},
{
id: "google",
name: "Google",
models: [
{
id: "gemini-2.5-pro-preview-03-25",
name: "Gemini 2.5 Pro",
cost: {
input: 1.25 / 1_000_000,
inputCached: 0 / 1_000_000,
output: 10 / 1_000_000,
outputCached: 0 / 1_000_000,
},
contextWindow: 1_000_000,
maxOutputTokens: 50_000,
attachment: true,
},
],
},
]
const AUTODETECT: Record<string, string[]> = {
anthropic: ["ANTHROPIC_API_KEY"],
openai: ["OPENAI_API_KEY"],
google: ["GOOGLE_GENERATIVE_AI_API_KEY", "GEMINI_API_KEY"],
}
const state = App.state("llm", async () => {
const config = await Config.get()
const providers: Record<
string,
{
info: Provider.Info
instance: ProviderInstance
}
> = {}
const models = new Map<
string,
{ info: Provider.Model; instance: LanguageModel }
>()
const list = concat(NATIVE_PROVIDERS, config.providers ?? [])
for (const provider of list) {
if (
!config.providers?.find((p) => p.id === provider.id) &&
!AUTODETECT[provider.id]?.some((env) => process.env[env])
)
continue
const dir = path.join(
Global.cache(),
`node_modules`,
`@ai-sdk`,
provider.id,
)
if (!(await Bun.file(path.join(dir, "package.json")).exists())) {
BunProc.run(["add", "--exact", `@ai-sdk/${provider.id}@alpha`], {
cwd: Global.cache(),
})
}
const mod = await import(
path.join(Global.cache(), `node_modules`, `@ai-sdk`, provider.id)
)
const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!]
const loaded = fn(provider.options)
log.info("loaded", { provider: provider.id })
providers[provider.id] = {
info: provider,
instance: loaded,
}
}
return {
models,
providers,
}
})
export async function providers() {
return state().then((state) => state.providers)
}
export async function findModel(providerID: string, modelID: string) {
const key = `${providerID}/${modelID}`
const s = await state()
if (s.models.has(key)) return s.models.get(key)!
const provider = s.providers[providerID]
if (!provider) throw new ModelNotFoundError(modelID)
log.info("loading", {
providerID,
modelID,
})
const info = provider.info.models.find((m) => m.id === modelID)
if (!info) throw new ModelNotFoundError(modelID)
try {
const match = provider.instance.languageModel(modelID)
log.info("found", { providerID, modelID })
s.models.set(key, {
info,
instance: match,
})
return {
info,
instance: match,
}
} catch (e) {
if (e instanceof NoSuchModelError) throw new ModelNotFoundError(modelID)
throw e
}
}
}

View File

@@ -0,0 +1,208 @@
import { spawn } from "child_process";
import path from "path";
import {
createMessageConnection,
StreamMessageReader,
StreamMessageWriter,
} from "vscode-jsonrpc/node";
import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types";
import { App } from "../app/app";
import { Log } from "../util/log";
import { LANGUAGE_EXTENSIONS } from "./language";
import { Bus } from "../bus";
import z from "zod";
export namespace LSPClient {
const log = Log.create({ service: "lsp.client" });
export type Info = Awaited<ReturnType<typeof create>>;
export type Diagnostic = VSCodeDiagnostic;
export const Event = {
Diagnostics: Bus.event(
"lsp.client.diagnostics",
z.object({
serverID: z.string(),
path: z.string(),
}),
),
};
export async function create(input: { cmd: string[]; serverID: string }) {
log.info("starting client", input);
const app = await App.use();
const [command, ...args] = input.cmd;
const server = spawn(command, args, {
stdio: ["pipe", "pipe", "pipe"],
cwd: app.root,
});
const connection = createMessageConnection(
new StreamMessageReader(server.stdout),
new StreamMessageWriter(server.stdin),
);
const diagnostics = new Map<string, Diagnostic[]>();
connection.onNotification("textDocument/publishDiagnostics", (params) => {
const path = new URL(params.uri).pathname;
log.info("textDocument/publishDiagnostics", {
path,
});
const exists = diagnostics.has(path);
diagnostics.set(path, params.diagnostics);
// servers seem to send one blank publishDiagnostics event before the first real one
if (!exists && !params.diagnostics.length) return;
Bus.publish(Event.Diagnostics, { path, serverID: input.serverID });
});
connection.listen();
await connection.sendRequest("initialize", {
processId: server.pid,
initializationOptions: {
workspaceFolders: [
{
name: "workspace",
uri: "file://" + app.root,
},
],
tsserver: {
path: require.resolve("typescript/lib/tsserver.js"),
},
},
capabilities: {
workspace: {
configuration: true,
didChangeConfiguration: {
dynamicRegistration: true,
},
didChangeWatchedFiles: {
dynamicRegistration: true,
relativePatternSupport: true,
},
},
textDocument: {
synchronization: {
dynamicRegistration: true,
didSave: true,
},
completion: {
completionItem: {},
},
codeLens: {
dynamicRegistration: true,
},
documentSymbol: {},
codeAction: {
codeActionLiteralSupport: {
codeActionKind: {
valueSet: [],
},
},
},
publishDiagnostics: {
versionSupport: true,
},
semanticTokens: {
requests: {
range: {},
full: {},
},
tokenTypes: [],
tokenModifiers: [],
formats: [],
},
},
window: {},
},
});
await connection.sendNotification("initialized", {});
log.info("initialized");
const files = new Set<string>();
const result = {
get clientID() {
return input.serverID;
},
get connection() {
return connection;
},
notify: {
async open(input: { path: string }) {
const file = Bun.file(input.path);
const text = await file.text();
const opened = files.has(input.path);
if (!opened) {
log.info("textDocument/didOpen", input);
diagnostics.delete(input.path);
const extension = path.extname(input.path);
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext";
await connection.sendNotification("textDocument/didOpen", {
textDocument: {
uri: `file://` + input.path,
languageId,
version: Date.now(),
text,
},
});
files.add(input.path);
return;
}
log.info("textDocument/didChange", input);
diagnostics.delete(input.path);
await connection.sendNotification("textDocument/didChange", {
textDocument: {
uri: `file://` + input.path,
version: Date.now(),
},
contentChanges: [
{
text,
},
],
});
},
},
get diagnostics() {
return diagnostics;
},
async waitForDiagnostics(input: { path: string }) {
log.info("waiting for diagnostics", input);
let unsub: () => void;
let timeout: NodeJS.Timeout;
return await Promise.race([
new Promise<void>(async (resolve) => {
unsub = Bus.subscribe(Event.Diagnostics, (event) => {
if (
event.properties.path === input.path &&
event.properties.serverID === result.clientID
) {
log.info("got diagnostics", input);
clearTimeout(timeout);
unsub?.();
resolve();
}
});
}),
new Promise<void>((resolve) => {
timeout = setTimeout(() => {
log.info("timed out refreshing diagnostics", input);
unsub?.();
resolve();
}, 5000);
}),
]);
},
async shutdown() {
log.info("shutting down");
connection.end();
connection.dispose();
},
};
return result;
}
}

View File

@@ -0,0 +1,131 @@
import { App } from "../app/app";
import { Log } from "../util/log";
import { LSPClient } from "./client";
import path from "path";
export namespace LSP {
const log = Log.create({ service: "lsp" });
const state = App.state(
"lsp",
async () => {
log.info("initializing");
const clients = new Map<string, LSPClient.Info>();
return {
clients,
};
},
async (state) => {
for (const client of state.clients.values()) {
await client.shutdown();
}
},
);
export async function file(input: string) {
const extension = path.parse(input).ext;
const s = await state();
const matches = AUTO.filter((x) => x.extensions.includes(extension));
for (const match of matches) {
const existing = s.clients.get(match.id);
if (existing) continue;
const client = await LSPClient.create({
cmd: match.command,
serverID: match.id,
});
s.clients.set(match.id, client);
}
await run(async (client) => {
const wait = client.waitForDiagnostics({ path: input });
await client.notify.open({ path: input });
return wait;
});
}
export async function diagnostics() {
const results: Record<string, LSPClient.Diagnostic[]> = {};
for (const result of await run(async (client) => client.diagnostics)) {
for (const [path, diagnostics] of result.entries()) {
const arr = results[path] || [];
arr.push(...diagnostics);
results[path] = arr;
}
}
return results;
}
export async function hover(input: {
file: string;
line: number;
character: number;
}) {
return run((client) => {
return client.connection.sendRequest("textDocument/hover", {
textDocument: {
uri: `file://${input.file}`,
},
position: {
line: input.line,
character: input.character,
},
});
});
}
async function run<T>(
input: (client: LSPClient.Info) => Promise<T>,
): Promise<T[]> {
const clients = await state().then((x) => [...x.clients.values()]);
const tasks = clients.map((x) => input(x));
return Promise.all(tasks);
}
const AUTO: {
id: string;
command: string[];
extensions: string[];
install?: () => Promise<void>;
}[] = [
{
id: "typescript",
command: ["bun", "x", "typescript-language-server", "--stdio"],
extensions: [
".ts",
".tsx",
".js",
".jsx",
".mjs",
".cjs",
".mts",
".cts",
".mtsx",
".ctsx",
],
},
/*
{
id: "golang",
command: ["gopls"],
extensions: [".go"],
},
*/
];
export namespace Diagnostic {
export function pretty(diagnostic: LSPClient.Diagnostic) {
const severityMap = {
1: "ERROR",
2: "WARN",
3: "INFO",
4: "HINT",
};
const severity = severityMap[diagnostic.severity || 1];
const line = diagnostic.range.start.line + 1;
const col = diagnostic.range.start.character + 1;
return `${severity} [${line}:${col}] ${diagnostic.message}`;
}
}
}

View File

@@ -0,0 +1,89 @@
export const LANGUAGE_EXTENSIONS: Record<string, string> = {
".abap": "abap",
".bat": "bat",
".bib": "bibtex",
".bibtex": "bibtex",
".clj": "clojure",
".coffee": "coffeescript",
".c": "c",
".cpp": "cpp",
".cxx": "cpp",
".cc": "cpp",
".c++": "cpp",
".cs": "csharp",
".css": "css",
".d": "d",
".pas": "pascal",
".pascal": "pascal",
".diff": "diff",
".patch": "diff",
".dart": "dart",
".dockerfile": "dockerfile",
".ex": "elixir",
".exs": "elixir",
".erl": "erlang",
".hrl": "erlang",
".fs": "fsharp",
".fsi": "fsharp",
".fsx": "fsharp",
".fsscript": "fsharp",
".gitcommit": "git-commit",
".gitrebase": "git-rebase",
".go": "go",
".groovy": "groovy",
".hbs": "handlebars",
".handlebars": "handlebars",
".hs": "haskell",
".html": "html",
".htm": "html",
".ini": "ini",
".java": "java",
".js": "javascript",
".jsx": "javascriptreact",
".json": "json",
".tex": "latex",
".latex": "latex",
".less": "less",
".lua": "lua",
".makefile": "makefile",
makefile: "makefile",
".md": "markdown",
".markdown": "markdown",
".m": "objective-c",
".mm": "objective-cpp",
".pl": "perl",
".pm": "perl6",
".php": "php",
".ps1": "powershell",
".psm1": "powershell",
".pug": "jade",
".jade": "jade",
".py": "python",
".r": "r",
".cshtml": "razor",
".razor": "razor",
".rb": "ruby",
".rs": "rust",
".scss": "scss",
".sass": "sass",
".scala": "scala",
".shader": "shaderlab",
".sh": "shellscript",
".bash": "shellscript",
".zsh": "shellscript",
".ksh": "shellscript",
".sql": "sql",
".swift": "swift",
".ts": "typescript",
".tsx": "typescriptreact",
".mts": "typescript",
".cts": "typescript",
".mtsx": "typescriptreact",
".ctsx": "typescriptreact",
".xml": "xml",
".xsl": "xsl",
".yaml": "yaml",
".yml": "yaml",
".mjs": "javascript",
".cjs": "javascript",
} as const;

View File

@@ -0,0 +1,35 @@
import z from "zod";
export namespace Provider {
export const Model = z
.object({
id: z.string(),
name: z.string().optional(),
cost: z.object({
input: z.number(),
inputCached: z.number(),
output: z.number(),
outputCached: z.number(),
}),
contextWindow: z.number(),
maxOutputTokens: z.number().optional(),
attachment: z.boolean(),
reasoning: z.boolean().optional(),
})
.openapi({
ref: "Provider.Model",
});
export type Model = z.output<typeof Model>;
export const Info = z
.object({
id: z.string(),
name: z.string(),
options: z.record(z.string(), z.any()).optional(),
models: Model.array(),
})
.openapi({
ref: "Provider.Info",
});
export type Info = z.output<typeof Info>;
}

View File

@@ -0,0 +1,309 @@
import { Log } from "../util/log";
import { Bus } from "../bus";
import { describeRoute, generateSpecs, openAPISpecs } from "hono-openapi";
import { Hono } from "hono";
import { streamSSE } from "hono/streaming";
import { Session } from "../session/session";
import { resolver, validator as zValidator } from "hono-openapi/zod";
import { z } from "zod";
import { LLM } from "../llm/llm";
import { Message } from "../session/message";
import { Provider } from "../provider/provider";
export namespace Server {
const log = Log.create({ service: "server" });
const PORT = 16713;
export type App = ReturnType<typeof app>;
function app() {
const app = new Hono();
const result = app
.get(
"/openapi",
openAPISpecs(app, {
documentation: {
info: {
title: "opencode",
version: "1.0.0",
description: "opencode api",
},
openapi: "3.0.0",
},
}),
)
.get(
"/event",
describeRoute({
description: "Get events",
responses: {
200: {
description: "Event stream",
content: {
"application/json": {
schema: resolver(
Bus.payloads().openapi({
ref: "Event",
}),
),
},
},
},
},
}),
async (c) => {
log.info("event connected");
return streamSSE(c, async (stream) => {
stream.writeSSE({
data: JSON.stringify({}),
});
const unsub = Bus.subscribeAll(async (event) => {
await stream.writeSSE({
data: JSON.stringify(event),
});
});
await new Promise<void>((resolve) => {
stream.onAbort(() => {
unsub();
resolve();
log.info("event disconnected");
});
});
});
},
)
.post(
"/session_create",
describeRoute({
description: "Create a new session",
responses: {
200: {
description: "Successfully created session",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
},
}),
async (c) => {
const session = await Session.create();
return c.json(session);
},
)
.post(
"/session_share",
describeRoute({
description: "Share the session",
responses: {
200: {
description: "Successfully shared session",
content: {
"application/json": {
schema: resolver(Session.Info),
},
},
},
},
}),
zValidator(
"json",
z.object({
sessionID: z.string(),
}),
),
async (c) => {
const body = c.req.valid("json");
await Session.share(body.sessionID);
const session = await Session.get(body.sessionID);
return c.json(session);
},
)
.post(
"/session_messages",
describeRoute({
description: "Get messages for a session",
responses: {
200: {
description: "Successfully created session",
content: {
"application/json": {
schema: resolver(Message.Info.array()),
},
},
},
},
}),
zValidator(
"json",
z.object({
sessionID: z.string(),
}),
),
async (c) => {
const messages = await Session.messages(
c.req.valid("json").sessionID,
);
return c.json(messages);
},
)
.post(
"/session_list",
describeRoute({
description: "List all sessions",
responses: {
200: {
description: "List of sessions",
content: {
"application/json": {
schema: resolver(Session.Info.array()),
},
},
},
},
}),
async (c) => {
const sessions = await Array.fromAsync(Session.list());
return c.json(sessions);
},
)
.post(
"/session_abort",
describeRoute({
description: "Abort a session",
responses: {
200: {
description: "Aborted session",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
zValidator(
"json",
z.object({
sessionID: z.string(),
}),
),
async (c) => {
const body = c.req.valid("json");
return c.json(Session.abort(body.sessionID));
},
)
.post(
"/session_summarize",
describeRoute({
description: "Summarize the session",
responses: {
200: {
description: "Summarize the session",
content: {
"application/json": {
schema: resolver(z.boolean()),
},
},
},
},
}),
zValidator(
"json",
z.object({
sessionID: z.string(),
providerID: z.string(),
modelID: z.string(),
}),
),
async (c) => {
const body = c.req.valid("json");
await Session.summarize(body);
return c.json(true);
},
)
.post(
"/session_chat",
describeRoute({
description: "Chat with a model",
responses: {
200: {
description: "Chat with a model",
content: {
"application/json": {
schema: resolver(Message.Info),
},
},
},
},
}),
zValidator(
"json",
z.object({
sessionID: z.string(),
providerID: z.string(),
modelID: z.string(),
parts: Message.Part.array(),
}),
),
async (c) => {
const body = c.req.valid("json");
const msg = await Session.chat(body);
return c.json(msg);
},
)
.post(
"/provider_list",
describeRoute({
description: "List all providers",
responses: {
200: {
description: "List of providers",
content: {
"application/json": {
schema: resolver(Provider.Info.array()),
},
},
},
},
}),
async (c) => {
const providers = await LLM.providers();
const result = [] as (Provider.Info & { key: string })[];
for (const [key, provider] of Object.entries(providers)) {
result.push({ ...provider.info, key });
}
return c.json(result);
},
);
return result;
}
export async function openapi() {
const a = app();
const result = await generateSpecs(a, {
documentation: {
info: {
title: "opencode",
version: "1.0.0",
description: "opencode api",
},
openapi: "3.0.0",
},
});
return result;
}
export function listen() {
const server = Bun.serve({
port: PORT,
hostname: "0.0.0.0",
idleTimeout: 0,
fetch: app().fetch,
});
return server;
}
}

View File

@@ -0,0 +1,171 @@
import z from "zod";
import { Bus } from "../bus";
export namespace Message {
export const ToolCall = z
.object({
state: z.literal("call"),
step: z.number().optional(),
toolCallId: z.string(),
toolName: z.string(),
args: z.custom<Required<unknown>>(),
})
.openapi({
ref: "Message.ToolInvocation.ToolCall",
});
export type ToolCall = z.infer<typeof ToolCall>;
export const ToolPartialCall = z
.object({
state: z.literal("partial-call"),
step: z.number().optional(),
toolCallId: z.string(),
toolName: z.string(),
args: z.custom<Required<unknown>>(),
})
.openapi({
ref: "Message.ToolInvocation.ToolPartialCall",
});
export type ToolPartialCall = z.infer<typeof ToolPartialCall>;
export const ToolResult = z
.object({
state: z.literal("result"),
step: z.number().optional(),
toolCallId: z.string(),
toolName: z.string(),
args: z.custom<Required<unknown>>(),
result: z.string(),
})
.openapi({
ref: "Message.ToolInvocation.ToolResult",
});
export type ToolResult = z.infer<typeof ToolResult>;
export const ToolInvocation = z
.discriminatedUnion("state", [ToolCall, ToolPartialCall, ToolResult])
.openapi({
ref: "Message.ToolInvocation",
});
export type ToolInvocation = z.infer<typeof ToolInvocation>;
export const TextPart = z
.object({
type: z.literal("text"),
text: z.string(),
})
.openapi({
ref: "Message.Part.Text",
});
export type TextPart = z.infer<typeof TextPart>;
export const ReasoningPart = z
.object({
type: z.literal("reasoning"),
text: z.string(),
providerMetadata: z.record(z.any()).optional(),
})
.openapi({
ref: "Message.Part.Reasoning",
});
export type ReasoningPart = z.infer<typeof ReasoningPart>;
export const ToolInvocationPart = z
.object({
type: z.literal("tool-invocation"),
toolInvocation: ToolInvocation,
})
.openapi({
ref: "Message.Part.ToolInvocation",
});
export type ToolInvocationPart = z.infer<typeof ToolInvocationPart>;
export const SourceUrlPart = z
.object({
type: z.literal("source-url"),
sourceId: z.string(),
url: z.string(),
title: z.string().optional(),
providerMetadata: z.record(z.any()).optional(),
})
.openapi({
ref: "Message.Part.SourceUrl",
});
export type SourceUrlPart = z.infer<typeof SourceUrlPart>;
export const FilePart = z
.object({
type: z.literal("file"),
mediaType: z.string(),
filename: z.string().optional(),
url: z.string(),
})
.openapi({
ref: "Message.Part.File",
});
export type FilePart = z.infer<typeof FilePart>;
export const StepStartPart = z
.object({
type: z.literal("step-start"),
})
.openapi({
ref: "Message.Part.StepStart",
});
export type StepStartPart = z.infer<typeof StepStartPart>;
export const Part = z
.discriminatedUnion("type", [
TextPart,
ReasoningPart,
ToolInvocationPart,
SourceUrlPart,
FilePart,
StepStartPart,
])
.openapi({
ref: "Message.Part",
});
export type Part = z.infer<typeof Part>;
export const Info = z
.object({
id: z.string(),
role: z.enum(["system", "user", "assistant"]),
parts: z.array(Part),
metadata: z.object({
time: z.object({
created: z.number(),
completed: z.number().optional(),
}),
sessionID: z.string(),
tool: z.record(z.string(), z.any()),
assistant: z
.object({
modelID: z.string(),
providerID: z.string(),
cost: z.number(),
summary: z.boolean().optional(),
tokens: z.object({
input: z.number(),
output: z.number(),
reasoning: z.number(),
}),
})
.optional(),
}),
})
.openapi({
ref: "Message.Info",
});
export type Info = z.infer<typeof Info>;
export const Event = {
Updated: Bus.event(
"message.updated",
z.object({
info: Info,
}),
),
};
}

View File

@@ -0,0 +1,95 @@
You are OpenCode, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure.
# Memory
If the current working directory contains a file called OpenCode.md, it will be automatically added to your context. This file serves multiple purposes:
1. Storing frequently used bash commands (build, test, lint, etc.) so you can use them without searching each time
2. Recording the user's code style preferences (naming conventions, preferred libraries, etc.)
3. Maintaining useful information about the codebase structure and organization
When you spend time searching for commands to typecheck, lint, build, or test, you should ask the user if it's okay to add those commands to CONTEXT.md. Similarly, when learning about code style preferences or important codebase information, ask if it's okay to add that to CONTEXT.md so you can remember it for next time.
# Tone and style
You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
Remember that your output will be displayed on a command line interface. Your responses can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session.
If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences.
IMPORTANT: You should minimize output tokens as much as possible while maintaining helpfulness, quality, and accuracy. Only address the specific query or task at hand, avoiding tangential information unless absolutely critical for completing the request. If you can answer in 1-3 sentences or a short paragraph, please do.
IMPORTANT: You should NOT answer with unnecessary preamble or postamble (such as explaining your code or summarizing your action), unless the user asks you to.
IMPORTANT: Keep your responses short, since they will be displayed on a command line interface. You MUST answer concisely with fewer than 4 lines (not including tool use or code generation), unless user asks for detail. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...". Here are some examples to demonstrate appropriate verbosity:
<example>
user: 2 + 2
assistant: 4
</example>
<example>
user: what is 2+2?
assistant: 4
</example>
<example>
user: is 11 a prime number?
assistant: yes
</example>
<example>
user: what command should I run to list files in the current directory?
assistant: ls
</example>
<example>
user: what command should I run to watch files in the current directory?
assistant: [use the ls tool to list the files in the current directory, then read docs/commands in the relevant file to find out how to watch files]
npm run dev
</example>
<example>
user: How many golf balls fit inside a jetta?
assistant: 150000
</example>
<example>
user: what files are in the directory src/?
assistant: [runs ls and sees foo.c, bar.c, baz.c]
user: which file contains the implementation of foo?
assistant: src/foo.c
</example>
<example>
user: write tests for new feature
assistant: [uses grep and glob search tools to find where similar tests are defined, uses concurrent read file tool use blocks in one tool call to read relevant files at the same time, uses edit/patch file tool to write new tests]
</example>
# Proactiveness
You are allowed to be proactive, but only when the user asks you to do something. You should strive to strike a balance between:
1. Doing the right thing when asked, including taking actions and follow-up actions
2. Not surprising the user with actions you take without asking
For example, if the user asks you how to approach something, you should do your best to answer their question first, and not immediately jump into taking actions.
3. Do not add additional code explanation summary unless requested by the user. After working on a file, just stop, rather than providing an explanation of what you did.
# Following conventions
When making changes to files, first understand the file's code conventions. Mimic code style, use existing libraries and utilities, and follow existing patterns.
- NEVER assume that a given library is available, even if it is well known. Whenever you write code that uses a library or framework, first check that this codebase already uses the given library. For example, you might look at neighboring files, or check the package.json (or cargo.toml, and so on depending on the language).
- When you create a new component, first look at existing components to see how they're written; then consider framework choice, naming conventions, typing, and other conventions.
- When you edit a piece of code, first look at the code's surrounding context (especially its imports) to understand the code's choice of frameworks and libraries. Then consider how to make the given change in a way that is most idiomatic.
- Always follow security best practices. Never introduce code that exposes or logs secrets and keys. Never commit secrets or keys to the repository.
# Code style
- Do not add comments to the code you write, unless the user asks you to, or the code is complex and requires additional context.
# Doing tasks
The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
1. Use the available search tools to understand the codebase and the user's query. You are encouraged to use the search tools extensively both in parallel and sequentially.
2. Implement the solution using all tools available to you
3. Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.
4. VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (eg. npm run lint, npm run typecheck, ruff, etc.) if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to opencode.md so that you will know to run it next time.
NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
# Tool usage policy
- When doing file search, prefer to use the Agent tool in order to reduce context usage.
- If you intend to call multiple tools and there are no dependencies between the calls, make all of the independent calls in the same function_calls block.
- IMPORTANT: The user does not see the full output of the tool responses, so if you need the output of the tool for the response make sure to summarize it for the user.
You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.

View File

@@ -0,0 +1,10 @@
You are a helpful AI assistant tasked with summarizing conversations.
When asked to summarize, provide a detailed but concise summary of the conversation.
Focus on information that would be helpful for continuing the conversation, including:
- What was done
- What is currently being worked on
- Which files are being modified
- What needs to be done next
Your summary should be comprehensive enough to provide context but concise enough to be quickly understood.

View File

@@ -0,0 +1,7 @@
you will generate a short title based on the first message a user begins a conversation with
- ensure it is not more than 50 characters long
- the title should be a summary of the user's message
- it should be one line long
- do not use quotes or colons
- the entire text you return will be used as the title
- never return anything that is more than one sentence (one line) long

View File

@@ -0,0 +1,498 @@
import path from "path";
import { App } from "../app/app";
import { Identifier } from "../id/id";
import { LLM } from "../llm/llm";
import { Storage } from "../storage/storage";
import { Log } from "../util/log";
import {
convertToModelMessages,
generateText,
stepCountIs,
streamText,
type LanguageModelUsage,
} from "ai";
import { z } from "zod";
import * as tools from "../tool";
import { Decimal } from "decimal.js";
import PROMPT_ANTHROPIC from "./prompt/anthropic.txt";
import PROMPT_TITLE from "./prompt/title.txt";
import PROMPT_SUMMARIZE from "./prompt/summarize.txt";
import { Share } from "../share/share";
import { Message } from "./message";
import { Bus } from "../bus";
import type { Provider } from "../provider/provider";
export namespace Session {
const log = Log.create({ service: "session" });
export const Info = z
.object({
id: Identifier.schema("session"),
share: z
.object({
secret: z.string(),
url: z.string(),
})
.optional(),
title: z.string(),
time: z.object({
created: z.number(),
updated: z.number(),
}),
})
.openapi({
ref: "session.info",
});
export type Info = z.output<typeof Info>;
export const Event = {
Updated: Bus.event(
"session.updated",
z.object({
info: Info,
}),
),
};
const state = App.state("session", () => {
const sessions = new Map<string, Info>();
const messages = new Map<string, Message.Info[]>();
return {
sessions,
messages,
};
});
export async function create() {
const result: Info = {
id: Identifier.descending("session"),
title: "New Session - " + new Date().toISOString(),
time: {
created: Date.now(),
updated: Date.now(),
},
};
log.info("created", result);
state().sessions.set(result.id, result);
await Storage.writeJSON("session/info/" + result.id, result);
share(result.id).then((share) => {
update(result.id, (draft) => {
draft.share = share;
});
});
Bus.publish(Event.Updated, {
info: result,
});
return result;
}
export async function get(id: string) {
const result = state().sessions.get(id);
if (result) {
return result;
}
const read = await Storage.readJSON<Info>("session/info/" + id);
state().sessions.set(id, read);
return read as Info;
}
export async function share(id: string) {
const session = await get(id);
if (session.share) return session.share;
const share = await Share.create(id);
await update(id, (draft) => {
draft.share = share;
});
return share;
}
export async function update(id: string, editor: (session: Info) => void) {
const { sessions } = state();
const session = await get(id);
if (!session) return;
editor(session);
session.time.updated = Date.now();
sessions.set(id, session);
await Storage.writeJSON("session/info/" + id, session);
Bus.publish(Event.Updated, {
info: session,
});
return session;
}
export async function messages(sessionID: string) {
const result = [] as Message.Info[];
const list = Storage.list("session/message/" + sessionID);
for await (const p of list) {
const read = await Storage.readJSON<Message.Info>(p).catch(() => {});
if (!read) continue;
result.push(read);
}
result.sort((a, b) => (a.id > b.id ? 1 : -1));
return result;
}
export async function* list() {
for await (const item of Storage.list("session/info")) {
const sessionID = path.basename(item, ".json");
yield get(sessionID);
}
}
export function abort(sessionID: string) {
const controller = pending.get(sessionID);
if (!controller) return false;
controller.abort();
pending.delete(sessionID);
return true;
}
async function updateMessage(msg: Message.Info) {
await Storage.writeJSON(
"session/message/" + msg.metadata.sessionID + "/" + msg.id,
msg,
);
Bus.publish(Message.Event.Updated, {
info: msg,
});
}
export async function chat(input: {
sessionID: string;
providerID: string;
modelID: string;
parts: Message.Part[];
}) {
const l = log.clone().tag("session", input.sessionID);
l.info("chatting");
const model = await LLM.findModel(input.providerID, input.modelID);
let msgs = await messages(input.sessionID);
const previous = msgs.at(-1);
if (previous?.metadata.assistant) {
const tokens =
previous.metadata.assistant.tokens.input +
previous.metadata.assistant.tokens.output;
if (
tokens >
(model.info.contextWindow - (model.info.maxOutputTokens ?? 0)) * 0.9
) {
await summarize({
sessionID: input.sessionID,
providerID: input.providerID,
modelID: input.modelID,
});
return chat(input);
}
}
using abort = lock(input.sessionID);
const lastSummary = msgs.findLast(
(msg) => msg.metadata.assistant?.summary === true,
);
if (lastSummary)
msgs = msgs.filter(
(msg) => msg.role === "system" || msg.id >= lastSummary.id,
);
const app = await App.use();
if (msgs.length === 0) {
const system: Message.Info = {
id: Identifier.ascending("message"),
role: "system",
parts: [
{
type: "text",
text: PROMPT_ANTHROPIC,
},
],
metadata: {
sessionID: input.sessionID,
time: {
created: Date.now(),
},
tool: {},
},
};
const contextFile = Bun.file(path.join(app.root, "CONTEXT.md"));
if (await contextFile.exists()) {
const context = await contextFile.text();
system.parts.push({
type: "text",
text: context,
});
}
msgs.push(system);
generateText({
messages: convertToModelMessages([
{
role: "system",
parts: [
{
type: "text",
text: PROMPT_TITLE,
},
],
},
{
role: "user",
parts: input.parts,
},
]),
model: model.instance,
}).then((result) => {
return Session.update(input.sessionID, (draft) => {
draft.title = result.text;
});
});
await updateMessage(system);
}
const msg: Message.Info = {
role: "user",
id: Identifier.ascending("message"),
parts: input.parts,
metadata: {
time: {
created: Date.now(),
},
sessionID: input.sessionID,
tool: {},
},
};
msgs.push(msg);
await updateMessage(msg);
const next: Message.Info = {
id: Identifier.ascending("message"),
role: "assistant",
parts: [],
metadata: {
assistant: {
cost: 0,
tokens: {
input: 0,
output: 0,
reasoning: 0,
},
modelID: input.modelID,
providerID: input.providerID,
},
time: {
created: Date.now(),
},
sessionID: input.sessionID,
tool: {},
},
};
await updateMessage(next);
const result = streamText({
onStepFinish: async (step) => {
const assistant = next.metadata!.assistant!;
const usage = getUsage(step.usage, model.info);
assistant.cost = usage.cost;
assistant.tokens = usage.tokens;
await updateMessage(next);
},
abortSignal: abort.signal,
maxRetries: 6,
stopWhen: stepCountIs(1000),
messages: convertToModelMessages(msgs),
temperature: 0,
tools,
model: model.instance,
});
let text: Message.TextPart | undefined;
const reader = result.toUIMessageStream().getReader();
while (true) {
const result = await reader.read().catch((e) => {
if (e instanceof DOMException && e.name === "AbortError") {
return;
}
throw e;
});
if (!result) break;
const { done, value } = result;
if (done) break;
l.info("part", {
type: value.type,
});
switch (value.type) {
case "start":
break;
case "start-step":
text = undefined;
next.parts.push({
type: "step-start",
});
break;
case "text":
if (!text) {
text = value;
next.parts.push(value);
break;
}
text.text += value.text;
break;
case "tool-call":
next.parts.push({
type: "tool-invocation",
toolInvocation: {
state: "call",
...value,
// hack until zod v4
args: value.args as any,
},
});
break;
case "tool-result":
const match = next.parts.find(
(p) =>
p.type === "tool-invocation" &&
p.toolInvocation.toolCallId === value.toolCallId,
);
if (match && match.type === "tool-invocation") {
const { output, metadata } = value.result as any;
next.metadata!.tool[value.toolCallId] = metadata;
match.toolInvocation = {
...match.toolInvocation,
state: "result",
result: output,
};
}
break;
case "finish":
break;
case "finish-step":
break;
case "error":
log.error("error", value);
break;
default:
l.info("unhandled", {
type: value.type,
});
}
await updateMessage(next);
}
next.metadata!.time.completed = Date.now();
await updateMessage(next);
return next;
}
export async function summarize(input: {
sessionID: string;
providerID: string;
modelID: string;
}) {
using abort = lock(input.sessionID);
const msgs = await messages(input.sessionID);
const lastSummary = msgs.findLast(
(msg) => msg.metadata.assistant?.summary === true,
)?.id;
const filtered = msgs.filter(
(msg) => msg.role !== "system" && (!lastSummary || msg.id >= lastSummary),
);
const model = await LLM.findModel(input.providerID, input.modelID);
const next: Message.Info = {
id: Identifier.ascending("message"),
role: "assistant",
parts: [],
metadata: {
tool: {},
sessionID: input.sessionID,
assistant: {
summary: true,
cost: 0,
modelID: input.modelID,
providerID: input.providerID,
tokens: {
input: 0,
output: 0,
reasoning: 0,
},
},
time: {
created: Date.now(),
},
},
};
await updateMessage(next);
const result = await generateText({
abortSignal: abort.signal,
model: model.instance,
messages: convertToModelMessages([
{
role: "system",
parts: [
{
type: "text",
text: PROMPT_SUMMARIZE,
},
],
},
...filtered,
{
role: "user",
parts: [
{
type: "text",
text: "Provide a detailed but concise summary of our conversation above. Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next.",
},
],
},
]),
});
next.parts.push({
type: "text",
text: result.text,
});
const assistant = next.metadata!.assistant!;
const usage = getUsage(result.usage, model.info);
assistant.cost = usage.cost;
assistant.tokens = usage.tokens;
await updateMessage(next);
}
const pending = new Map<string, AbortController>();
function lock(sessionID: string) {
log.info("locking", { sessionID });
if (pending.has(sessionID)) throw new BusyError(sessionID);
const controller = new AbortController();
pending.set(sessionID, controller);
return {
signal: controller.signal,
[Symbol.dispose]() {
log.info("unlocking", { sessionID });
pending.delete(sessionID);
},
};
}
function getUsage(usage: LanguageModelUsage, model: Provider.Model) {
const tokens = {
input: usage.inputTokens ?? 0,
output: usage.outputTokens ?? 0,
reasoning: usage.reasoningTokens ?? 0,
};
return {
cost: new Decimal(0)
.add(new Decimal(tokens.input).mul(model.cost.input))
.add(new Decimal(tokens.output).mul(model.cost.output))
.toNumber(),
tokens,
};
}
export class BusyError extends Error {
constructor(public readonly sessionID: string) {
super(`Session ${sessionID} is busy`);
}
}
}

View File

@@ -0,0 +1,67 @@
import { App } from "../app/app";
import { Bus } from "../bus";
import { Session } from "../session/session";
import { Storage } from "../storage/storage";
import { Log } from "../util/log";
export namespace Share {
const log = Log.create({ service: "share" });
let queue: Promise<void> = Promise.resolve();
const pending = new Map<string, any>();
const state = App.state("share", async () => {
Bus.subscribe(Storage.Event.Write, async (payload) => {
const [root, ...splits] = payload.properties.key.split("/");
if (root !== "session") return;
const [, sessionID] = splits;
const session = await Session.get(sessionID);
if (!session.share) return;
const { secret } = session.share;
const key = payload.properties.key;
pending.set(key, payload.properties.content);
queue = queue
.then(async () => {
const content = pending.get(key);
if (content === undefined) return;
pending.delete(key);
return fetch(`${URL}/share_sync`, {
method: "POST",
body: JSON.stringify({
sessionID: sessionID,
secret,
key: key,
content,
}),
});
})
.then((x) => {
if (x) {
log.info("synced", {
key: key,
status: x.status,
});
}
});
});
});
export async function init() {
await state();
}
export const URL =
process.env["OPENCODE_API"] ?? "https://api.dev.opencode.ai";
export async function create(sessionID: string) {
return fetch(`${URL}/share_create`, {
method: "POST",
body: JSON.stringify({ sessionID: sessionID }),
})
.then((x) => x.json())
.then((x) => x as { url: string; secret: string });
}
}

View File

@@ -0,0 +1,55 @@
import { FileStorage } from "@flystorage/file-storage";
import { LocalStorageAdapter } from "@flystorage/local-fs";
import fs from "fs/promises";
import { Log } from "../util/log";
import { App } from "../app/app";
import { AppPath } from "../app/path";
import { Bus } from "../bus";
import z from "zod";
export namespace Storage {
const log = Log.create({ service: "storage" });
export const Event = {
Write: Bus.event(
"storage.write",
z.object({ key: z.string(), content: z.any() }),
),
};
const state = App.state("storage", async () => {
const app = await App.use();
const storageDir = AppPath.storage(app.root);
await fs.mkdir(storageDir, { recursive: true });
const storage = new FileStorage(new LocalStorageAdapter(storageDir));
log.info("created", { path: storageDir });
return {
storage,
};
});
export async function readJSON<T>(key: string) {
const storage = await state().then((x) => x.storage);
const data = await storage.readToString(key + ".json");
return JSON.parse(data) as T;
}
export async function writeJSON<T>(key: string, content: T) {
const storage = await state().then((x) => x.storage);
const json = JSON.stringify(content);
await storage.write(key + ".json", json);
Bus.publish(Event.Write, { key, content });
}
export async function* list(prefix: string) {
try {
const storage = await state().then((x) => x.storage);
const list = storage.list(prefix);
for await (const item of list) {
yield item.path.slice(0, -5);
}
} catch {
return;
}
}
}

View 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"),
};
},
});

View 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,
};
},
});

View 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);
}

View 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"),
};
},
});

View 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"),
};
},
});

View 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";

View 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,
};
},
});

View 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",
};
},
});

View 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),
};
},
});

View 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,
};
},
});

View 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(),
};
}
},
});
}
}

View 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;
}
}

View 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;
}
}

View File

@@ -0,0 +1,25 @@
import { AsyncLocalStorage } from "async_hooks";
export namespace Context {
export class NotFound extends Error {
constructor(public readonly name: string) {
super(`No context found for ${name}`);
}
}
export function create<T>(name: string) {
const storage = new AsyncLocalStorage<T>();
return {
use() {
const result = storage.getStore();
if (!result) {
throw new NotFound(name);
}
return result;
},
provide<R>(value: T, fn: () => R) {
return storage.run<R>(value, fn);
},
};
}
}

View File

View File

@@ -0,0 +1,64 @@
import path from "path";
import { AppPath } from "../app/path";
import fs from "fs/promises";
export namespace Log {
const write = {
out: (msg: string) => {
process.stdout.write(msg);
},
err: (msg: string) => {
process.stderr.write(msg);
},
};
export async function file(directory: string) {
const outPath = path.join(AppPath.data(directory), "opencode.out.log");
const errPath = path.join(AppPath.data(directory), "opencode.err.log");
await fs.truncate(outPath).catch(() => {});
await fs.truncate(errPath).catch(() => {});
const out = Bun.file(outPath);
const err = Bun.file(errPath);
const outWriter = out.writer();
const errWriter = err.writer();
write["out"] = (msg) => {
outWriter.write(msg);
outWriter.flush();
};
write["err"] = (msg) => {
errWriter.write(msg);
errWriter.flush();
};
}
export function create(tags?: Record<string, any>) {
tags = tags || {};
function build(message: any, extra?: Record<string, any>) {
const prefix = Object.entries({
...tags,
...extra,
})
.filter(([_, value]) => value !== undefined && value !== null)
.map(([key, value]) => `${key}=${value}`)
.join(" ");
return [new Date().toISOString(), prefix, message].filter(Boolean).join(" ") + "\n";
}
const result = {
info(message?: any, extra?: Record<string, any>) {
write.out(build(message, extra));
},
error(message?: any, extra?: Record<string, any>) {
write.err(build(message, extra));
},
tag(key: string, value: string) {
if (tags) tags[key] = value;
return result;
},
clone() {
return Log.create({ ...tags });
},
};
return result;
}
}

View File

@@ -0,0 +1,5 @@
export const foo: string = "42";
export function dummyFunction(): void {
console.log("This is a dummy function");
}

View File

@@ -0,0 +1,17 @@
// Bun Snapshot v1, https://goo.gl/fbAQLP
exports[`tool.ls basic 1`] = `
"- /home/thdxr/dev/projects/sst/opencode/js/example/
- home/
- thdxr/
- dev/
- projects/
- sst/
- opencode/
- js/
- example/
- ink.tsx
- broken.ts
- cli.ts
"
`;

View File

@@ -0,0 +1,55 @@
import { describe, expect, test } from "bun:test";
import { App } from "../../src/app/app";
import { glob } from "../../src/tool/glob";
import { ls } from "../../src/tool/ls";
describe("tool.glob", () => {
test("truncate", async () => {
await App.provide({ directory: process.cwd() }, async () => {
let result = await glob.execute(
{
pattern: "./node_modules/**/*",
},
{
toolCallId: "test",
messages: [],
},
);
expect(result.metadata.truncated).toBe(true);
});
});
test("basic", async () => {
await App.provide({ directory: process.cwd() }, async () => {
let result = await glob.execute(
{
pattern: "*.json",
},
{
toolCallId: "test",
messages: [],
},
);
expect(result.metadata).toMatchObject({
truncated: false,
count: 2,
});
});
});
});
describe("tool.ls", () => {
test("basic", async () => {
const result = await App.provide({ directory: process.cwd() }, async () => {
return await ls.execute(
{
path: "./example",
},
{
toolCallId: "test",
messages: [],
},
);
});
expect(result.output).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,5 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {}
}