Initial commit: omp-lsp DSH plugin (oh-my-pi port) with docs and tests
This commit is contained in:
commit
17b4a5f433
11 files changed
+1091
No files matched your search
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* @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)
|
||||
Reference in new issue
Block a user