From 3ed7a05370041fe916345d56dc1bd9c33f7b29f3 Mon Sep 17 00:00:00 2001 From: Shubham Naik Date: Wed, 7 Jan 2026 11:41:09 -0800 Subject: [PATCH] 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 Co-authored-by: Letta Co-authored-by: cpacker --- src/index.ts | 10 ++ src/lsp/client.ts | 260 ++++++++++++++++++++++++++++++ src/lsp/manager.ts | 257 +++++++++++++++++++++++++++++ src/lsp/servers/index.ts | 13 ++ src/lsp/servers/python.ts | 64 ++++++++ src/lsp/servers/typescript.ts | 68 ++++++++ src/lsp/types.ts | 161 ++++++++++++++++++ src/tests/lsp.test.ts | 85 ++++++++++ src/tools/descriptions/ReadLSP.md | 39 +++++ src/tools/impl/ReadLSP.ts | 100 ++++++++++++ src/tools/manager.ts | 17 ++ src/tools/schemas/ReadLSP.json | 24 +++ src/tools/toolDefinitions.ts | 9 ++ 13 files changed, 1107 insertions(+) create mode 100644 src/lsp/client.ts create mode 100644 src/lsp/manager.ts create mode 100644 src/lsp/servers/index.ts create mode 100644 src/lsp/servers/python.ts create mode 100644 src/lsp/servers/typescript.ts create mode 100644 src/lsp/types.ts create mode 100644 src/tests/lsp.test.ts create mode 100644 src/tools/descriptions/ReadLSP.md create mode 100644 src/tools/impl/ReadLSP.ts create mode 100644 src/tools/schemas/ReadLSP.json diff --git a/src/index.ts b/src/index.ts index 21eadc1..7abc221 100755 --- a/src/index.ts +++ b/src/index.ts @@ -300,6 +300,16 @@ async function main(): Promise { 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(); diff --git a/src/lsp/client.ts b/src/lsp/client.ts new file mode 100644 index 0000000..c3c7b9c --- /dev/null +++ b/src/lsp/client.ts @@ -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(method: string, params?: unknown): Promise { + 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 { + 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( + "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 { + if (this.initialized) { + await this.sendRequest("shutdown"); + this.sendNotification("exit"); + } + this.process.process.kill(); + } +} diff --git a/src/lsp/manager.ts b/src/lsp/manager.ts new file mode 100644 index 0000000..c90d76a --- /dev/null +++ b/src/lsp/manager.ts @@ -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(); + private diagnostics = new Map(); + private openDocuments = new Map(); + 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 { + // 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 { + 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 { + 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 = { + ".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 { + const promises: Promise[] = []; + 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(); diff --git a/src/lsp/servers/index.ts b/src/lsp/servers/index.ts new file mode 100644 index 0000000..537de4b --- /dev/null +++ b/src/lsp/servers/index.ts @@ -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]; diff --git a/src/lsp/servers/python.ts b/src/lsp/servers/python.ts new file mode 100644 index 0000000..e84aa61 --- /dev/null +++ b/src/lsp/servers/python.ts @@ -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 { + try { + const { execSync } = await import("node:child_process"); + execSync("pyright-langserver --version", { + stdio: "ignore", + }); + return true; + } catch { + return false; + } + }, + async install(): Promise { + 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}`)); + } + }); + }); + }, + }, +}; diff --git a/src/lsp/servers/typescript.ts b/src/lsp/servers/typescript.ts new file mode 100644 index 0000000..a150eeb --- /dev/null +++ b/src/lsp/servers/typescript.ts @@ -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 { + try { + const { execSync } = await import("node:child_process"); + execSync("typescript-language-server --version", { + stdio: "ignore", + }); + return true; + } catch { + return false; + } + }, + async install(): Promise { + 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}`)); + } + }); + }); + }, + }, +}; diff --git a/src/lsp/types.ts b/src/lsp/types.ts new file mode 100644 index 0000000..d49c76b --- /dev/null +++ b/src/lsp/types.ts @@ -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; +} + +/** + * LSP Server definition + */ +export interface LSPServerInfo { + id: string; + extensions: string[]; + command: string[]; + env?: Record; + initialization?: Record; + autoInstall?: { + check: () => Promise; + install: () => Promise; + }; +} + +/** + * 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; +} diff --git a/src/tests/lsp.test.ts b/src/tests/lsp.test.ts new file mode 100644 index 0000000..e60a893 --- /dev/null +++ b/src/tests/lsp.test.ts @@ -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 + } + } +}); diff --git a/src/tools/descriptions/ReadLSP.md b/src/tools/descriptions/ReadLSP.md new file mode 100644 index 0000000..bdac1ef --- /dev/null +++ b/src/tools/descriptions/ReadLSP.md @@ -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 + +ERROR [10:5] Cannot find name 'foo' +ERROR [15:3] Type 'string' is not assignable to type 'number' + +``` + +This helps you catch type errors before making edits. diff --git a/src/tools/impl/ReadLSP.ts b/src/tools/impl/ReadLSP.ts new file mode 100644 index 0000000..689ef7e --- /dev/null +++ b/src/tools/impl/ReadLSP.ts @@ -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 = { + 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 { + // 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\n${displayed.map(formatDiagnostic).join("\n")}${suffix}\n`, + }; + } + + // No errors - return as-is + return result; + } catch (_error) { + // If LSP fails, silently return the base result + return result; + } +} diff --git a/src/tools/manager.ts b/src/tools/manager.ts index 3c335b2..fb124cb 100644 --- a/src/tools/manager.ts +++ b/src/tools/manager.ts @@ -140,6 +140,7 @@ const TOOL_PERMISSIONS: Record = { 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 { ); } } + + // 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 { diff --git a/src/tools/schemas/ReadLSP.json b/src/tools/schemas/ReadLSP.json new file mode 100644 index 0000000..efa19af --- /dev/null +++ b/src/tools/schemas/ReadLSP.json @@ -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#" +} diff --git a/src/tools/toolDefinitions.ts b/src/tools/toolDefinitions.ts index c53fc05..11c2d2e 100644 --- a/src/tools/toolDefinitions.ts +++ b/src/tools/toolDefinitions.ts @@ -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(),