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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thincoder",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Thin coding agent - zero dependencies, no build step, Node.js native. Sharp code, zero bloat.",
5
5
  "keywords": [
6
6
  "ai",
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
+ }
@@ -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
+ }