Files
omp-lsp/index.js
T

339 lines
19 KiB
JavaScript

/**
* @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=<path>).')
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)