Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc7af31fe5 | ||
|
|
2f8d23ec66 | ||
|
|
77ae3fb9b9 | ||
|
|
4e7f6c47fd | ||
|
|
50469ed750 | ||
|
|
aaab785493 |
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "0.0.0",
|
||||
"name": "opencode",
|
||||
"version": "0.0.5", "name": "opencode",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -26,6 +26,18 @@ import { ServeCommand } from "./cli/cmd/serve"
|
||||
|
||||
const cancel = new AbortController()
|
||||
|
||||
process.on("unhandledRejection", (e) => {
|
||||
Log.Default.error("rejection", {
|
||||
e: e instanceof Error ? e.message : e,
|
||||
})
|
||||
})
|
||||
|
||||
process.on("uncaughtException", (e) => {
|
||||
Log.Default.error("exception", {
|
||||
e: e instanceof Error ? e.message : e,
|
||||
})
|
||||
})
|
||||
|
||||
const cli = yargs(hideBin(process.argv))
|
||||
.scriptName("opencode")
|
||||
.help("help", "show help")
|
||||
@@ -54,7 +66,12 @@ const cli = yargs(hideBin(process.argv))
|
||||
handler: async (args) => {
|
||||
while (true) {
|
||||
const cwd = args.project ? path.resolve(args.project) : process.cwd()
|
||||
process.chdir(cwd)
|
||||
try {
|
||||
process.chdir(cwd)
|
||||
} catch (e) {
|
||||
UI.error("Failed to change directory to " + cwd)
|
||||
return
|
||||
}
|
||||
const result = await App.provide({ cwd }, async (app) => {
|
||||
const providers = await Provider.list()
|
||||
if (Object.keys(providers).length === 0) {
|
||||
|
||||
@@ -28,20 +28,21 @@ import { TaskTool } from "../tool/task"
|
||||
export namespace Provider {
|
||||
const log = Log.create({ service: "provider" })
|
||||
|
||||
type CustomLoader = (provider: ModelsDev.Provider) => Promise<
|
||||
| {
|
||||
getModel?: (sdk: any, modelID: string) => Promise<any>
|
||||
options: Record<string, any>
|
||||
}
|
||||
| false
|
||||
>
|
||||
type CustomLoader = (
|
||||
provider: ModelsDev.Provider,
|
||||
api?: string,
|
||||
) => Promise<{
|
||||
autoload: boolean
|
||||
getModel?: (sdk: any, modelID: string) => Promise<any>
|
||||
options?: Record<string, any>
|
||||
}>
|
||||
|
||||
type Source = "env" | "config" | "custom" | "api"
|
||||
|
||||
const CUSTOM_LOADERS: Record<string, CustomLoader> = {
|
||||
async anthropic(provider) {
|
||||
const access = await AuthAnthropic.access()
|
||||
if (!access) return false
|
||||
if (!access) return { autoload: false }
|
||||
for (const model of Object.values(provider.models)) {
|
||||
model.cost = {
|
||||
input: 0,
|
||||
@@ -49,6 +50,7 @@ export namespace Provider {
|
||||
}
|
||||
}
|
||||
return {
|
||||
autoload: true,
|
||||
options: {
|
||||
apiKey: "",
|
||||
async fetch(input: any, init: any) {
|
||||
@@ -69,9 +71,9 @@ export namespace Provider {
|
||||
},
|
||||
"github-copilot": async (provider) => {
|
||||
const copilot = await AuthCopilot()
|
||||
if (!copilot) return false
|
||||
if (!copilot) return { autoload: false }
|
||||
let info = await Auth.get("github-copilot")
|
||||
if (!info || info.type !== "oauth") return false
|
||||
if (!info || info.type !== "oauth") return { autoload: false }
|
||||
|
||||
if (provider && provider.models) {
|
||||
for (const model of Object.values(provider.models)) {
|
||||
@@ -83,6 +85,7 @@ export namespace Provider {
|
||||
}
|
||||
|
||||
return {
|
||||
autoload: true,
|
||||
options: {
|
||||
apiKey: "",
|
||||
async fetch(input: any, init: any) {
|
||||
@@ -113,9 +116,18 @@ export namespace Provider {
|
||||
},
|
||||
}
|
||||
},
|
||||
openai: async () => {
|
||||
return {
|
||||
autoload: false,
|
||||
async getModel(sdk: any, modelID: string) {
|
||||
return sdk.responses(modelID)
|
||||
},
|
||||
options: {},
|
||||
}
|
||||
},
|
||||
"amazon-bedrock": async () => {
|
||||
if (!process.env["AWS_PROFILE"] && !process.env["AWS_ACCESS_KEY_ID"])
|
||||
return false
|
||||
return { autoload: false }
|
||||
|
||||
const region = process.env["AWS_REGION"] ?? "us-east-1"
|
||||
|
||||
@@ -123,6 +135,7 @@ export namespace Provider {
|
||||
await BunProc.install("@aws-sdk/credential-providers")
|
||||
)
|
||||
return {
|
||||
autoload: true,
|
||||
options: {
|
||||
region,
|
||||
credentialProvider: fromNodeProviderChain(),
|
||||
@@ -248,8 +261,13 @@ export namespace Provider {
|
||||
for (const [providerID, fn] of Object.entries(CUSTOM_LOADERS)) {
|
||||
if (disabled.has(providerID)) continue
|
||||
const result = await fn(database[providerID])
|
||||
if (result) {
|
||||
mergeProvider(providerID, result.options, "custom", result.getModel)
|
||||
if (result && (result.autoload || providers[providerID])) {
|
||||
mergeProvider(
|
||||
providerID,
|
||||
result.options ?? {},
|
||||
"custom",
|
||||
result.getModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -657,6 +657,21 @@ export namespace Session {
|
||||
}
|
||||
break
|
||||
|
||||
case "finish":
|
||||
log.info("message finish", {
|
||||
reason: value.finishReason,
|
||||
})
|
||||
const assistant = next.metadata!.assistant!
|
||||
const usage = getUsage(
|
||||
model.info,
|
||||
value.usage,
|
||||
value.providerMetadata,
|
||||
)
|
||||
assistant.cost = usage.cost
|
||||
await updateMessage(next)
|
||||
if (value.finishReason === "length")
|
||||
throw new Message.OutputLengthError({})
|
||||
break
|
||||
default:
|
||||
l.info("unhandled", {
|
||||
type: value.type,
|
||||
@@ -670,6 +685,9 @@ export namespace Session {
|
||||
error: e,
|
||||
})
|
||||
switch (true) {
|
||||
case Message.OutputLengthError.isInstance(e):
|
||||
next.metadata.error = e
|
||||
break
|
||||
case LoadAPIKeyError.isInstance(e):
|
||||
next.metadata.error = new Provider.AuthError(
|
||||
{
|
||||
|
||||
@@ -4,6 +4,11 @@ import { Provider } from "../provider/provider"
|
||||
import { NamedError } from "../util/error"
|
||||
|
||||
export namespace Message {
|
||||
export const OutputLengthError = NamedError.create(
|
||||
"MessageOutputLengthError",
|
||||
z.object({}),
|
||||
)
|
||||
|
||||
export const ToolCall = z
|
||||
.object({
|
||||
state: z.literal("call"),
|
||||
@@ -145,6 +150,7 @@ export namespace Message {
|
||||
.discriminatedUnion("name", [
|
||||
Provider.AuthError.Schema,
|
||||
NamedError.Unknown.Schema,
|
||||
OutputLengthError.Schema,
|
||||
])
|
||||
.optional(),
|
||||
sessionID: z.string(),
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// the approaches in this edit tool are sourced from
|
||||
// https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-23-25.ts
|
||||
|
||||
import { z } from "zod"
|
||||
import * as path from "path"
|
||||
import { Tool } from "./tool"
|
||||
@@ -55,35 +58,19 @@ export const EditTool = Tool.define({
|
||||
}
|
||||
|
||||
const file = Bun.file(filepath)
|
||||
if (!(await file.exists())) throw new Error(`File ${filepath} not found`)
|
||||
const stats = await file.stat()
|
||||
const stats = await file.stat().catch(() => {})
|
||||
if (!stats) throw new Error(`File ${filepath} not found`)
|
||||
if (stats.isDirectory())
|
||||
throw new Error(`Path is a directory, not a file: ${filepath}`)
|
||||
await FileTimes.assert(ctx.sessionID, filepath)
|
||||
contentOld = await file.text()
|
||||
const index = contentOld.indexOf(params.oldString)
|
||||
if (index === -1)
|
||||
throw new Error(
|
||||
`oldString not found in file. Make sure it matches exactly, including whitespace and line breaks`,
|
||||
)
|
||||
|
||||
if (params.replaceAll) {
|
||||
contentNew = contentOld.replaceAll(params.oldString, params.newString)
|
||||
}
|
||||
|
||||
if (!params.replaceAll) {
|
||||
const lastIndex = contentOld.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`,
|
||||
)
|
||||
|
||||
contentNew =
|
||||
contentOld.substring(0, index) +
|
||||
params.newString +
|
||||
contentOld.substring(index + params.oldString.length)
|
||||
}
|
||||
|
||||
contentNew = replace(
|
||||
contentOld,
|
||||
params.oldString,
|
||||
params.newString,
|
||||
params.replaceAll,
|
||||
)
|
||||
await file.write(contentNew)
|
||||
})()
|
||||
|
||||
@@ -116,6 +103,169 @@ export const EditTool = Tool.define({
|
||||
},
|
||||
})
|
||||
|
||||
export type Replacer = (
|
||||
content: string,
|
||||
find: string,
|
||||
) => Generator<string, void, unknown>
|
||||
|
||||
export const SimpleReplacer: Replacer = function* (_content, find) {
|
||||
yield find
|
||||
}
|
||||
|
||||
export const LineTrimmedReplacer: Replacer = function* (content, find) {
|
||||
const originalLines = content.split("\n")
|
||||
const searchLines = find.split("\n")
|
||||
|
||||
if (searchLines[searchLines.length - 1] === "") {
|
||||
searchLines.pop()
|
||||
}
|
||||
|
||||
for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
|
||||
let matches = true
|
||||
|
||||
for (let j = 0; j < searchLines.length; j++) {
|
||||
const originalTrimmed = originalLines[i + j].trim()
|
||||
const searchTrimmed = searchLines[j].trim()
|
||||
|
||||
if (originalTrimmed !== searchTrimmed) {
|
||||
matches = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (matches) {
|
||||
let matchStartIndex = 0
|
||||
for (let k = 0; k < i; k++) {
|
||||
matchStartIndex += originalLines[k].length + 1
|
||||
}
|
||||
|
||||
let matchEndIndex = matchStartIndex
|
||||
for (let k = 0; k < searchLines.length; k++) {
|
||||
matchEndIndex += originalLines[i + k].length + 1
|
||||
}
|
||||
|
||||
yield content.substring(matchStartIndex, matchEndIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const BlockAnchorReplacer: Replacer = function* (content, find) {
|
||||
const originalLines = content.split("\n")
|
||||
const searchLines = find.split("\n")
|
||||
|
||||
if (searchLines.length < 3) {
|
||||
return
|
||||
}
|
||||
|
||||
if (searchLines[searchLines.length - 1] === "") {
|
||||
searchLines.pop()
|
||||
}
|
||||
|
||||
const firstLineSearch = searchLines[0].trim()
|
||||
const lastLineSearch = searchLines[searchLines.length - 1].trim()
|
||||
|
||||
// Find blocks where first line matches the search first line
|
||||
for (let i = 0; i < originalLines.length; i++) {
|
||||
if (originalLines[i].trim() !== firstLineSearch) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Look for the matching last line after this first line
|
||||
for (let j = i + 2; j < originalLines.length; j++) {
|
||||
if (originalLines[j].trim() === lastLineSearch) {
|
||||
// Found a potential block from i to j
|
||||
let matchStartIndex = 0
|
||||
for (let k = 0; k < i; k++) {
|
||||
matchStartIndex += originalLines[k].length + 1
|
||||
}
|
||||
|
||||
let matchEndIndex = matchStartIndex
|
||||
for (let k = 0; k <= j - i; k++) {
|
||||
matchEndIndex += originalLines[i + k].length + 1
|
||||
}
|
||||
|
||||
yield content.substring(matchStartIndex, matchEndIndex)
|
||||
break // Only match the first occurrence of the last line
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const WhitespaceNormalizedReplacer: Replacer = function* (
|
||||
content,
|
||||
find,
|
||||
) {
|
||||
const normalizeWhitespace = (text: string) => text.replace(/\s+/g, " ").trim()
|
||||
const normalizedFind = normalizeWhitespace(find)
|
||||
|
||||
// Handle single line matches
|
||||
const lines = content.split("\n")
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
if (normalizeWhitespace(line) === normalizedFind) {
|
||||
yield line
|
||||
}
|
||||
|
||||
// Also check for substring matches within lines
|
||||
const normalizedLine = normalizeWhitespace(line)
|
||||
if (normalizedLine.includes(normalizedFind)) {
|
||||
// Find the actual substring in the original line that matches
|
||||
const words = find.trim().split(/\s+/)
|
||||
if (words.length > 0) {
|
||||
const pattern = words
|
||||
.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
||||
.join("\\s+")
|
||||
const regex = new RegExp(pattern)
|
||||
const match = line.match(regex)
|
||||
if (match) {
|
||||
yield match[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle multi-line matches
|
||||
const findLines = find.split("\n")
|
||||
if (findLines.length > 1) {
|
||||
for (let i = 0; i <= lines.length - findLines.length; i++) {
|
||||
const block = lines.slice(i, i + findLines.length)
|
||||
if (normalizeWhitespace(block.join("\n")) === normalizedFind) {
|
||||
yield block.join("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const IndentationFlexibleReplacer: Replacer = function* (content, find) {
|
||||
const removeIndentation = (text: string) => {
|
||||
const lines = text.split("\n")
|
||||
const nonEmptyLines = lines.filter((line) => line.trim().length > 0)
|
||||
if (nonEmptyLines.length === 0) return text
|
||||
|
||||
const minIndent = Math.min(
|
||||
...nonEmptyLines.map((line) => {
|
||||
const match = line.match(/^(\s*)/)
|
||||
return match ? match[1].length : 0
|
||||
}),
|
||||
)
|
||||
|
||||
return lines
|
||||
.map((line) => (line.trim().length === 0 ? line : line.slice(minIndent)))
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
const normalizedFind = removeIndentation(find)
|
||||
const contentLines = content.split("\n")
|
||||
const findLines = find.split("\n")
|
||||
|
||||
for (let i = 0; i <= contentLines.length - findLines.length; i++) {
|
||||
const block = contentLines.slice(i, i + findLines.length).join("\n")
|
||||
if (removeIndentation(block) === normalizedFind) {
|
||||
yield block
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function trimDiff(diff: string): string {
|
||||
const lines = diff.split("\n")
|
||||
const contentLines = lines.filter(
|
||||
@@ -151,3 +301,34 @@ function trimDiff(diff: string): string {
|
||||
|
||||
return trimmedLines.join("\n")
|
||||
}
|
||||
|
||||
export function replace(
|
||||
content: string,
|
||||
oldString: string,
|
||||
newString: string,
|
||||
replaceAll = false,
|
||||
): string {
|
||||
for (const replacer of [
|
||||
SimpleReplacer,
|
||||
LineTrimmedReplacer,
|
||||
BlockAnchorReplacer,
|
||||
WhitespaceNormalizedReplacer,
|
||||
IndentationFlexibleReplacer,
|
||||
]) {
|
||||
for (const search of replacer(content, oldString)) {
|
||||
const index = content.indexOf(search)
|
||||
if (index === -1) continue
|
||||
if (replaceAll) {
|
||||
return content.replaceAll(search, newString)
|
||||
}
|
||||
const lastIndex = content.lastIndexOf(search)
|
||||
if (index !== lastIndex) continue
|
||||
return (
|
||||
content.substring(0, index) +
|
||||
newString +
|
||||
content.substring(index + search.length)
|
||||
)
|
||||
}
|
||||
}
|
||||
throw new Error("oldString not found in content or was found multiple times")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { replace } from "../../src/tool/edit"
|
||||
|
||||
interface TestCase {
|
||||
content: string
|
||||
find: string
|
||||
replace: string
|
||||
all?: boolean
|
||||
fail?: boolean
|
||||
}
|
||||
|
||||
const testCases: TestCase[] = [
|
||||
// SimpleReplacer cases
|
||||
{
|
||||
content: ["function hello() {", ' console.log("world");', "}"].join("\n"),
|
||||
find: 'console.log("world");',
|
||||
replace: 'console.log("universe");',
|
||||
},
|
||||
{
|
||||
content: [
|
||||
"if (condition) {",
|
||||
" doSomething();",
|
||||
" doSomethingElse();",
|
||||
"}",
|
||||
].join("\n"),
|
||||
find: [" doSomething();", " doSomethingElse();"].join("\n"),
|
||||
replace: [" doNewThing();", " doAnotherThing();"].join("\n"),
|
||||
},
|
||||
|
||||
// LineTrimmedReplacer cases
|
||||
{
|
||||
content: ["function test() {", ' console.log("hello");', "}"].join("\n"),
|
||||
find: 'console.log("hello");',
|
||||
replace: 'console.log("goodbye");',
|
||||
},
|
||||
{
|
||||
content: ["const x = 5; ", "const y = 10;"].join("\n"),
|
||||
find: "const x = 5;",
|
||||
replace: "const x = 15;",
|
||||
},
|
||||
{
|
||||
content: [" if (true) {", " return false;", " }"].join("\n"),
|
||||
find: ["if (true) {", "return false;", "}"].join("\n"),
|
||||
replace: ["if (false) {", "return true;", "}"].join("\n"),
|
||||
},
|
||||
|
||||
// BlockAnchorReplacer cases
|
||||
{
|
||||
content: [
|
||||
"function calculate(a, b) {",
|
||||
" const temp = a + b;",
|
||||
" const result = temp * 2;",
|
||||
" return result;",
|
||||
"}",
|
||||
].join("\n"),
|
||||
find: [
|
||||
"function calculate(a, b) {",
|
||||
" // different middle content",
|
||||
" return result;",
|
||||
"}",
|
||||
].join("\n"),
|
||||
replace: ["function calculate(a, b) {", " return a * b * 2;", "}"].join(
|
||||
"\n",
|
||||
),
|
||||
},
|
||||
{
|
||||
content: [
|
||||
"class MyClass {",
|
||||
" constructor() {",
|
||||
" this.value = 0;",
|
||||
" }",
|
||||
" ",
|
||||
" getValue() {",
|
||||
" return this.value;",
|
||||
" }",
|
||||
"}",
|
||||
].join("\n"),
|
||||
find: ["class MyClass {", " // different implementation", "}"].join("\n"),
|
||||
replace: [
|
||||
"class MyClass {",
|
||||
" constructor() {",
|
||||
" this.value = 42;",
|
||||
" }",
|
||||
"}",
|
||||
].join("\n"),
|
||||
},
|
||||
|
||||
// WhitespaceNormalizedReplacer cases
|
||||
{
|
||||
content: ["function test() {", '\tconsole.log("hello");', "}"].join("\n"),
|
||||
find: ' console.log("hello");',
|
||||
replace: ' console.log("world");',
|
||||
},
|
||||
{
|
||||
content: "const x = 5;",
|
||||
find: "const x = 5;",
|
||||
replace: "const x = 10;",
|
||||
},
|
||||
{
|
||||
content: "if\t( condition\t) {",
|
||||
find: "if ( condition ) {",
|
||||
replace: "if (newCondition) {",
|
||||
},
|
||||
|
||||
// IndentationFlexibleReplacer cases
|
||||
{
|
||||
content: [
|
||||
" function nested() {",
|
||||
' console.log("deeply nested");',
|
||||
" return true;",
|
||||
" }",
|
||||
].join("\n"),
|
||||
find: [
|
||||
"function nested() {",
|
||||
' console.log("deeply nested");',
|
||||
" return true;",
|
||||
"}",
|
||||
].join("\n"),
|
||||
replace: [
|
||||
"function nested() {",
|
||||
' console.log("updated");',
|
||||
" return false;",
|
||||
"}",
|
||||
].join("\n"),
|
||||
},
|
||||
{
|
||||
content: [
|
||||
" if (true) {",
|
||||
' console.log("level 1");',
|
||||
' console.log("level 2");',
|
||||
" }",
|
||||
].join("\n"),
|
||||
find: [
|
||||
"if (true) {",
|
||||
'console.log("level 1");',
|
||||
' console.log("level 2");',
|
||||
"}",
|
||||
].join("\n"),
|
||||
replace: ["if (true) {", 'console.log("updated");', "}"].join("\n"),
|
||||
},
|
||||
|
||||
// replaceAll option cases
|
||||
{
|
||||
content: [
|
||||
'console.log("test");',
|
||||
'console.log("test");',
|
||||
'console.log("test");',
|
||||
].join("\n"),
|
||||
find: 'console.log("test");',
|
||||
replace: 'console.log("updated");',
|
||||
all: true,
|
||||
},
|
||||
{
|
||||
content: ['console.log("test");', 'console.log("test");'].join("\n"),
|
||||
find: 'console.log("test");',
|
||||
replace: 'console.log("updated");',
|
||||
all: false,
|
||||
},
|
||||
|
||||
// Error cases
|
||||
{
|
||||
content: 'console.log("hello");',
|
||||
find: "nonexistent string",
|
||||
replace: "updated",
|
||||
fail: true,
|
||||
},
|
||||
{
|
||||
content: ["test", "test", "different content", "test"].join("\n"),
|
||||
find: "test",
|
||||
replace: "updated",
|
||||
all: false,
|
||||
fail: true,
|
||||
},
|
||||
|
||||
// Edge cases
|
||||
{
|
||||
content: "",
|
||||
find: "",
|
||||
replace: "new content",
|
||||
},
|
||||
{
|
||||
content: "const regex = /[.*+?^${}()|[\\\\]\\\\\\\\]/g;",
|
||||
find: "/[.*+?^${}()|[\\\\]\\\\\\\\]/g",
|
||||
replace: "/\\\\w+/g",
|
||||
},
|
||||
{
|
||||
content: 'const message = "Hello 世界! 🌍";',
|
||||
find: "Hello 世界! 🌍",
|
||||
replace: "Hello World! 🌎",
|
||||
},
|
||||
]
|
||||
|
||||
describe("EditTool Replacers", () => {
|
||||
test.each(testCases)("case %#", (testCase) => {
|
||||
if (testCase.fail) {
|
||||
expect(() => {
|
||||
replace(testCase.content, testCase.find, testCase.replace, testCase.all)
|
||||
}).toThrow()
|
||||
} else {
|
||||
const result = replace(
|
||||
testCase.content,
|
||||
testCase.find,
|
||||
testCase.replace,
|
||||
testCase.all,
|
||||
)
|
||||
expect(result).toContain(testCase.replace)
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user