/** * @local/omp-lsp — oh-my-pi style language-server integration for DSH. * * - Diagnostics writethrough: after every successful `edit` / `write` (DSH built-ins or * @local/omp-edit), the touched files are synced to their language server and fresh * errors/warnings are appended to the tool result the model sees. * - `lsp` tool: diagnostics, definition, type_definition, implementation, references, hover, * symbols, rename (applied through DSH's fs), code_actions, status, reload, capabilities, * raw request. * * Servers (typescript-language-server, pyright, gopls, rust-analyzer, clangd, bash, json, yaml, * css, html) are auto-detected on PATH, ~/.local/bin and the project's node_modules/.bin; one * client per (server, project root), idle clients are stopped after `idleMinutes`. */ import fs from 'node:fs' import path from 'node:path' import { ServerPool } from './lib/servers.js' import { fromUri, toUri } from './lib/client.js' import { applyTextEdits, columnOf, formatDiagnostics, formatHover, formatLocations, formatSymbols, normalizeWorkspaceEdit, rel, } from './lib/format.js' export const name = 'omp-lsp' export const inject = ['tools', 'fs', 'systemPrompt'] const ACTIONS = ['diagnostics', 'definition', 'type_definition', 'implementation', 'references', 'hover', 'symbols', 'rename', 'rename_file', 'code_actions', 'status', 'reload', 'capabilities', 'request'] const WRITE_TOOLS = new Set(['edit', 'write']) export function apply(ctx, config = {}) { const cfg = { writethrough: true, writethroughTools: ['edit', 'write'], minSeverity: 2, // 1 error, 2 warning, 3 info, 4 hint budgetMs: 4000, maxDiagnostics: 25, idleMinutes: 10, servers: {}, disabled: [], ...config, } const writeTools = new Set(cfg.writethroughTools ?? [...WRITE_TOOLS]) const pool = new ServerPool({ servers: cfg.servers, disabled: cfg.disabled, idleMs: cfg.idleMinutes * 60_000, log: (m) => console.warn(`[omp-lsp] ${m}`), }) ctx.effect(() => () => { pool.stopAll() }, 'omp-lsp: language servers') const cwdOf = (exec) => exec?.agent?.session?.header?.cwd const absPath = async (p, exec) => { const t = await ctx.fs.resolve(String(p).replace(/^\[(.+?)#[0-9A-Fa-f]{4}\]$/, '$1'), { ...(cwdOf(exec) ? { cwd: cwdOf(exec) } : {}), signal: exec?.signal }) return { target: t, file: ctx.fs.processPath(t) } } // ---------------------------------------------------------------- writethrough diagnostics /** Files a write/edit call touched, from its arguments and (omp-edit) result value. */ function touchedFiles(exec, result) { const a = isObj(exec.arguments) ? exec.arguments : {} const files = new Set() if (typeof a.file_path === 'string') files.add(a.file_path) if (typeof a.path === 'string') files.add(a.path) const v = result.value if (isObj(v)) { if (typeof v.path === 'string') files.add(v.path) for (const r of Array.isArray(v.results) ? v.results : []) { if (r?.operation === 'delete') continue if (typeof r?.movedTo === 'string') files.add(r.movedTo) else if (typeof r?.path === 'string') files.add(r.path) } } // omp-edit text payloads: [PATH#TAG] headers / *** Edit File: / *** Update File: if (typeof a.input === 'string' && !(isObj(v) && Array.isArray(v.results))) { for (const m of a.input.matchAll(/^\[(.+?)#[0-9A-Fa-f]{4}\]\s*$/gm)) files.add(m[1]) for (const m of a.input.matchAll(/^\*\*\* (?:Edit|Update|Add) File:\s*(.+?)(?:\s+all)?\s*$/gm)) files.add(m[1]) } return [...files] } async function diagnose(files, exec) { const sections = [] const totals = { error: 0, warning: 0 } await Promise.all(files.slice(0, 8).map(async (f) => { try { const { file } = await absPath(f, exec) if (!fs.existsSync(file)) return const client = await pool.clientFor(file, { cwd: cwdOf(exec) }) if (!client) return const { items, fresh } = await client.diagnosticsFor(file, { budgetMs: cfg.budgetMs }) const fmt = formatDiagnostics(file, items, { cwd: cwdOf(exec), minSeverity: cfg.minSeverity, max: cfg.maxDiagnostics }) totals.error += fmt.counts.error; totals.warning += fmt.counts.warning if (fmt.text) sections.push(fmt.text) else if (!fresh) sections.push(`${rel(file, cwdOf(exec))}: ${client.id} did not report diagnostics within ${cfg.budgetMs}ms.`) } catch (e) { sections.push(`${f}: diagnostics unavailable (${e?.message ?? e})`) } })) return { sections, totals } } if (cfg.writethrough) { ctx.on('tools/post-execute', async (exec, result, next) => { const decision = await next() if (decision.kind !== 'accept' || result.isError || !writeTools.has(exec.name) || exec.parent !== undefined) return decision const files = touchedFiles(exec, result) if (!files.length) return decision const { sections, totals } = await diagnose(files, exec) if (!sections.length) return decision const head = totals.error || totals.warning ? `LSP diagnostics after this ${exec.name} (${totals.error} error${totals.error === 1 ? '' : 's'}, ${totals.warning} warning${totals.warning === 1 ? '' : 's'}):` : `LSP diagnostics after this ${exec.name}:` const content = [...(decision.content ?? result.content ?? []), { type: 'text', text: `\n${head}\n${sections.join('\n')}` }] return { ...decision, content } }) } // ---------------------------------------------------------------- lsp tool async function positionArgs(args, exec) { if (!args.file) throw new Error('file is required for this action') const { file } = await absPath(args.file, exec) if (!fs.existsSync(file)) throw new Error(`${args.file}: not found`) const client = await pool.clientFor(file, { cwd: cwdOf(exec) }) if (!client) throw new Error(`No language server available for ${path.extname(file) || args.file}. Run lsp action=status to see installed servers.`) const { uri } = client.sync(file) const line = Math.max(1, Math.floor(args.line ?? 1)) - 1 const lineStr = fs.readFileSync(file, 'utf8').split(/\r?\n/)[line] ?? '' const character = columnOf(lineStr, args.symbol) if (character < 0) throw new Error(`symbol ${JSON.stringify(args.symbol)} not found on line ${line + 1}: ${lineStr.trim()}`) return { client, file, uri, position: { line, character } } } async function applyWorkspaceEdit(we, exec) { const { files, ops } = normalizeWorkspaceEdit(we) const changed = [] for (const [file, edits] of files) { const { target } = await absPath(file, exec) const before = await ctx.fs.readText(target, exec?.signal) const after = applyTextEdits(before, edits) if (after === before) continue const outcome = await ctx.fs.writeText(target, after, undefined, exec?.signal, sandboxPolicyFor(exec)) ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec) changed.push(`${rel(file, cwdOf(exec))} (${edits.length} edit${edits.length === 1 ? '' : 's'})`) } for (const op of ops) { if (op.kind === 'rename') { const from = fromUri(op.oldUri); const to = fromUri(op.newUri) await fs.promises.mkdir(path.dirname(to), { recursive: true }) await fs.promises.rename(from, to) changed.push(`renamed ${rel(from, cwdOf(exec))} → ${rel(to, cwdOf(exec))}`) } else if (op.kind === 'create') { await fs.promises.writeFile(fromUri(op.uri), '', { flag: 'wx' }).catch(() => {}) changed.push(`created ${rel(fromUri(op.uri), cwdOf(exec))}`) } else if (op.kind === 'delete') { await fs.promises.rm(fromUri(op.uri), { recursive: !!op.options?.recursive, force: true }) changed.push(`deleted ${rel(fromUri(op.uri), cwdOf(exec))}`) } } return changed } const sandboxPolicyFor = (exec) => { try { return ctx.get('sandboxPolicy')?.resolve({ ...(exec?.agent ? { session: exec.agent.session } : {}) }) } catch { return undefined } } function describeEdit(we, exec) { const { files, ops } = normalizeWorkspaceEdit(we) const out = [] for (const [file, edits] of files) out.push(`${rel(file, cwdOf(exec))}: ${edits.length} edit${edits.length === 1 ? '' : 's'}`) for (const op of ops) out.push(`${op.kind} ${fromUri(op.oldUri ?? op.uri)}${op.newUri ? ` → ${fromUri(op.newUri)}` : ''}`) return out.join('\n') || '(no changes)' } async function runLsp(args, exec) { const action = args.action const timeout = Math.max(5, Math.min(300, args.timeout ?? 20)) * 1000 const cwd = cwdOf(exec) switch (action) { case 'status': { const s = pool.status() return [ 'Servers:', ...s.servers.map((x) => ` ${x.installed ? '✓' : '✗'} ${x.id} (${x.command}) ${x.extensions.join(' ')}`), s.running.length ? 'Running:' : 'Running: none', ...s.running.map((r) => ` ${r.id} ${r.state} root=${r.root} open=${r.openDocuments}`), ...(s.broken.length ? ['Failed:', ...s.broken.map((b) => ` ${b.server}: ${b.error}`)] : []), ].join('\n') } case 'reload': { await pool.stopAll() pool.reaper = setInterval(() => pool.reap(), 60_000); pool.reaper.unref?.() pool.broken.clear() return 'All language servers stopped; they restart on next use.' } case 'diagnostics': { if (!args.file) throw new Error('file is required (path or glob)') const files = await expand(args.file, cwd) if (!files.length) return `No files match ${args.file}.` const out = []; let errors = 0; let warnings = 0 for (const f of files.slice(0, 50)) { const client = await pool.clientFor(f, { cwd }) if (!client) continue const { items } = await client.diagnosticsFor(f, { budgetMs: Math.min(timeout, 15000) }) const fmt = formatDiagnostics(f, items, { cwd, minSeverity: 4, max: 100 }) errors += fmt.counts.error; warnings += fmt.counts.warning if (fmt.text) out.push(fmt.text) } return out.length ? `${errors} error(s), ${warnings} warning(s)\n${out.join('\n')}` : `No diagnostics (${files.length} file${files.length === 1 ? '' : 's'} checked).` } case 'definition': case 'type_definition': case 'implementation': case 'references': { const { client, uri, position } = await positionArgs(args, exec) const method = { definition: 'textDocument/definition', type_definition: 'textDocument/typeDefinition', implementation: 'textDocument/implementation', references: 'textDocument/references' }[action] const params = { textDocument: { uri }, position, ...(action === 'references' ? { context: { includeDeclaration: true } } : {}) } return formatLocations(await client.request(method, params, timeout), { cwd }) } case 'hover': { const { client, uri, position } = await positionArgs(args, exec) return formatHover(await client.request('textDocument/hover', { textDocument: { uri }, position }, timeout)) } case 'symbols': { if (args.file && args.file !== '*') { const { file } = await absPath(args.file, exec) const client = await pool.clientFor(file, { cwd }) if (!client) throw new Error(`No language server for ${args.file}`) const { uri } = client.sync(file) return formatSymbols(await client.request('textDocument/documentSymbol', { textDocument: { uri } }, timeout), { cwd }) } if (!args.query) throw new Error('symbols needs file, or file="*" with query for a workspace search') const clients = [...pool.clients.values()].filter((c) => c.state === 'ready') if (!clients.length) throw new Error('No running language server; open a file first (e.g. lsp action=symbols file=).') const res = (await Promise.all(clients.map((c) => c.request('workspace/symbol', { query: args.query }, timeout).catch(() => [])))).flat() return formatSymbols(res, { cwd }) } case 'rename': { if (!args.new_name) throw new Error('new_name is required') const { client, uri, position } = await positionArgs(args, exec) const we = await client.request('textDocument/rename', { textDocument: { uri }, position, newName: args.new_name }, timeout) if (!we) return 'Rename not possible at this position.' if (args.apply === false) return `Preview (not applied):\n${describeEdit(we, exec)}` const changed = await applyWorkspaceEdit(we, exec) for (const c of pool.clients.values()) for (const u of c.docs.keys()) c.sync(fromUri(u)) return `Renamed to ${args.new_name}:\n${changed.join('\n') || '(no changes)'}` } case 'rename_file': { if (!args.file || !args.new_name) throw new Error('file and new_name are required') const { file } = await absPath(args.file, exec) const dest = path.resolve(path.dirname(file), args.new_name) const client = await pool.clientFor(file, { cwd }) let we = null if (client?.capabilities?.workspace?.fileOperations?.willRename) { we = await client.request('workspace/willRenameFiles', { files: [{ oldUri: toUri(file), newUri: toUri(dest) }] }, timeout).catch(() => null) } if (args.apply === false) return `Preview: ${rel(file, cwd)} → ${rel(dest, cwd)}${we ? `\n${describeEdit(we, exec)}` : ''}` const changed = we ? await applyWorkspaceEdit(we, exec) : [] await fs.promises.mkdir(path.dirname(dest), { recursive: true }) await fs.promises.rename(file, dest) client?.notify('workspace/didRenameFiles', { files: [{ oldUri: toUri(file), newUri: toUri(dest) }] }) return `Moved ${rel(file, cwd)} → ${rel(dest, cwd)}${changed.length ? `\nUpdated references:\n${changed.join('\n')}` : ''}` } case 'code_actions': { const { client, uri, position } = await positionArgs(args, exec) const diags = (client.diagnostics.get(uri)?.items || []).filter((d) => d.range.start.line <= position.line && d.range.end.line >= position.line) const actions = (await client.request('textDocument/codeAction', { textDocument: { uri }, range: { start: { line: position.line, character: 0 }, end: { line: position.line + 1, character: 0 } }, context: { diagnostics: diags } }, timeout)) || [] const filtered = args.query ? actions.filter((a) => (a.title || '').toLowerCase().includes(args.query.toLowerCase()) || (a.kind || '').startsWith(args.query)) : actions if (args.apply !== true) return filtered.length ? filtered.map((a, i) => `${i + 1}. ${a.title}${a.kind ? ` [${a.kind}]` : ''}`).join('\n') : 'No code actions here.' const pickAction = filtered[0] if (!pickAction) return 'No matching code action to apply.' let act = pickAction if (!act.edit && act.data !== undefined && client.capabilities?.codeActionProvider?.resolveProvider) act = await client.request('codeAction/resolve', act, timeout) if (act.edit) { const changed = await applyWorkspaceEdit(act.edit, exec) return `Applied "${act.title}":\n${changed.join('\n')}` } if (act.command) { await client.request('workspace/executeCommand', act.command.command ? act.command : act, timeout); return `Executed "${act.title}".` } return `"${act.title}" has no edit to apply.` } case 'capabilities': { if (!args.file) throw new Error('file is required') const { file } = await absPath(args.file, exec) const client = await pool.clientFor(file, { cwd }) if (!client) throw new Error(`No language server for ${args.file}`) return JSON.stringify(client.capabilities, null, 1).slice(0, 8000) } case 'request': { if (!args.file || !args.query) throw new Error('request needs file (to pick the server) and query (LSP method)') const { client, uri, position } = await positionArgs(args, exec) const params = args.payload ? JSON.parse(args.payload) : { textDocument: { uri }, position } return JSON.stringify(await client.request(args.query, params, timeout), null, 1).slice(0, 20000) } default: throw new Error(`unknown action ${action}`) } } async function expand(spec, cwd) { const base = cwd || process.cwd() if (!/[*?[{]/.test(spec)) return [path.resolve(base, spec)].filter((f) => fs.existsSync(f)) const out = [] const glob = fs.promises.glob ? fs.promises.glob(spec, { cwd: base, exclude: (p) => /(^|\/)(node_modules|\.git|dist|build)(\/|$)/.test(p) }) : null if (glob) for await (const f of glob) { out.push(path.resolve(base, f)); if (out.length >= 200) break } return out } ctx.tools.register({ name: 'lsp', description: 'Query language servers (auto-detected per file type): diagnostics (file path or glob), definition, type_definition, implementation, references, hover (file + 1-based line + symbol text to locate the column; `name#N` picks the Nth occurrence), symbols (file, or file="*" + query for workspace search), rename (new_name; applies through the workspace unless apply=false), rename_file (moves a file and updates imports when supported), code_actions (lists; apply=true applies the first match of query), status, reload, capabilities, request (raw LSP method in query, JSON params in payload). Errors and warnings are also appended automatically after edit/write.', parameters: { type: 'object', additionalProperties: false, required: ['action'], properties: { action: { type: 'string', enum: ACTIONS }, file: { type: 'string', description: 'File path; a glob for diagnostics; "*" for workspace symbols.' }, line: { type: 'integer', description: '1-based line for position-based actions (default 1).' }, symbol: { type: 'string', description: 'Text on that line to locate the column; `name#N` selects the Nth occurrence.' }, query: { type: 'string', description: 'Workspace-symbol query, code-action filter, or LSP method for action=request.' }, new_name: { type: 'string', description: 'New name for rename / new path for rename_file.' }, apply: { type: 'boolean', description: 'rename/rename_file: apply unless false. code_actions: apply only when true.' }, timeout: { type: 'integer', description: 'Seconds (5-300, default 20).' }, payload: { type: 'string', description: 'JSON params for action=request.' }, }, }, output: { schema: { type: 'object', additionalProperties: false, properties: { text: { type: 'string' } } }, render: (_a, v) => [{ type: 'text', text: v.text }] }, timeoutMs: 320_000, async execute(args, exec) { return { text: await runLsp(args ?? {}, exec) } }, }) ctx.systemPrompt.section({ name: 'omp-lsp', order: 1650, text: 'Language-server diagnostics are appended to edit/write results; fix reported errors you introduced. Use the `lsp` tool for definitions, references, hover, renames and workspace diagnostics instead of grepping when a server is available.', }) } const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)