thincoder 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/package.json +1 -1
- package/src/context.mjs +18 -0
- package/src/tools/codemode.mjs +178 -0
- package/src/tools/index.mjs +4 -2
- package/src/tools/lsp.mjs +317 -0
package/README.md
CHANGED
|
@@ -205,6 +205,11 @@ Code conventions: pure `.mjs`, no semicolons, no npm dependencies allowed (inclu
|
|
|
205
205
|
|
|
206
206
|
## Changelog
|
|
207
207
|
|
|
208
|
+
### 0.10.0 (2026-07)
|
|
209
|
+
- **LSP tool** — `lsp` tool provides code intelligence via Language Server Protocol: go-to-definition, find-references, hover info, document symbols, diagnostics. Zero-dependency JSON-RPC 2.0 over stdio client. Lazy-starts language servers on first call. Configurable via `lsp.servers` in config.json (defaults: `typescript-language-server` for JS/TS, `pyright-langserver` for Python).
|
|
210
|
+
- **Smart context: compaction checkpoint** — `compressIfNeeded` now auto-creates a git checkpoint before compaction. A checkpoint reference is injected after compaction so the model can reconstruct context from git diff + recent messages + task progress. Prevents information loss during long sessions.
|
|
211
|
+
- **CodeMode: sandboxed JS execution** — `execute` tool backed by `vm.Script.runInNewContext`. Compose multiple file operations (read/write/glob/grep/log) into a single script, reducing API round-trips and keeping intermediate results out of context. Sandbox strips all Node APIs, limits output to 50KB, enforces 30s timeout, and blocks private IPs in fetch. Script size capped at 50KB.
|
|
212
|
+
|
|
208
213
|
### 0.9.0 (2026-07)
|
|
209
214
|
- **Config JSON Schema** — `saveConfig` auto-injects `$schema` reference; `docs/schemas/config.schema.json` provides editor autocompletion/validation for all config fields including the new `hooks` section.
|
|
210
215
|
- **Lifecycle Hooks** — `PreToolUse` / `PostToolUse` / `PostToolUseFailure` / `Notification` events. User-defined shell commands in config, with per-tool regex matching, timeout control, and `block`/`allow`/`notify` actions. Implemented in `src/hooks.mjs`, integrated into tool dispatch.
|
package/package.json
CHANGED
package/src/context.mjs
CHANGED
|
@@ -178,7 +178,25 @@ export async function compressIfNeeded(agent, threshold, callbacks) {
|
|
|
178
178
|
onReasoning: callbacks?.onReasoning,
|
|
179
179
|
})
|
|
180
180
|
|
|
181
|
+
// Auto-checkpoint before compaction: snapshot current state so the model can
|
|
182
|
+
// reconstruct context from git diff + recent messages + task progress later.
|
|
183
|
+
let cpId = null
|
|
184
|
+
try {
|
|
185
|
+
const { createCheckpoint } = await import("../git/checkpoint.mjs")
|
|
186
|
+
const cp = await createCheckpoint(agent.cwd)
|
|
187
|
+
cpId = cp?.id
|
|
188
|
+
} catch { /* checkpoint might fail — compaction itself should not be blocked */ }
|
|
189
|
+
|
|
181
190
|
applyCompression(agent, split.headEnd, split.tailStart, COMPACTION_PREFIX + summary.content)
|
|
191
|
+
|
|
192
|
+
// Inject checkpoint reference after compaction so the model knows it can use /restore
|
|
193
|
+
if (cpId) {
|
|
194
|
+
agent.history.splice(split.headEnd, 0, {
|
|
195
|
+
role: "user",
|
|
196
|
+
content: `[System: context compacted. A checkpoint (id: ${cpId}) was auto-created before compaction. Use the checkpoint tool to review pre-compaction state if needed. File changes since then are tracked in git diff.]`,
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
|
|
182
200
|
return true
|
|
183
201
|
}
|
|
184
202
|
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tools/codemode.mjs — CodeMode: sandboxed JS execution tool
|
|
3
|
+
*
|
|
4
|
+
* Gives the model an `execute` tool backed by Node.js vm.Script.runInNewContext.
|
|
5
|
+
* Multiple tool calls can be composed into a single script, reducing API round-trips
|
|
6
|
+
* and keeping large intermediate results out of context.
|
|
7
|
+
*
|
|
8
|
+
* Sandbox API (all sync, no callbacks):
|
|
9
|
+
* readFile(path) — read a file relative to cwd, return string
|
|
10
|
+
* writeFile(path, c) — write content to a file (auto-creates parent dirs)
|
|
11
|
+
* glob(pattern) — return array of matching paths
|
|
12
|
+
* grep(pattern, file) — return array of matching lines
|
|
13
|
+
* log(...args) — append to output buffer
|
|
14
|
+
* fetch(url) — HTTP GET, return string (SSRF-protected)
|
|
15
|
+
*
|
|
16
|
+
* Not available: require, import, process, child_process, setTimeout, any Node API.
|
|
17
|
+
*
|
|
18
|
+
* Limits:
|
|
19
|
+
* timeout: 30s (configurable via timeoutMs param)
|
|
20
|
+
* maxOutput: 50000 bytes
|
|
21
|
+
* maxScriptSize: 50000 bytes
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { Script, createContext } from "node:vm"
|
|
25
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, statSync, readdirSync } from "node:fs"
|
|
26
|
+
import { join, dirname, relative, resolve } from "node:path"
|
|
27
|
+
import { globToRegex, normalizeEOL } from "./shared.mjs"
|
|
28
|
+
|
|
29
|
+
const MAX_OUTPUT = 50_000
|
|
30
|
+
const MAX_SCRIPT = 50_000
|
|
31
|
+
const DEFAULT_TIMEOUT = 30_000
|
|
32
|
+
|
|
33
|
+
/** SSRF-safe fetch: only http/https, private IP rejection, 10s timeout */
|
|
34
|
+
async function sandboxFetch(url) {
|
|
35
|
+
const parsed = new URL(url)
|
|
36
|
+
if (!["http:", "https:"].includes(parsed.protocol)) {
|
|
37
|
+
throw new Error(`CodeMode fetch: protocol not allowed: ${parsed.protocol}`)
|
|
38
|
+
}
|
|
39
|
+
// Block private/internal IPs
|
|
40
|
+
const hostname = parsed.hostname.toLowerCase()
|
|
41
|
+
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" ||
|
|
42
|
+
hostname.startsWith("192.168.") || hostname.startsWith("10.") ||
|
|
43
|
+
hostname.startsWith("172.16.") || hostname.startsWith("172.17.") ||
|
|
44
|
+
hostname.startsWith("172.18.") || hostname.startsWith("172.19.") ||
|
|
45
|
+
hostname.startsWith("172.20.") || hostname.startsWith("172.21.") ||
|
|
46
|
+
hostname.startsWith("172.22.") || hostname.startsWith("172.23.") ||
|
|
47
|
+
hostname.startsWith("172.24.") || hostname.startsWith("172.25.") ||
|
|
48
|
+
hostname.startsWith("172.26.") || hostname.startsWith("172.27.") ||
|
|
49
|
+
hostname.startsWith("172.28.") || hostname.startsWith("172.29.") ||
|
|
50
|
+
hostname.startsWith("172.30.") || hostname.startsWith("172.31.") ||
|
|
51
|
+
hostname === "0.0.0.0" || hostname.endsWith(".local")) {
|
|
52
|
+
throw new Error(`CodeMode fetch: private/internal host not allowed: ${hostname}`)
|
|
53
|
+
}
|
|
54
|
+
const ctrl = new AbortController()
|
|
55
|
+
const timer = setTimeout(() => ctrl.abort(), 10_000)
|
|
56
|
+
try {
|
|
57
|
+
const res = await fetch(url, { signal: ctrl.signal })
|
|
58
|
+
const text = await res.text()
|
|
59
|
+
return text.slice(0, 100_000)
|
|
60
|
+
} finally {
|
|
61
|
+
clearTimeout(timer)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const codeModeTool = {
|
|
66
|
+
name: "execute",
|
|
67
|
+
description:
|
|
68
|
+
"Execute sandboxed JavaScript code. Use this to compose multiple file operations into one call — " +
|
|
69
|
+
"read, write, glob, grep, and log results. No network or system access. Max 30s timeout, 50KB output.",
|
|
70
|
+
parameters: {
|
|
71
|
+
type: "object",
|
|
72
|
+
properties: {
|
|
73
|
+
code: {
|
|
74
|
+
type: "string",
|
|
75
|
+
description: "JavaScript code to execute in the sandbox. Use provided functions: readFile(path), writeFile(path, content), glob(pattern), grep(pattern, file), log(...args).",
|
|
76
|
+
},
|
|
77
|
+
timeoutMs: {
|
|
78
|
+
type: "integer",
|
|
79
|
+
description: `Timeout in milliseconds (default ${DEFAULT_TIMEOUT}, max 60000)`,
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
required: ["code"],
|
|
83
|
+
},
|
|
84
|
+
readonly: false,
|
|
85
|
+
|
|
86
|
+
async execute(args, ctx) {
|
|
87
|
+
const cwd = ctx.cwd
|
|
88
|
+
const code = args.code ?? ""
|
|
89
|
+
|
|
90
|
+
if (code.length > MAX_SCRIPT) {
|
|
91
|
+
return `Error: script too large (${code.length} > ${MAX_SCRIPT} bytes). Split into smaller scripts or use individual tools.`
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const output = []
|
|
95
|
+
const timeoutMs = Math.min(args.timeoutMs ?? DEFAULT_TIMEOUT, 60_000)
|
|
96
|
+
|
|
97
|
+
// File path guard: ensure paths are within cwd
|
|
98
|
+
function safePath(p) {
|
|
99
|
+
if (typeof p !== "string") throw new Error(`Path must be a string, got ${typeof p}`)
|
|
100
|
+
// Normalize and resolve
|
|
101
|
+
const abs = resolve(cwd, p)
|
|
102
|
+
// Check containment
|
|
103
|
+
const rel = relative(cwd, abs)
|
|
104
|
+
if (rel.startsWith("..") || (rel.includes("..") && process.platform === "win32")) {
|
|
105
|
+
throw new Error(`Path traversal denied: ${p}`)
|
|
106
|
+
}
|
|
107
|
+
return abs
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const sandbox = createContext({
|
|
111
|
+
readFile: (p) => {
|
|
112
|
+
const abs = safePath(p)
|
|
113
|
+
if (!existsSync(abs)) throw new Error(`File not found: ${p}`)
|
|
114
|
+
const st = statSync(abs)
|
|
115
|
+
if (st.size > 5_000_000) throw new Error(`File too large: ${p} (${Math.round(st.size / 1000000)}MB)`)
|
|
116
|
+
return normalizeEOL(readFileSync(abs, "utf8"))
|
|
117
|
+
},
|
|
118
|
+
writeFile: (p, content) => {
|
|
119
|
+
const abs = safePath(p)
|
|
120
|
+
mkdirSync(dirname(abs), { recursive: true })
|
|
121
|
+
writeFileSync(abs, String(content), "utf8")
|
|
122
|
+
},
|
|
123
|
+
glob: (pattern) => {
|
|
124
|
+
if (typeof pattern !== "string") throw new Error("glob pattern must be a string")
|
|
125
|
+
const regex = globToRegex(pattern)
|
|
126
|
+
const results = []
|
|
127
|
+
function walk(dir, rel) {
|
|
128
|
+
let entries
|
|
129
|
+
try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
|
|
130
|
+
for (const e of entries) {
|
|
131
|
+
if (e.name.startsWith(".") || e.name === "node_modules") continue
|
|
132
|
+
const relPath = rel ? `${rel}/${e.name}` : e.name
|
|
133
|
+
if (e.isDirectory()) { walk(join(dir, e.name), relPath) }
|
|
134
|
+
else if (regex.test(relPath)) results.push(relPath)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
walk(cwd, "")
|
|
138
|
+
return results.slice(0, 200)
|
|
139
|
+
},
|
|
140
|
+
grep: (pattern, file) => {
|
|
141
|
+
if (typeof pattern !== "string") throw new Error("grep pattern must be a string")
|
|
142
|
+
if (typeof file !== "string") throw new Error("grep file must be a string")
|
|
143
|
+
const abs = safePath(file)
|
|
144
|
+
if (!existsSync(abs)) throw new Error(`File not found: ${file}`)
|
|
145
|
+
const content = normalizeEOL(readFileSync(abs, "utf8"))
|
|
146
|
+
const regex = new RegExp(pattern)
|
|
147
|
+
const lines = content.split("\n")
|
|
148
|
+
const matches = []
|
|
149
|
+
for (let i = 0; i < lines.length; i++) {
|
|
150
|
+
if (regex.test(lines[i])) matches.push(`${i + 1}: ${lines[i].slice(0, 200)}`)
|
|
151
|
+
}
|
|
152
|
+
return matches.slice(0, 100)
|
|
153
|
+
},
|
|
154
|
+
log: (...args) => {
|
|
155
|
+
const line = args.map((a) => typeof a === "object" ? JSON.stringify(a) : String(a)).join(" ")
|
|
156
|
+
output.push(line)
|
|
157
|
+
if (output.join("\n").length > MAX_OUTPUT) {
|
|
158
|
+
output.push("... (output truncated)")
|
|
159
|
+
throw new Error("CodeMode output limit exceeded")
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
fetch: sandboxFetch,
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
const script = new Script(code, {
|
|
167
|
+
filename: "codemode.js",
|
|
168
|
+
timeout: timeoutMs,
|
|
169
|
+
})
|
|
170
|
+
script.runInContext(sandbox)
|
|
171
|
+
return output.join("\n") || "(no output)"
|
|
172
|
+
} catch (err) {
|
|
173
|
+
const out = output.join("\n")
|
|
174
|
+
const prefix = out ? `${out}\n\n` : ""
|
|
175
|
+
return `${prefix}Error: ${err.message}`
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
}
|
package/src/tools/index.mjs
CHANGED
|
@@ -8,13 +8,15 @@ import { websearchTool, fetchTool } from "./web.mjs";
|
|
|
8
8
|
import { gitDiffTool, gitStatusTool, gitLogTool, questionTool, checkpointTool } from "./git.mjs";
|
|
9
9
|
import { checklistTool } from "./checklist.mjs";
|
|
10
10
|
import { linterTool } from "./linter.mjs";
|
|
11
|
+
import { lspTool } from "./lsp.mjs";
|
|
12
|
+
import { codeModeTool } from "./codemode.mjs";
|
|
11
13
|
|
|
12
14
|
export const builtinTools = [
|
|
13
15
|
readTool, writeTool, editTool, insertAfterTool, hashlineEditTool, applyPatchTool,
|
|
14
16
|
syntaxCheckTool, readImageTool, bashTool, globTool, grepTool,
|
|
15
17
|
websearchTool, lsTool, fetchTool, deleteTool,
|
|
16
18
|
gitDiffTool, gitStatusTool, gitLogTool, questionTool, checkpointTool,
|
|
17
|
-
checklistTool, linterTool,
|
|
19
|
+
checklistTool, linterTool, lspTool, codeModeTool,
|
|
18
20
|
];
|
|
19
21
|
|
|
20
22
|
export {
|
|
@@ -22,5 +24,5 @@ export {
|
|
|
22
24
|
syntaxCheckTool, readImageTool, bashTool, globTool, grepTool,
|
|
23
25
|
websearchTool, lsTool, fetchTool, deleteTool,
|
|
24
26
|
gitDiffTool, gitStatusTool, gitLogTool, questionTool, checkpointTool,
|
|
25
|
-
checklistTool, linterTool,
|
|
27
|
+
checklistTool, linterTool, lspTool, codeModeTool,
|
|
26
28
|
};
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tools/lsp.mjs — LSP (Language Server Protocol) code intelligence tool
|
|
3
|
+
* Zero-dependency JSON-RPC 2.0 over stdio client.
|
|
4
|
+
*
|
|
5
|
+
* Provides: go-to-definition, find-references, hover info, document symbols, diagnostics.
|
|
6
|
+
* Lazy-starts language servers on first call. Configurable via config.json lsp.servers.
|
|
7
|
+
*
|
|
8
|
+
* Config format:
|
|
9
|
+
* "lsp": {
|
|
10
|
+
* "servers": {
|
|
11
|
+
* "typescript": { "command": "typescript-language-server", "args": ["--stdio"] },
|
|
12
|
+
* "python": { "command": "pyright-langserver", "args": ["--stdio"] }
|
|
13
|
+
* }
|
|
14
|
+
* }
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { spawn } from "node:child_process"
|
|
18
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
19
|
+
import { join, extname } from "node:path"
|
|
20
|
+
|
|
21
|
+
// ---- JSON-RPC transport over stdio ----
|
|
22
|
+
|
|
23
|
+
/** Send a JSON-RPC request to the server via stdin */
|
|
24
|
+
function send(proc, message) {
|
|
25
|
+
const body = JSON.stringify(message)
|
|
26
|
+
const header = `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n`
|
|
27
|
+
proc.stdin.write(header + body)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Read one JSON-RPC message from stdout. Returns parsed JSON, or null on EOF. */
|
|
31
|
+
function readMessage(proc) {
|
|
32
|
+
return new Promise((resolve) => {
|
|
33
|
+
let header = ""
|
|
34
|
+
let contentLength = -1
|
|
35
|
+
|
|
36
|
+
const onData = (chunk) => {
|
|
37
|
+
if (contentLength < 0) {
|
|
38
|
+
header += chunk.toString()
|
|
39
|
+
const match = header.match(/Content-Length: (\d+)\r\n\r\n/)
|
|
40
|
+
if (match) {
|
|
41
|
+
contentLength = parseInt(match[1])
|
|
42
|
+
const bodyStart = header.indexOf("\r\n\r\n") + 4
|
|
43
|
+
const remaining = header.slice(bodyStart)
|
|
44
|
+
header = ""
|
|
45
|
+
if (remaining.length >= contentLength) {
|
|
46
|
+
proc.stdout.removeListener("data", onData)
|
|
47
|
+
try { resolve(JSON.parse(remaining.slice(0, contentLength))) } catch { resolve(null) }
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
// Need more data — leave remaining in a buffer-like state
|
|
51
|
+
proc.stdout.removeListener("data", onData)
|
|
52
|
+
readBody(proc, remaining, contentLength).then(resolve)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
proc.stdout.on("data", onData)
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function readBody(proc, buf, targetLen) {
|
|
62
|
+
return new Promise((resolve) => {
|
|
63
|
+
const onData = (chunk) => {
|
|
64
|
+
buf += chunk.toString()
|
|
65
|
+
if (buf.length >= targetLen) {
|
|
66
|
+
proc.stdout.removeListener("data", onData)
|
|
67
|
+
try { resolve(JSON.parse(buf.slice(0, targetLen))) } catch { resolve(null) }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
proc.stdout.on("data", onData)
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Send a request and wait for the matching response */
|
|
75
|
+
async function request(proc, method, params, id) {
|
|
76
|
+
send(proc, { jsonrpc: "2.0", id, method, params })
|
|
77
|
+
while (true) {
|
|
78
|
+
const msg = await readMessage(proc)
|
|
79
|
+
if (!msg) return null
|
|
80
|
+
if (msg.id === id) return msg
|
|
81
|
+
// Store notifications for later retrieval (diagnostics)
|
|
82
|
+
if (msg.method === "textDocument/publishDiagnostics") {
|
|
83
|
+
proc._diagnostics = proc._diagnostics || {}
|
|
84
|
+
proc._diagnostics[msg.params.uri] = msg.params.diagnostics
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Send a notification (no response expected) */
|
|
90
|
+
function notify(proc, method, params) {
|
|
91
|
+
send(proc, { jsonrpc: "2.0", method, params })
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---- Language server process management ----
|
|
95
|
+
|
|
96
|
+
const servers = new Map() // ext → { proc, rootUri, ready }
|
|
97
|
+
|
|
98
|
+
/** Convert file path to file:// URI */
|
|
99
|
+
function toUri(absPath) {
|
|
100
|
+
return "file:///" + absPath.replace(/\\/g, "/").replace(/^([A-Z]):/, (_, d) => d.toLowerCase() + "%3A")
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Resolve which language server to use for a file extension */
|
|
104
|
+
function resolveServerConfig(config, ext) {
|
|
105
|
+
const map = {
|
|
106
|
+
".js": "typescript", ".mjs": "typescript", ".cjs": "typescript",
|
|
107
|
+
".ts": "typescript", ".tsx": "typescript", ".mts": "typescript", ".cts": "typescript",
|
|
108
|
+
".py": "python", ".pyi": "python",
|
|
109
|
+
".rs": "rust",
|
|
110
|
+
".go": "go",
|
|
111
|
+
}
|
|
112
|
+
const key = map[ext] || ext.slice(1)
|
|
113
|
+
const servers = config?.lsp?.servers ?? {}
|
|
114
|
+
return servers[key] || null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Start or reuse a language server for the given file */
|
|
118
|
+
async function getServer(cwd, filePath, config) {
|
|
119
|
+
const ext = extname(filePath).toLowerCase()
|
|
120
|
+
const srvConfig = resolveServerConfig(config, ext)
|
|
121
|
+
if (!srvConfig) return null
|
|
122
|
+
|
|
123
|
+
const abs = join(cwd, ...filePath.split("/"))
|
|
124
|
+
if (!existsSync(abs)) return null
|
|
125
|
+
|
|
126
|
+
const rootUri = toUri(cwd)
|
|
127
|
+
const key = `${ext}:${cwd}`
|
|
128
|
+
let entry = servers.get(key)
|
|
129
|
+
|
|
130
|
+
if (entry && entry.ready) return entry
|
|
131
|
+
|
|
132
|
+
// Start new server
|
|
133
|
+
const proc = spawn(srvConfig.command, srvConfig.args || [], {
|
|
134
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
135
|
+
windowsHide: true,
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
proc.stderr.on("data", () => {}) // suppress stderr noise
|
|
139
|
+
|
|
140
|
+
entry = { proc, rootUri, ready: false }
|
|
141
|
+
servers.set(key, entry)
|
|
142
|
+
|
|
143
|
+
// Initialize handshake
|
|
144
|
+
const initResult = await request(proc, "initialize", {
|
|
145
|
+
processId: null,
|
|
146
|
+
rootUri,
|
|
147
|
+
capabilities: {
|
|
148
|
+
textDocument: {
|
|
149
|
+
definition: { linkSupport: false },
|
|
150
|
+
references: {},
|
|
151
|
+
hover: { contentFormat: ["plaintext"] },
|
|
152
|
+
documentSymbol: { hierarchicalDocumentSymbolSupport: true },
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
workspace: {},
|
|
156
|
+
}, 1)
|
|
157
|
+
|
|
158
|
+
if (!initResult) {
|
|
159
|
+
proc.kill()
|
|
160
|
+
servers.delete(key)
|
|
161
|
+
return null
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
notify(proc, "initialized", {})
|
|
165
|
+
entry.ready = true
|
|
166
|
+
return entry
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Notify the server that a file is open (required before queries) */
|
|
170
|
+
async function ensureOpen(proc, uri, ext) {
|
|
171
|
+
const langMap = { ".js": "javascript", ".mjs": "javascript", ".cjs": "javascript", ".ts": "typescript", ".tsx": "typescript", ".mts": "typescript", ".cts": "typescript", ".py": "python", ".rs": "rust", ".go": "go" }
|
|
172
|
+
const languageId = langMap[ext] || ext.slice(1)
|
|
173
|
+
notify(proc, "textDocument/didOpen", {
|
|
174
|
+
textDocument: {
|
|
175
|
+
uri,
|
|
176
|
+
languageId,
|
|
177
|
+
version: 1,
|
|
178
|
+
text: readFileSync(uri.replace(/^file:\/\/\//, "").replace(/%3A/, ":").replace(/\//g, "\\"), "utf8"),
|
|
179
|
+
},
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---- Tool definition ----
|
|
184
|
+
|
|
185
|
+
export const lspTool = {
|
|
186
|
+
name: "lsp",
|
|
187
|
+
description:
|
|
188
|
+
"LSP code intelligence: go to definition, find references, hover info, document symbols, diagnostics. " +
|
|
189
|
+
"Use this to understand code structure without grep-guessing function locations or type shapes.",
|
|
190
|
+
parameters: {
|
|
191
|
+
type: "object",
|
|
192
|
+
properties: {
|
|
193
|
+
subcommand: {
|
|
194
|
+
type: "string",
|
|
195
|
+
enum: ["definition", "references", "hover", "symbols", "diagnostics"],
|
|
196
|
+
description: "LSP operation to perform",
|
|
197
|
+
},
|
|
198
|
+
uri: {
|
|
199
|
+
type: "string",
|
|
200
|
+
description: "Target file path (relative to project root)",
|
|
201
|
+
},
|
|
202
|
+
line: {
|
|
203
|
+
type: "integer",
|
|
204
|
+
description: "1-based line number (for definition/references/hover)",
|
|
205
|
+
},
|
|
206
|
+
character: {
|
|
207
|
+
type: "integer",
|
|
208
|
+
description: "1-based character offset (for definition/references/hover)",
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
required: ["subcommand", "uri"],
|
|
212
|
+
},
|
|
213
|
+
readonly: true,
|
|
214
|
+
|
|
215
|
+
async execute(args, ctx) {
|
|
216
|
+
const config = ctx.agent?.config ?? {}
|
|
217
|
+
const cwd = ctx.cwd
|
|
218
|
+
const filePath = args.uri
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
const entry = await getServer(cwd, filePath, config)
|
|
222
|
+
if (!entry) {
|
|
223
|
+
const ext = extname(filePath).toLowerCase()
|
|
224
|
+
return `No LSP server configured for "${ext}" files. Add one to config.json:\n"lsp": { "servers": { "${ext.slice(1)}": { "command": "...", "args": ["--stdio"] } } }`
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const { proc } = entry
|
|
228
|
+
const abs = join(cwd, ...filePath.split("/"))
|
|
229
|
+
const uri = toUri(abs)
|
|
230
|
+
const ext = extname(filePath).toLowerCase()
|
|
231
|
+
await ensureOpen(proc, uri, ext)
|
|
232
|
+
|
|
233
|
+
// Wait briefly for diagnostics to arrive
|
|
234
|
+
await new Promise((r) => setTimeout(r, 300))
|
|
235
|
+
|
|
236
|
+
switch (args.subcommand) {
|
|
237
|
+
case "definition": {
|
|
238
|
+
if (!args.line || !args.character) return "Error: line and character required for definition"
|
|
239
|
+
const res = await request(proc, "textDocument/definition", {
|
|
240
|
+
textDocument: { uri },
|
|
241
|
+
position: { line: args.line - 1, character: args.character - 1 },
|
|
242
|
+
}, 10)
|
|
243
|
+
if (!res?.result) return "No definition found."
|
|
244
|
+
const locs = Array.isArray(res.result) ? res.result : [res.result]
|
|
245
|
+
return locs.map((l) => {
|
|
246
|
+
const path = l.uri.replace(/^file:\/\/\//, "").replace(/%3A/, ":")
|
|
247
|
+
return `${path}:${l.range.start.line + 1}:${l.range.start.character + 1}`
|
|
248
|
+
}).join("\n")
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
case "references": {
|
|
252
|
+
if (!args.line || !args.character) return "Error: line and character required for references"
|
|
253
|
+
const res = await request(proc, "textDocument/references", {
|
|
254
|
+
textDocument: { uri },
|
|
255
|
+
position: { line: args.line - 1, character: args.character - 1 },
|
|
256
|
+
context: { includeDeclaration: false },
|
|
257
|
+
}, 10)
|
|
258
|
+
if (!res?.result?.length) return "No references found."
|
|
259
|
+
return res.result.slice(0, 50).map((l) => {
|
|
260
|
+
const path = l.uri.replace(/^file:\/\/\//, "").replace(/%3A/, ":")
|
|
261
|
+
return `${path}:${l.range.start.line + 1}:${l.range.start.character + 1}`
|
|
262
|
+
}).join("\n") + (res.result.length > 50 ? `\n... and ${res.result.length - 50} more` : "")
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
case "hover": {
|
|
266
|
+
if (!args.line || !args.character) return "Error: line and character required for hover"
|
|
267
|
+
const res = await request(proc, "textDocument/hover", {
|
|
268
|
+
textDocument: { uri },
|
|
269
|
+
position: { line: args.line - 1, character: args.character - 1 },
|
|
270
|
+
}, 10)
|
|
271
|
+
if (!res?.result?.contents) return "No hover info available."
|
|
272
|
+
const contents = res.result.contents
|
|
273
|
+
if (typeof contents === "string") return contents
|
|
274
|
+
if (Array.isArray(contents)) return contents.map((c) => typeof c === "string" ? c : c.value).join("\n")
|
|
275
|
+
if (contents.value) return contents.value
|
|
276
|
+
return JSON.stringify(contents)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
case "symbols": {
|
|
280
|
+
const res = await request(proc, "textDocument/documentSymbol", {
|
|
281
|
+
textDocument: { uri },
|
|
282
|
+
}, 10)
|
|
283
|
+
if (!res?.result?.length) return "No symbols found."
|
|
284
|
+
function render(nodes, depth) {
|
|
285
|
+
const lines = []
|
|
286
|
+
for (const n of nodes) {
|
|
287
|
+
const kind = n.kind != null ? ` [${symbolKind(n.kind)}]` : ""
|
|
288
|
+
lines.push(`${" ".repeat(depth)}${n.name}${kind} — L${n.range.start.line + 1}`)
|
|
289
|
+
if (n.children?.length) lines.push(...render(n.children, depth + 1))
|
|
290
|
+
}
|
|
291
|
+
return lines
|
|
292
|
+
}
|
|
293
|
+
return render(res.result, 0).join("\n")
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
case "diagnostics": {
|
|
297
|
+
const diags = proc._diagnostics?.[uri]
|
|
298
|
+
if (!diags?.length) return "No diagnostics."
|
|
299
|
+
return diags.slice(0, 30).map((d) => {
|
|
300
|
+
const sev = { 1: "ERROR", 2: "WARN", 3: "INFO", 4: "HINT" }[d.severity] || "?"
|
|
301
|
+
return `L${d.range.start.line + 1}: ${sev}: ${d.message}${d.code ? ` [${d.code}]` : ""}`
|
|
302
|
+
}).join("\n") + (diags.length > 30 ? `\n... and ${diags.length - 30} more` : "")
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
default:
|
|
306
|
+
return `Unknown subcommand: ${args.subcommand}`
|
|
307
|
+
}
|
|
308
|
+
} catch (err) {
|
|
309
|
+
return `LSP error: ${err.message}`
|
|
310
|
+
}
|
|
311
|
+
},
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function symbolKind(k) {
|
|
315
|
+
const kinds = { 1: "file", 2: "module", 3: "namespace", 4: "package", 5: "class", 6: "method", 7: "property", 8: "field", 9: "constructor", 10: "enum", 11: "interface", 12: "function", 13: "variable", 14: "constant", 15: "string", 16: "number", 17: "boolean", 18: "array", 19: "object", 20: "key", 21: "null", 22: "enumMember", 23: "struct", 24: "event", 25: "operator", 26: "typeParameter" }
|
|
316
|
+
return kinds[k] || `kind-${k}`
|
|
317
|
+
}
|