mirror of
https://github.com/aljazceru/opencode.git
synced 2025-12-30 06:04:20 +01:00
sync
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import { App } from "./app";
|
||||
import { Server } from "./server/server";
|
||||
import { Cli, Command, Option, runExit } from "clipanion";
|
||||
import { Cli, Command, Option } from "clipanion";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { Bus } from "./bus";
|
||||
import { Session } from "./session/session";
|
||||
import { LSP } from "./lsp";
|
||||
|
||||
const cli = new Cli({
|
||||
binaryLabel: `opencode`,
|
||||
@@ -25,6 +26,7 @@ cli.register(
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
cli.register(
|
||||
class extends Command {
|
||||
static paths = [["generate"]];
|
||||
@@ -71,6 +73,9 @@ cli.register(
|
||||
"tool:",
|
||||
part.toolInvocation.toolName,
|
||||
part.toolInvocation.args,
|
||||
part.toolInvocation.state === "result"
|
||||
? part.toolInvocation.result
|
||||
: "",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
178
js/src/lsp/client.ts
Normal file
178
js/src/lsp/client.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { spawn } from "child_process";
|
||||
import path from "path";
|
||||
import {
|
||||
createMessageConnection,
|
||||
Disposable,
|
||||
StreamMessageReader,
|
||||
StreamMessageWriter,
|
||||
} from "vscode-jsonrpc/node";
|
||||
import { App } from "../app";
|
||||
import { Log } from "../util/log";
|
||||
import { LANGUAGE_EXTENSIONS } from "./language";
|
||||
|
||||
export namespace LSPClient {
|
||||
const log = Log.create({ service: "lsp.client" });
|
||||
|
||||
export type Info = Awaited<ReturnType<typeof create>>;
|
||||
|
||||
export async function create(input: { cmd: string[] }) {
|
||||
log.info("starting client", input);
|
||||
let version = 0;
|
||||
|
||||
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, any>();
|
||||
connection.onNotification("textDocument/publishDiagnostics", (params) => {
|
||||
log.info("textDocument/publishDiagnostics", {
|
||||
path: new URL(params.uri).pathname,
|
||||
});
|
||||
diagnostics.set(new URL(params.uri).pathname, params.diagnostics);
|
||||
});
|
||||
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 result = {
|
||||
get connection() {
|
||||
return connection;
|
||||
},
|
||||
notify: {
|
||||
async open(input: { path: string }) {
|
||||
log.info("textDocument/didOpen", input);
|
||||
diagnostics.delete(input.path);
|
||||
const text = await Bun.file(input.path).text();
|
||||
const languageId = LANGUAGE_EXTENSIONS[path.extname(input.path)];
|
||||
await connection.sendNotification("textDocument/didOpen", {
|
||||
textDocument: {
|
||||
uri: `file://` + input.path,
|
||||
languageId,
|
||||
version: 1,
|
||||
text: text,
|
||||
},
|
||||
});
|
||||
},
|
||||
async change(input: { path: string }) {
|
||||
log.info("textDocument/didChange", input);
|
||||
diagnostics.delete(input.path);
|
||||
const text = await Bun.file(input.path).text();
|
||||
version++;
|
||||
await connection.sendNotification("textDocument/didChange", {
|
||||
textDocument: {
|
||||
uri: `file://` + input.path,
|
||||
version: Date.now(),
|
||||
},
|
||||
contentChanges: [
|
||||
{
|
||||
text,
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
},
|
||||
get diagnostics() {
|
||||
return diagnostics;
|
||||
},
|
||||
async refreshDiagnostics(input: { path: string }) {
|
||||
log.info("refreshing diagnostics", input);
|
||||
let notif: Disposable | undefined;
|
||||
return await Promise.race([
|
||||
new Promise<void>(async (resolve) => {
|
||||
notif = connection.onNotification(
|
||||
"textDocument/publishDiagnostics",
|
||||
(params) => {
|
||||
log.info("refreshed diagnostics", input);
|
||||
if (new URL(params.uri).pathname === input.path) {
|
||||
diagnostics.set(
|
||||
new URL(params.uri).pathname,
|
||||
params.diagnostics,
|
||||
);
|
||||
resolve();
|
||||
notif?.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
await result.notify.change(input);
|
||||
}),
|
||||
new Promise<void>((resolve) =>
|
||||
setTimeout(() => {
|
||||
notif?.dispose();
|
||||
resolve();
|
||||
}, 5000),
|
||||
),
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
31
js/src/lsp/index.ts
Normal file
31
js/src/lsp/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { App } from "../app";
|
||||
import { Log } from "../util/log";
|
||||
import { LSPClient } from "./client";
|
||||
|
||||
export namespace LSP {
|
||||
const log = Log.create({ service: "lsp" });
|
||||
|
||||
const state = App.state("lsp", async () => {
|
||||
const clients = new Map<string, LSPClient.Info>();
|
||||
|
||||
clients.set(
|
||||
"typescript",
|
||||
await LSPClient.create({
|
||||
cmd: ["bun", "x", "typescript-language-server", "--stdio"],
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
clients,
|
||||
diagnostics: new Map<string, any>(),
|
||||
};
|
||||
});
|
||||
|
||||
export 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);
|
||||
}
|
||||
}
|
||||
83
js/src/lsp/language.ts
Normal file
83
js/src/lsp/language.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
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",
|
||||
".xml": "xml",
|
||||
".xsl": "xsl",
|
||||
".yaml": "yaml",
|
||||
".yml": "yaml",
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import * as path from "path";
|
||||
import { Log } from "../util/log";
|
||||
import { Tool } from "./tool";
|
||||
import { FileTimes } from "./util/file-times";
|
||||
import { LSP } from "../lsp";
|
||||
|
||||
const log = Log.create({ service: "tool.edit" });
|
||||
|
||||
@@ -78,7 +79,7 @@ Before using this tool:
|
||||
- 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 absolute path to the file to modify (must be absolute, not relative)
|
||||
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
|
||||
|
||||
@@ -112,7 +113,7 @@ WARNING: If you do not follow these requirements:
|
||||
When making edits:
|
||||
- Ensure the edit results in idiomatic, correct code
|
||||
- Do not leave the code in a broken state
|
||||
- Always use absolute file paths (starting with /)
|
||||
- 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.`;
|
||||
|
||||
@@ -134,21 +135,68 @@ export const EditTool = Tool.define({
|
||||
filePath = path.join(process.cwd(), filePath);
|
||||
}
|
||||
|
||||
// Handle different operations based on parameters
|
||||
if (params.old_string === "") {
|
||||
return {
|
||||
output: createNewFile(filePath, params.new_string),
|
||||
};
|
||||
}
|
||||
await (async () => {
|
||||
if (params.old_string === "") {
|
||||
await createNewFile(filePath, params.new_string);
|
||||
return;
|
||||
}
|
||||
|
||||
if (params.new_string === "") {
|
||||
return {
|
||||
output: deleteContent(filePath, params.old_string),
|
||||
};
|
||||
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.old_string);
|
||||
if (index === -1)
|
||||
throw new Error(
|
||||
`old_string not found in file. Make sure it matches exactly, including whitespace and line breaks`,
|
||||
);
|
||||
const lastIndex = content.lastIndexOf(params.old_string);
|
||||
if (index !== lastIndex)
|
||||
throw new Error(
|
||||
`old_string appears multiple times in the file. Please provide more context to ensure a unique match`,
|
||||
);
|
||||
|
||||
const newContent =
|
||||
content.substring(0, index) +
|
||||
params.new_string +
|
||||
content.substring(index + params.old_string.length);
|
||||
|
||||
console.log(newContent);
|
||||
await file.write(newContent);
|
||||
})();
|
||||
|
||||
FileTimes.write(filePath);
|
||||
FileTimes.read(filePath);
|
||||
|
||||
let output = "";
|
||||
await LSP.run((client) => client.refreshDiagnostics({ path: filePath }));
|
||||
const diagnostics = await LSP.run(async (client) => client.diagnostics);
|
||||
for (const diagnostic of diagnostics) {
|
||||
for (const [file, params] of diagnostic.entries()) {
|
||||
if (params.length === 0) continue;
|
||||
if (file === filePath) {
|
||||
output += `\nThis file has errors, please fix\n<file_diagnostics>\n${JSON.stringify(params)}\n</file_diagnostics>\n`;
|
||||
continue;
|
||||
}
|
||||
output += `\n<project_diagnostics>\n${JSON.stringify(params)}\n</project_diagnostics>\n`;
|
||||
}
|
||||
}
|
||||
console.log(output);
|
||||
|
||||
return {
|
||||
output: replaceContent(filePath, params.old_string, params.new_string),
|
||||
output,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -186,160 +234,4 @@ async function createNewFile(
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteContent(
|
||||
filePath: string,
|
||||
oldString: string,
|
||||
): Promise<string> {
|
||||
try {
|
||||
// Check if file exists
|
||||
let fileStats;
|
||||
try {
|
||||
fileStats = fs.statSync(filePath);
|
||||
if (fileStats.isDirectory()) {
|
||||
throw new Error(`Path is a directory, not a file: ${filePath}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
throw new Error(`File not found: ${filePath}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const lastReadTime = FileTimes.get(filePath);
|
||||
if (!lastReadTime) {
|
||||
throw new Error(
|
||||
"You must read the file before editing it. Use the View tool first",
|
||||
);
|
||||
}
|
||||
|
||||
const modTime = fileStats.mtime;
|
||||
if (modTime > lastReadTime) {
|
||||
throw new Error(
|
||||
`File ${filePath} has been modified since it was last read (mod time: ${modTime.toISOString()}, last read: ${lastReadTime.toISOString()})`,
|
||||
);
|
||||
}
|
||||
|
||||
const oldContent = fs.readFileSync(filePath, "utf8");
|
||||
const index = oldContent.indexOf(oldString);
|
||||
if (index === -1) {
|
||||
throw new Error(
|
||||
"old_string not found in file. Make sure it matches exactly, including whitespace and line breaks",
|
||||
);
|
||||
}
|
||||
|
||||
const lastIndex = oldContent.lastIndexOf(oldString);
|
||||
if (index !== lastIndex) {
|
||||
throw new Error(
|
||||
"old_string appears multiple times in the file. Please provide more context to ensure a unique match",
|
||||
);
|
||||
}
|
||||
|
||||
const newContent =
|
||||
oldContent.substring(0, index) +
|
||||
oldContent.substring(index + oldString.length);
|
||||
|
||||
const { diff, additions, removals } = generateDiff(
|
||||
oldContent,
|
||||
newContent,
|
||||
filePath,
|
||||
);
|
||||
|
||||
// Write the file
|
||||
fs.writeFileSync(filePath, newContent);
|
||||
|
||||
FileTimes.write(filePath);
|
||||
FileTimes.read(filePath);
|
||||
|
||||
return `Content deleted from file: ${filePath}`;
|
||||
} catch (err: any) {
|
||||
throw new Error(`Failed to delete content: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function replaceContent(
|
||||
filePath: string,
|
||||
oldString: string,
|
||||
newString: string,
|
||||
): Promise<string> {
|
||||
try {
|
||||
// Check if file exists
|
||||
let fileStats;
|
||||
try {
|
||||
fileStats = fs.statSync(filePath);
|
||||
if (fileStats.isDirectory()) {
|
||||
throw new Error(`Path is a directory, not a file: ${filePath}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.code === "ENOENT") {
|
||||
throw new Error(`File not found: ${filePath}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Check if file has been read before
|
||||
const lastReadTime = getLastReadTime(filePath);
|
||||
if (!lastReadTime) {
|
||||
throw new Error(
|
||||
"You must read the file before editing it. Use the View tool first",
|
||||
);
|
||||
}
|
||||
|
||||
// Check if file has been modified since last read
|
||||
const modTime = fileStats.mtime;
|
||||
if (modTime > lastReadTime) {
|
||||
throw new Error(
|
||||
`File ${filePath} has been modified since it was last read (mod time: ${modTime.toISOString()}, last read: ${lastReadTime.toISOString()})`,
|
||||
);
|
||||
}
|
||||
|
||||
// Read the file content
|
||||
const oldContent = fs.readFileSync(filePath, "utf8");
|
||||
|
||||
// Find the string to replace
|
||||
const index = oldContent.indexOf(oldString);
|
||||
if (index === -1) {
|
||||
throw new Error(
|
||||
"old_string not found in file. Make sure it matches exactly, including whitespace and line breaks",
|
||||
);
|
||||
}
|
||||
|
||||
// Check if the string appears multiple times
|
||||
const lastIndex = oldContent.lastIndexOf(oldString);
|
||||
if (index !== lastIndex) {
|
||||
throw new Error(
|
||||
"old_string appears multiple times in the file. Please provide more context to ensure a unique match",
|
||||
);
|
||||
}
|
||||
|
||||
// Create the new content
|
||||
const newContent =
|
||||
oldContent.substring(0, index) +
|
||||
newString +
|
||||
oldContent.substring(index + oldString.length);
|
||||
|
||||
// Check if content actually changed
|
||||
if (oldContent === newContent) {
|
||||
throw new Error(
|
||||
"new content is the same as old content. No changes made.",
|
||||
);
|
||||
}
|
||||
|
||||
// Generate diff
|
||||
const { diff, additions, removals } = generateDiff(
|
||||
oldContent,
|
||||
newContent,
|
||||
filePath,
|
||||
);
|
||||
|
||||
// Write the file
|
||||
fs.writeFileSync(filePath, newContent);
|
||||
|
||||
FileTimes.write(filePath);
|
||||
FileTimes.read(filePath);
|
||||
|
||||
return `Content replaced in file: ${filePath}`;
|
||||
} catch (err: any) {
|
||||
throw new Error(`Failed to replace content: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function getFile(filePath: string) {}
|
||||
|
||||
@@ -40,6 +40,9 @@ export namespace Tool {
|
||||
output: result.output,
|
||||
};
|
||||
} catch (e: any) {
|
||||
log.error("error", {
|
||||
msg: e.toString(),
|
||||
});
|
||||
return "An error occurred: " + e.toString();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,6 +2,8 @@ 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;
|
||||
@@ -117,8 +119,11 @@ export const ViewTool = Tool.define({
|
||||
}
|
||||
output += "\n</file>";
|
||||
|
||||
await LSP.run((client) => client.notify.open({ path: filePath }));
|
||||
FileTimes.read(filePath);
|
||||
|
||||
return {
|
||||
output: output,
|
||||
output,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -143,4 +148,3 @@ function isImageFile(filePath: string): string | false {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ export namespace Log {
|
||||
};
|
||||
|
||||
export function file(directory: string) {
|
||||
return;
|
||||
const out = Bun.file(
|
||||
path.join(AppPath.data(directory), "opencode.out.log"),
|
||||
).writer();
|
||||
|
||||
5
js/src/util/scrap.ts
Normal file
5
js/src/util/scrap.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const foo: string = "42";
|
||||
|
||||
export function dummyFunction(): void {
|
||||
console.log("This is a dummy function");
|
||||
}
|
||||
Reference in New Issue
Block a user