feat: add LSP support (TypeScript and Python) (#474)
Co-authored-by: letta-code <248085862+letta-code@users.noreply.github.com> Co-authored-by: Charles Packer <cpacker@users.noreply.github.com> Co-authored-by: Letta <noreply@letta.com> Co-authored-by: cpacker <packercharles@gmail.com>
This commit is contained in:
10
src/index.ts
10
src/index.ts
@@ -300,6 +300,16 @@ async function main(): Promise<void> {
|
||||
await settingsManager.initialize();
|
||||
const settings = await settingsManager.getSettingsWithSecureTokens();
|
||||
|
||||
// Initialize LSP infrastructure for type checking
|
||||
if (process.env.LETTA_ENABLE_LSP) {
|
||||
try {
|
||||
const { lspManager } = await import("./lsp/manager.js");
|
||||
await lspManager.initialize(process.cwd());
|
||||
} catch (error) {
|
||||
console.error("[LSP] Failed to initialize:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize telemetry (enabled by default, opt-out via LETTA_CODE_TELEM=0)
|
||||
telemetry.init();
|
||||
|
||||
|
||||
260
src/lsp/client.ts
Normal file
260
src/lsp/client.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* LSP Client - Handles JSON-RPC communication with LSP servers
|
||||
*/
|
||||
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { Readable, Writable } from "node:stream";
|
||||
import type {
|
||||
Diagnostic,
|
||||
InitializeParams,
|
||||
InitializeResult,
|
||||
JsonRpcNotification,
|
||||
JsonRpcRequest,
|
||||
JsonRpcResponse,
|
||||
LSPServerProcess,
|
||||
} from "./types.js";
|
||||
|
||||
export interface LSPClientOptions {
|
||||
serverID: string;
|
||||
server: LSPServerProcess;
|
||||
rootUri: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* LSP Client that communicates with an LSP server via JSON-RPC over STDIO
|
||||
*/
|
||||
export class LSPClient extends EventEmitter {
|
||||
private serverID: string;
|
||||
private process: LSPServerProcess;
|
||||
private rootUri: string;
|
||||
private stdin: Writable;
|
||||
private stdout: Readable;
|
||||
private requestId = 0;
|
||||
private pendingRequests = new Map<
|
||||
number | string,
|
||||
{
|
||||
resolve: (result: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
>();
|
||||
private buffer = "";
|
||||
private initialized = false;
|
||||
|
||||
constructor(options: LSPClientOptions) {
|
||||
super();
|
||||
this.serverID = options.serverID;
|
||||
this.process = options.server;
|
||||
this.rootUri = options.rootUri;
|
||||
|
||||
if (!this.process.process.stdin || !this.process.process.stdout) {
|
||||
throw new Error("LSP server process must have stdin/stdout");
|
||||
}
|
||||
|
||||
this.stdin = this.process.process.stdin;
|
||||
this.stdout = this.process.process.stdout;
|
||||
|
||||
this.setupListeners();
|
||||
}
|
||||
|
||||
private setupListeners(): void {
|
||||
// Read from stdout and parse JSON-RPC messages
|
||||
this.stdout.on("data", (chunk: Buffer) => {
|
||||
this.buffer += chunk.toString();
|
||||
this.processBuffer();
|
||||
});
|
||||
|
||||
this.stdout.on("error", (err) => {
|
||||
this.emit("error", err);
|
||||
});
|
||||
|
||||
this.process.process.on("exit", (code) => {
|
||||
this.emit("exit", code);
|
||||
});
|
||||
}
|
||||
|
||||
private processBuffer(): void {
|
||||
while (true) {
|
||||
// Find Content-Length header
|
||||
const headerMatch = this.buffer.match(/Content-Length: (\d+)\r\n/);
|
||||
if (!headerMatch?.[1]) break;
|
||||
|
||||
const contentLength = Number.parseInt(headerMatch[1], 10);
|
||||
const headerEnd = this.buffer.indexOf("\r\n\r\n");
|
||||
if (headerEnd === -1) break;
|
||||
|
||||
const messageStart = headerEnd + 4;
|
||||
if (this.buffer.length < messageStart + contentLength) break;
|
||||
|
||||
// Extract message
|
||||
const messageText = this.buffer.substring(
|
||||
messageStart,
|
||||
messageStart + contentLength,
|
||||
);
|
||||
this.buffer = this.buffer.substring(messageStart + contentLength);
|
||||
|
||||
try {
|
||||
const message = JSON.parse(messageText);
|
||||
this.handleMessage(message);
|
||||
} catch (error) {
|
||||
this.emit("error", new Error(`Failed to parse LSP message: ${error}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage(message: JsonRpcResponse | JsonRpcNotification): void {
|
||||
// Handle responses to our requests
|
||||
if ("id" in message && message.id !== undefined) {
|
||||
const response = message as JsonRpcResponse;
|
||||
const pending = this.pendingRequests.get(response.id);
|
||||
if (pending) {
|
||||
this.pendingRequests.delete(response.id);
|
||||
if (response.error) {
|
||||
pending.reject(new Error(`LSP Error: ${response.error.message}`));
|
||||
} else {
|
||||
pending.resolve(response.result);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle notifications from server
|
||||
const notification = message as JsonRpcNotification;
|
||||
if (notification.method === "textDocument/publishDiagnostics") {
|
||||
const params = notification.params as {
|
||||
uri: string;
|
||||
diagnostics: Diagnostic[];
|
||||
};
|
||||
this.emit("diagnostics", params.uri, params.diagnostics);
|
||||
}
|
||||
|
||||
this.emit("notification", notification);
|
||||
}
|
||||
|
||||
private sendRequest<T>(method: string, params?: unknown): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = ++this.requestId;
|
||||
const request: JsonRpcRequest = {
|
||||
jsonrpc: "2.0",
|
||||
id,
|
||||
method,
|
||||
params,
|
||||
};
|
||||
|
||||
this.pendingRequests.set(id, {
|
||||
resolve: resolve as (result: unknown) => void,
|
||||
reject,
|
||||
});
|
||||
|
||||
this.sendMessage(request);
|
||||
});
|
||||
}
|
||||
|
||||
private sendNotification(method: string, params?: unknown): void {
|
||||
const notification: JsonRpcNotification = {
|
||||
jsonrpc: "2.0",
|
||||
method,
|
||||
params,
|
||||
};
|
||||
this.sendMessage(notification);
|
||||
}
|
||||
|
||||
private sendMessage(message: JsonRpcRequest | JsonRpcNotification): void {
|
||||
const content = JSON.stringify(message);
|
||||
const header = `Content-Length: ${content.length}\r\n\r\n`;
|
||||
this.stdin.write(header + content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the LSP server
|
||||
*/
|
||||
async initialize(): Promise<InitializeResult> {
|
||||
const params: InitializeParams = {
|
||||
processId: process.pid,
|
||||
rootUri: `file://${this.rootUri}`,
|
||||
capabilities: {
|
||||
textDocument: {
|
||||
publishDiagnostics: {
|
||||
relatedInformation: true,
|
||||
versionSupport: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
initializationOptions: this.process.initialization,
|
||||
};
|
||||
|
||||
const result = await this.sendRequest<InitializeResult>(
|
||||
"initialize",
|
||||
params,
|
||||
);
|
||||
|
||||
// Send initialized notification
|
||||
this.sendNotification("initialized", {});
|
||||
this.initialized = true;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify server that a document was opened
|
||||
*/
|
||||
didOpen(
|
||||
uri: string,
|
||||
languageId: string,
|
||||
version: number,
|
||||
text: string,
|
||||
): void {
|
||||
if (!this.initialized) return;
|
||||
|
||||
this.sendNotification("textDocument/didOpen", {
|
||||
textDocument: {
|
||||
uri,
|
||||
languageId,
|
||||
version,
|
||||
text,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify server that a document was changed
|
||||
*/
|
||||
didChange(uri: string, version: number, text: string): void {
|
||||
if (!this.initialized) return;
|
||||
|
||||
this.sendNotification("textDocument/didChange", {
|
||||
textDocument: {
|
||||
uri,
|
||||
version,
|
||||
},
|
||||
contentChanges: [
|
||||
{
|
||||
text,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify server that a document was closed
|
||||
*/
|
||||
didClose(uri: string): void {
|
||||
if (!this.initialized) return;
|
||||
|
||||
this.sendNotification("textDocument/didClose", {
|
||||
textDocument: {
|
||||
uri,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown the LSP server gracefully
|
||||
*/
|
||||
async shutdown(): Promise<void> {
|
||||
if (this.initialized) {
|
||||
await this.sendRequest("shutdown");
|
||||
this.sendNotification("exit");
|
||||
}
|
||||
this.process.process.kill();
|
||||
}
|
||||
}
|
||||
257
src/lsp/manager.ts
Normal file
257
src/lsp/manager.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* LSP Manager - Orchestrates multiple LSP servers and maintains diagnostics
|
||||
*/
|
||||
|
||||
import * as path from "node:path";
|
||||
import { LSPClient } from "./client.js";
|
||||
import type { Diagnostic, LSPServerInfo } from "./types.js";
|
||||
|
||||
interface ActiveServer {
|
||||
client: LSPClient;
|
||||
rootUri: string;
|
||||
extensions: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Global LSP Manager singleton
|
||||
* Manages LSP servers and aggregates diagnostics
|
||||
*/
|
||||
export class LSPManager {
|
||||
private static instance: LSPManager | null = null;
|
||||
private servers = new Map<string, ActiveServer>();
|
||||
private diagnostics = new Map<string, Diagnostic[]>();
|
||||
private openDocuments = new Map<string, { version: number; uri: string }>();
|
||||
private serverDefinitions: LSPServerInfo[] = [];
|
||||
private enabled = false;
|
||||
|
||||
private constructor() {
|
||||
// Private constructor for singleton
|
||||
}
|
||||
|
||||
static getInstance(): LSPManager {
|
||||
if (!LSPManager.instance) {
|
||||
LSPManager.instance = new LSPManager();
|
||||
}
|
||||
return LSPManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize LSP system for a project
|
||||
*/
|
||||
async initialize(projectRoot: string): Promise<void> {
|
||||
// Check if LSP is enabled
|
||||
if (!process.env.LETTA_ENABLE_LSP) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.enabled = true;
|
||||
|
||||
// Load server definitions
|
||||
const { SERVERS } = await import("./servers/index.js");
|
||||
this.serverDefinitions = SERVERS;
|
||||
|
||||
console.log(`[LSP] Initialized for project: ${projectRoot}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or start LSP server for a file
|
||||
*/
|
||||
private async getOrStartServer(filePath: string): Promise<LSPClient | null> {
|
||||
if (!this.enabled) return null;
|
||||
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
|
||||
// Find server definition for this file extension
|
||||
const serverDef = this.serverDefinitions.find((s) =>
|
||||
s.extensions.includes(ext),
|
||||
);
|
||||
|
||||
if (!serverDef) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if server is already running
|
||||
const existing = this.servers.get(serverDef.id);
|
||||
if (existing) {
|
||||
return existing.client;
|
||||
}
|
||||
|
||||
// Start new server
|
||||
try {
|
||||
const { spawn } = await import("node:child_process");
|
||||
const rootUri = process.cwd();
|
||||
|
||||
// Check if server binary is available
|
||||
if (serverDef.autoInstall) {
|
||||
const isAvailable = await serverDef.autoInstall.check();
|
||||
if (!isAvailable) {
|
||||
console.log(
|
||||
`[LSP] ${serverDef.id} not found, attempting auto-install...`,
|
||||
);
|
||||
await serverDef.autoInstall.install();
|
||||
}
|
||||
}
|
||||
|
||||
const command = serverDef.command[0];
|
||||
if (!command) {
|
||||
console.error(`[LSP] ${serverDef.id} has no command configured`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const proc = spawn(command, serverDef.command.slice(1), {
|
||||
cwd: rootUri,
|
||||
env: {
|
||||
...process.env,
|
||||
...serverDef.env,
|
||||
},
|
||||
});
|
||||
|
||||
const client = new LSPClient({
|
||||
serverID: serverDef.id,
|
||||
server: {
|
||||
process: proc,
|
||||
initialization: serverDef.initialization,
|
||||
},
|
||||
rootUri,
|
||||
});
|
||||
|
||||
// Listen for diagnostics
|
||||
client.on("diagnostics", (uri: string, diagnostics: Diagnostic[]) => {
|
||||
this.updateDiagnostics(uri, diagnostics);
|
||||
});
|
||||
|
||||
client.on("error", (error: Error) => {
|
||||
console.error(`[LSP] ${serverDef.id} error:`, error);
|
||||
});
|
||||
|
||||
client.on("exit", (code: number | null) => {
|
||||
console.log(`[LSP] ${serverDef.id} exited with code ${code}`);
|
||||
this.servers.delete(serverDef.id);
|
||||
});
|
||||
|
||||
// Initialize the server
|
||||
await client.initialize();
|
||||
|
||||
this.servers.set(serverDef.id, {
|
||||
client,
|
||||
rootUri,
|
||||
extensions: serverDef.extensions,
|
||||
});
|
||||
|
||||
console.log(`[LSP] Started ${serverDef.id}`);
|
||||
|
||||
return client;
|
||||
} catch (error) {
|
||||
console.error(`[LSP] Failed to start ${serverDef.id}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify LSP that a file was opened or touched
|
||||
*/
|
||||
async touchFile(filePath: string, changed: boolean): Promise<void> {
|
||||
if (!this.enabled) return;
|
||||
|
||||
const client = await this.getOrStartServer(filePath);
|
||||
if (!client) return;
|
||||
|
||||
const absolutePath = path.isAbsolute(filePath)
|
||||
? filePath
|
||||
: path.resolve(process.cwd(), filePath);
|
||||
const uri = `file://${absolutePath}`;
|
||||
|
||||
const existing = this.openDocuments.get(absolutePath);
|
||||
|
||||
if (!existing) {
|
||||
// Open document for the first time
|
||||
const { promises: fs } = await import("node:fs");
|
||||
const text = await fs.readFile(absolutePath, "utf-8");
|
||||
const languageId = this.getLanguageId(filePath);
|
||||
|
||||
client.didOpen(uri, languageId, 1, text);
|
||||
this.openDocuments.set(absolutePath, { version: 1, uri });
|
||||
} else if (changed) {
|
||||
// Document was changed
|
||||
const { promises: fs } = await import("node:fs");
|
||||
const text = await fs.readFile(absolutePath, "utf-8");
|
||||
const newVersion = existing.version + 1;
|
||||
|
||||
client.didChange(uri, newVersion, text);
|
||||
this.openDocuments.set(absolutePath, {
|
||||
version: newVersion,
|
||||
uri,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update diagnostics for a file
|
||||
*/
|
||||
private updateDiagnostics(uri: string, diagnostics: Diagnostic[]): void {
|
||||
// Convert file:// URI to absolute path
|
||||
const filePath = uri.replace("file://", "");
|
||||
this.diagnostics.set(filePath, diagnostics);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get diagnostics for a specific file
|
||||
*/
|
||||
getDiagnostics(filePath?: string): Diagnostic[] {
|
||||
if (!this.enabled) return [];
|
||||
|
||||
if (filePath) {
|
||||
const absolutePath = path.isAbsolute(filePath)
|
||||
? filePath
|
||||
: path.resolve(process.cwd(), filePath);
|
||||
return this.diagnostics.get(absolutePath) || [];
|
||||
}
|
||||
|
||||
// Return all diagnostics
|
||||
const all: Diagnostic[] = [];
|
||||
for (const diagnostics of this.diagnostics.values()) {
|
||||
all.push(...diagnostics);
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get language ID for a file
|
||||
*/
|
||||
private getLanguageId(filePath: string): string {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const languageMap: Record<string, string> = {
|
||||
".ts": "typescript",
|
||||
".tsx": "typescriptreact",
|
||||
".js": "javascript",
|
||||
".jsx": "javascriptreact",
|
||||
".py": "python",
|
||||
".pyi": "python",
|
||||
".go": "go",
|
||||
".rs": "rust",
|
||||
".java": "java",
|
||||
".c": "c",
|
||||
".cpp": "cpp",
|
||||
".h": "c",
|
||||
".hpp": "cpp",
|
||||
};
|
||||
return languageMap[ext] || "plaintext";
|
||||
}
|
||||
|
||||
/**
|
||||
* Shutdown all LSP servers
|
||||
*/
|
||||
async shutdown(): Promise<void> {
|
||||
const promises: Promise<void>[] = [];
|
||||
for (const server of this.servers.values()) {
|
||||
promises.push(server.client.shutdown());
|
||||
}
|
||||
await Promise.all(promises);
|
||||
this.servers.clear();
|
||||
this.diagnostics.clear();
|
||||
this.openDocuments.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const lspManager = LSPManager.getInstance();
|
||||
13
src/lsp/servers/index.ts
Normal file
13
src/lsp/servers/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* LSP Server Registry
|
||||
*/
|
||||
|
||||
import type { LSPServerInfo } from "../types.js";
|
||||
import { PythonServer } from "./python.js";
|
||||
import { TypeScriptServer } from "./typescript.js";
|
||||
|
||||
/**
|
||||
* All available LSP servers
|
||||
* Add new servers here as they are implemented
|
||||
*/
|
||||
export const SERVERS: LSPServerInfo[] = [TypeScriptServer, PythonServer];
|
||||
64
src/lsp/servers/python.ts
Normal file
64
src/lsp/servers/python.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Python LSP Server Definition
|
||||
* Uses Pyright language server
|
||||
*/
|
||||
|
||||
import type { LSPServerInfo } from "../types.js";
|
||||
|
||||
/**
|
||||
* Python Language Server (Pyright)
|
||||
* High-performance static type checker for Python
|
||||
*/
|
||||
export const PythonServer: LSPServerInfo = {
|
||||
id: "python",
|
||||
extensions: [".py", ".pyi"],
|
||||
command: ["pyright-langserver", "--stdio"],
|
||||
initialization: {
|
||||
python: {
|
||||
analysis: {
|
||||
typeCheckingMode: "basic", // basic, standard, or strict
|
||||
autoSearchPaths: true,
|
||||
useLibraryCodeForTypes: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
autoInstall: {
|
||||
async check(): Promise<boolean> {
|
||||
try {
|
||||
const { execSync } = await import("node:child_process");
|
||||
execSync("pyright-langserver --version", {
|
||||
stdio: "ignore",
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
async install(): Promise<void> {
|
||||
if (process.env.LETTA_DISABLE_LSP_DOWNLOAD) {
|
||||
throw new Error(
|
||||
"LSP auto-download is disabled. Please install pyright manually: npm install -g pyright",
|
||||
);
|
||||
}
|
||||
|
||||
console.log("[LSP] Installing pyright...");
|
||||
|
||||
const { spawn } = await import("node:child_process");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn("npm", ["install", "-g", "pyright"], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
proc.on("exit", (code) => {
|
||||
if (code === 0) {
|
||||
console.log("[LSP] Successfully installed pyright");
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`npm install failed with code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
68
src/lsp/servers/typescript.ts
Normal file
68
src/lsp/servers/typescript.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* TypeScript/JavaScript LSP Server Definition
|
||||
*/
|
||||
|
||||
import type { LSPServerInfo } from "../types.js";
|
||||
|
||||
/**
|
||||
* TypeScript Language Server
|
||||
* Uses typescript-language-server + typescript
|
||||
*/
|
||||
export const TypeScriptServer: LSPServerInfo = {
|
||||
id: "typescript",
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"],
|
||||
command: ["typescript-language-server", "--stdio"],
|
||||
initialization: {
|
||||
preferences: {
|
||||
includeInlayParameterNameHints: "all",
|
||||
includeInlayFunctionParameterTypeHints: true,
|
||||
},
|
||||
},
|
||||
autoInstall: {
|
||||
async check(): Promise<boolean> {
|
||||
try {
|
||||
const { execSync } = await import("node:child_process");
|
||||
execSync("typescript-language-server --version", {
|
||||
stdio: "ignore",
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
async install(): Promise<void> {
|
||||
if (process.env.LETTA_DISABLE_LSP_DOWNLOAD) {
|
||||
throw new Error(
|
||||
"LSP auto-download is disabled. Please install typescript-language-server manually: npm install -g typescript-language-server typescript",
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
"[LSP] Installing typescript-language-server and typescript...",
|
||||
);
|
||||
|
||||
const { spawn } = await import("node:child_process");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(
|
||||
"npm",
|
||||
["install", "-g", "typescript-language-server", "typescript"],
|
||||
{
|
||||
stdio: "inherit",
|
||||
},
|
||||
);
|
||||
|
||||
proc.on("exit", (code) => {
|
||||
if (code === 0) {
|
||||
console.log(
|
||||
"[LSP] Successfully installed typescript-language-server",
|
||||
);
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`npm install failed with code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
161
src/lsp/types.ts
Normal file
161
src/lsp/types.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* LSP Infrastructure Types
|
||||
* Based on Language Server Protocol specification
|
||||
*/
|
||||
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
|
||||
/**
|
||||
* LSP Diagnostic severity levels
|
||||
*/
|
||||
export enum DiagnosticSeverity {
|
||||
Error = 1,
|
||||
Warning = 2,
|
||||
Information = 3,
|
||||
Hint = 4,
|
||||
}
|
||||
|
||||
/**
|
||||
* Position in a text document (0-based)
|
||||
*/
|
||||
export interface Position {
|
||||
line: number;
|
||||
character: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Range in a text document
|
||||
*/
|
||||
export interface Range {
|
||||
start: Position;
|
||||
end: Position;
|
||||
}
|
||||
|
||||
/**
|
||||
* LSP Diagnostic
|
||||
*/
|
||||
export interface Diagnostic {
|
||||
range: Range;
|
||||
severity?: DiagnosticSeverity;
|
||||
code?: string | number;
|
||||
source?: string;
|
||||
message: string;
|
||||
relatedInformation?: DiagnosticRelatedInformation[];
|
||||
}
|
||||
|
||||
export interface DiagnosticRelatedInformation {
|
||||
location: Location;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface Location {
|
||||
uri: string;
|
||||
range: Range;
|
||||
}
|
||||
|
||||
/**
|
||||
* LSP Server process handle
|
||||
*/
|
||||
export interface LSPServerProcess {
|
||||
process: ChildProcess;
|
||||
initialization?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* LSP Server definition
|
||||
*/
|
||||
export interface LSPServerInfo {
|
||||
id: string;
|
||||
extensions: string[];
|
||||
command: string[];
|
||||
env?: Record<string, string>;
|
||||
initialization?: Record<string, unknown>;
|
||||
autoInstall?: {
|
||||
check: () => Promise<boolean>;
|
||||
install: () => Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Text document for LSP
|
||||
*/
|
||||
export interface TextDocument {
|
||||
uri: string;
|
||||
languageId: string;
|
||||
version: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON-RPC Request
|
||||
*/
|
||||
export interface JsonRpcRequest {
|
||||
jsonrpc: "2.0";
|
||||
id: number | string;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON-RPC Response
|
||||
*/
|
||||
export interface JsonRpcResponse {
|
||||
jsonrpc: "2.0";
|
||||
id: number | string;
|
||||
result?: unknown;
|
||||
error?: {
|
||||
code: number;
|
||||
message: string;
|
||||
data?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON-RPC Notification
|
||||
*/
|
||||
export interface JsonRpcNotification {
|
||||
jsonrpc: "2.0";
|
||||
method: string;
|
||||
params?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* LSP Initialize params
|
||||
*/
|
||||
export interface InitializeParams {
|
||||
processId: number | null;
|
||||
rootUri: string | null;
|
||||
capabilities: ClientCapabilities;
|
||||
initializationOptions?: unknown;
|
||||
}
|
||||
|
||||
export interface ClientCapabilities {
|
||||
textDocument?: {
|
||||
publishDiagnostics?: {
|
||||
relatedInformation?: boolean;
|
||||
tagSupport?: { valueSet: number[] };
|
||||
versionSupport?: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* LSP Initialize result
|
||||
*/
|
||||
export interface InitializeResult {
|
||||
capabilities: ServerCapabilities;
|
||||
serverInfo?: {
|
||||
name: string;
|
||||
version?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ServerCapabilities {
|
||||
textDocumentSync?: number | TextDocumentSyncOptions;
|
||||
// Add more as needed
|
||||
}
|
||||
|
||||
export interface TextDocumentSyncOptions {
|
||||
openClose?: boolean;
|
||||
change?: number;
|
||||
}
|
||||
85
src/tests/lsp.test.ts
Normal file
85
src/tests/lsp.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { afterAll, beforeAll, expect, test } from "bun:test";
|
||||
import { lspManager } from "../lsp/manager";
|
||||
|
||||
// Enable LSP for tests
|
||||
process.env.LETTA_ENABLE_LSP = "true";
|
||||
|
||||
beforeAll(async () => {
|
||||
// Initialize LSP for the project
|
||||
await lspManager.initialize(process.cwd());
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Cleanup LSP servers
|
||||
await lspManager.shutdown();
|
||||
});
|
||||
|
||||
test("LSP Manager: initializes successfully", () => {
|
||||
// Just verify it doesn't throw
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
test("LSP Manager: touchFile opens a TypeScript file", async () => {
|
||||
const filePath = "./src/lsp/types.ts";
|
||||
|
||||
// Touch the file (should open it in LSP)
|
||||
await lspManager.touchFile(filePath, false);
|
||||
|
||||
// Wait for LSP to process
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
// File should be opened (no error thrown)
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
test("LSP Manager: getDiagnostics returns empty for valid file", async () => {
|
||||
const filePath = "./src/lsp/types.ts";
|
||||
|
||||
// Touch the file
|
||||
await lspManager.touchFile(filePath, false);
|
||||
|
||||
// Wait for diagnostics
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
// Get diagnostics - should be empty for a valid file
|
||||
const diagnostics = lspManager.getDiagnostics(filePath);
|
||||
|
||||
// types.ts should have no errors
|
||||
expect(diagnostics.length).toBe(0);
|
||||
});
|
||||
|
||||
test("LSP Manager: handles file changes", async () => {
|
||||
const { promises: fs } = await import("node:fs");
|
||||
const testFile = "./test-lsp-file.ts";
|
||||
|
||||
try {
|
||||
// Create a valid file
|
||||
await fs.writeFile(testFile, "const x: number = 42;");
|
||||
|
||||
// Touch file
|
||||
await lspManager.touchFile(testFile, false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
const diagnostics1 = lspManager.getDiagnostics(testFile);
|
||||
expect(diagnostics1.length).toBe(0);
|
||||
|
||||
// Modify file with an error
|
||||
await fs.writeFile(testFile, "const x: number = 'string';"); // Type error!
|
||||
|
||||
// Notify LSP of change
|
||||
await lspManager.touchFile(testFile, true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// LSP should process the change (diagnostics may or may not arrive depending on timing)
|
||||
// Just verify getDiagnostics doesn't crash
|
||||
const diagnostics2 = lspManager.getDiagnostics(testFile);
|
||||
expect(diagnostics2).toBeDefined();
|
||||
} finally {
|
||||
// Cleanup
|
||||
try {
|
||||
await fs.unlink(testFile);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
39
src/tools/descriptions/ReadLSP.md
Normal file
39
src/tools/descriptions/ReadLSP.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Read
|
||||
|
||||
Reads a file from the local filesystem. You can access any file directly by using this tool.
|
||||
Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.
|
||||
|
||||
Usage:
|
||||
- The file_path parameter must be an absolute path, not a relative path
|
||||
- By default, it reads up to 2000 lines starting from the beginning of the file
|
||||
- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters
|
||||
- Any lines longer than 2000 characters will be truncated
|
||||
- Results are returned using cat -n format, with line numbers starting at 1
|
||||
- This tool can only read files, not directories. To read a directory, use the ls command via Bash.
|
||||
- You can call multiple tools in a single response. It is always better to speculatively read multiple potentially useful files in parallel.
|
||||
- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.
|
||||
|
||||
## Diagnostics (TypeScript/JavaScript/Python)
|
||||
|
||||
When reading supported files, the Read tool automatically includes LSP diagnostics (type errors, syntax errors, etc.):
|
||||
|
||||
**Supported languages:**
|
||||
- TypeScript/JavaScript: `.ts`, `.tsx`, `.js`, `.jsx`, `.mjs`, `.cjs`
|
||||
- Python: `.py`, `.pyi`
|
||||
|
||||
**Behavior:**
|
||||
- **Auto-included** for files under 500 lines
|
||||
- **Manually controlled** via `include_types` parameter:
|
||||
- `include_types: true` - Force include diagnostics (even for large files)
|
||||
- `include_types: false` - Skip diagnostics (even for small files)
|
||||
|
||||
When errors are found, they appear at the end of the file content:
|
||||
```
|
||||
This file has errors, please fix
|
||||
<file_diagnostics>
|
||||
ERROR [10:5] Cannot find name 'foo'
|
||||
ERROR [15:3] Type 'string' is not assignable to type 'number'
|
||||
</file_diagnostics>
|
||||
```
|
||||
|
||||
This helps you catch type errors before making edits.
|
||||
100
src/tools/impl/ReadLSP.ts
Normal file
100
src/tools/impl/ReadLSP.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* LSP-enhanced Read tool - wraps the base Read tool and adds LSP diagnostics
|
||||
* This is used when LETTA_ENABLE_LSP is set
|
||||
*/
|
||||
import { read as baseRead } from "./Read.js";
|
||||
|
||||
// Format a single diagnostic in opencode style: "ERROR [line:col] message"
|
||||
function formatDiagnostic(diag: {
|
||||
severity?: number;
|
||||
range: { start: { line: number; character: number } };
|
||||
message: string;
|
||||
}): string {
|
||||
const severityMap: Record<number, string> = {
|
||||
1: "ERROR",
|
||||
2: "WARN",
|
||||
3: "INFO",
|
||||
4: "HINT",
|
||||
};
|
||||
const severity = severityMap[diag.severity || 1] || "ERROR";
|
||||
const line = diag.range.start.line + 1; // Convert to 1-based
|
||||
const col = diag.range.start.character + 1;
|
||||
return `${severity} [${line}:${col}] ${diag.message}`;
|
||||
}
|
||||
|
||||
interface ReadLSPArgs {
|
||||
file_path: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
include_types?: boolean;
|
||||
}
|
||||
|
||||
interface ReadLSPResult {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export async function read_lsp(args: ReadLSPArgs): Promise<ReadLSPResult> {
|
||||
// First, call the base read function
|
||||
const result = await baseRead(args);
|
||||
|
||||
// Skip LSP if not enabled (shouldn't happen since we only load this when enabled)
|
||||
if (!process.env.LETTA_ENABLE_LSP) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Determine if we should include diagnostics
|
||||
const lineCount = result.content.split("\n").length;
|
||||
const shouldInclude =
|
||||
args.include_types === true ||
|
||||
(args.include_types !== false && lineCount < 500);
|
||||
|
||||
if (!shouldInclude) {
|
||||
return result;
|
||||
}
|
||||
|
||||
try {
|
||||
// Import LSP manager dynamically
|
||||
const { lspManager } = await import("../../lsp/manager.js");
|
||||
const path = await import("node:path");
|
||||
|
||||
// Resolve the path
|
||||
const userCwd = process.env.USER_CWD || process.cwd();
|
||||
const resolvedPath = path.default.isAbsolute(args.file_path)
|
||||
? args.file_path
|
||||
: path.default.resolve(userCwd, args.file_path);
|
||||
|
||||
// Touch the file (opens it in LSP if not already open)
|
||||
await lspManager.touchFile(resolvedPath, false);
|
||||
|
||||
// Wait briefly for diagnostics (LSP servers are async)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// Get diagnostics
|
||||
const diagnostics = lspManager.getDiagnostics(resolvedPath);
|
||||
|
||||
if (diagnostics.length > 0) {
|
||||
// Only show errors (severity 1) like opencode does
|
||||
const errors = diagnostics.filter((d) => d.severity === 1);
|
||||
if (errors.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const maxDiagnostics = 10;
|
||||
const displayed = errors.slice(0, maxDiagnostics);
|
||||
const suffix =
|
||||
errors.length > maxDiagnostics
|
||||
? `\n... and ${errors.length - maxDiagnostics} more`
|
||||
: "";
|
||||
|
||||
return {
|
||||
content: `${result.content}\n\nThis file has errors, please fix\n<file_diagnostics>\n${displayed.map(formatDiagnostic).join("\n")}${suffix}\n</file_diagnostics>`,
|
||||
};
|
||||
}
|
||||
|
||||
// No errors - return as-is
|
||||
return result;
|
||||
} catch (_error) {
|
||||
// If LSP fails, silently return the base result
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -140,6 +140,7 @@ const TOOL_PERMISSIONS: Record<ToolName, { requiresApproval: boolean }> = {
|
||||
LS: { requiresApproval: false },
|
||||
MultiEdit: { requiresApproval: true },
|
||||
Read: { requiresApproval: false },
|
||||
ReadLSP: { requiresApproval: false },
|
||||
Skill: { requiresApproval: false },
|
||||
Task: { requiresApproval: true },
|
||||
TodoWrite: { requiresApproval: false },
|
||||
@@ -478,6 +479,22 @@ export async function loadTools(modelIdentifier?: string): Promise<void> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If LSP is enabled, swap Read with LSP-enhanced version
|
||||
if (process.env.LETTA_ENABLE_LSP && toolRegistry.has("Read")) {
|
||||
const lspDefinition = TOOL_DEFINITIONS.ReadLSP;
|
||||
if (lspDefinition) {
|
||||
// Replace Read with ReadLSP (but keep the name "Read" for the agent)
|
||||
toolRegistry.set("Read", {
|
||||
schema: {
|
||||
name: "Read", // Keep the tool name as "Read" for the agent
|
||||
description: lspDefinition.description,
|
||||
input_schema: lspDefinition.schema,
|
||||
},
|
||||
fn: lspDefinition.impl,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isOpenAIModel(modelIdentifier: string): boolean {
|
||||
|
||||
24
src/tools/schemas/ReadLSP.json
Normal file
24
src/tools/schemas/ReadLSP.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file_path": {
|
||||
"type": "string",
|
||||
"description": "The absolute path to the file to read"
|
||||
},
|
||||
"offset": {
|
||||
"type": "number",
|
||||
"description": "The line number to start reading from. Only provide if the file is too large to read at once"
|
||||
},
|
||||
"limit": {
|
||||
"type": "number",
|
||||
"description": "The number of lines to read. Only provide if the file is too large to read at once."
|
||||
},
|
||||
"include_types": {
|
||||
"type": "boolean",
|
||||
"description": "Optional: Include LSP diagnostics (type errors, syntax errors) for supported files (TypeScript, JavaScript, Python). Defaults to true for files under 500 lines."
|
||||
}
|
||||
},
|
||||
"required": ["file_path"],
|
||||
"additionalProperties": false,
|
||||
"$schema": "http://json-schema.org/draft-07/schema#"
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import MultiEditDescription from "./descriptions/MultiEdit.md";
|
||||
import ReadDescription from "./descriptions/Read.md";
|
||||
import ReadFileCodexDescription from "./descriptions/ReadFileCodex.md";
|
||||
import ReadFileGeminiDescription from "./descriptions/ReadFileGemini.md";
|
||||
import ReadLSPDescription from "./descriptions/ReadLSP.md";
|
||||
import ReadManyFilesGeminiDescription from "./descriptions/ReadManyFilesGemini.md";
|
||||
import ReplaceGeminiDescription from "./descriptions/ReplaceGemini.md";
|
||||
import RunShellCommandGeminiDescription from "./descriptions/RunShellCommandGemini.md";
|
||||
@@ -51,6 +52,7 @@ import { multi_edit } from "./impl/MultiEdit";
|
||||
import { read } from "./impl/Read";
|
||||
import { read_file } from "./impl/ReadFileCodex";
|
||||
import { read_file_gemini } from "./impl/ReadFileGemini";
|
||||
import { read_lsp } from "./impl/ReadLSP";
|
||||
import { read_many_files } from "./impl/ReadManyFilesGemini";
|
||||
import { replace } from "./impl/ReplaceGemini";
|
||||
import { run_shell_command } from "./impl/RunShellCommandGemini";
|
||||
@@ -84,6 +86,7 @@ import MultiEditSchema from "./schemas/MultiEdit.json";
|
||||
import ReadSchema from "./schemas/Read.json";
|
||||
import ReadFileCodexSchema from "./schemas/ReadFileCodex.json";
|
||||
import ReadFileGeminiSchema from "./schemas/ReadFileGemini.json";
|
||||
import ReadLSPSchema from "./schemas/ReadLSP.json";
|
||||
import ReadManyFilesGeminiSchema from "./schemas/ReadManyFilesGemini.json";
|
||||
import ReplaceGeminiSchema from "./schemas/ReplaceGemini.json";
|
||||
import RunShellCommandGeminiSchema from "./schemas/RunShellCommandGemini.json";
|
||||
@@ -167,6 +170,12 @@ const toolDefinitions = {
|
||||
description: ReadDescription.trim(),
|
||||
impl: read as unknown as ToolImplementation,
|
||||
},
|
||||
// LSP-enhanced Read - used when LETTA_ENABLE_LSP is set
|
||||
ReadLSP: {
|
||||
schema: ReadLSPSchema,
|
||||
description: ReadLSPDescription.trim(),
|
||||
impl: read_lsp as unknown as ToolImplementation,
|
||||
},
|
||||
Skill: {
|
||||
schema: SkillSchema,
|
||||
description: SkillDescription.trim(),
|
||||
|
||||
Reference in New Issue
Block a user