chad-code 0.1.0__py3-none-any.whl
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.
- chad/__init__.py +7 -0
- chad/agent.py +1093 -0
- chad/base_engine.py +109 -0
- chad/bench.py +243 -0
- chad/cli.py +469 -0
- chad/compaction.py +108 -0
- chad/config.py +63 -0
- chad/diag.py +100 -0
- chad/engine.py +883 -0
- chad/guardrails.py +373 -0
- chad/ignore.py +18 -0
- chad/lsp.py +323 -0
- chad/mcp.py +719 -0
- chad/mcp_oauth.py +329 -0
- chad/openai_engine.py +252 -0
- chad/prompt.py +305 -0
- chad/render.py +462 -0
- chad/repomap.py +767 -0
- chad/session.py +281 -0
- chad/skills.py +407 -0
- chad/symbols.py +360 -0
- chad/syntaxgate.py +105 -0
- chad/toolcall_parse.py +115 -0
- chad/tools.py +962 -0
- chad/tui.py +921 -0
- chad/validate.py +361 -0
- chad_code-0.1.0.dist-info/METADATA +370 -0
- chad_code-0.1.0.dist-info/RECORD +32 -0
- chad_code-0.1.0.dist-info/WHEEL +5 -0
- chad_code-0.1.0.dist-info/entry_points.txt +4 -0
- chad_code-0.1.0.dist-info/licenses/LICENSE +21 -0
- chad_code-0.1.0.dist-info/top_level.txt +1 -0
chad/lsp.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Precise cross-file code intelligence via an in-process language server.
|
|
2
|
+
|
|
3
|
+
Phase 2 of the symbolic stack. Tree-sitter (`repomap.py`) gives fast, language-
|
|
4
|
+
agnostic STRUCTURE; this adds semantic PRECISION that name-matching cannot:
|
|
5
|
+
true go-to-definition resolution and cross-file find-all-references that follow
|
|
6
|
+
imports, respect scoping, and don't confuse a method with an unrelated function of
|
|
7
|
+
the same name.
|
|
8
|
+
|
|
9
|
+
We drive `solidlsp` (Serena's synchronous LSP core) directly — no MCP server, no
|
|
10
|
+
Serena agent. For Python it spawns pyright on a worker thread (≈0.3s warm; a
|
|
11
|
+
one-time pyright download on a cold machine). The server is started LAZILY on first
|
|
12
|
+
use (so sessions that never ask for references pay nothing), bound to one project
|
|
13
|
+
root, and reaped on rebind / process exit.
|
|
14
|
+
|
|
15
|
+
Everything degrades gracefully: if the language server can't start (no Node,
|
|
16
|
+
offline, unsupported language) callers fall back to the tree-sitter backend. The
|
|
17
|
+
LSP is a precision upgrade, never a hard dependency of the harness.
|
|
18
|
+
|
|
19
|
+
Operational guardrails (all measured on an 11k-file repo before they existed):
|
|
20
|
+
* every request carries a timeout (default 5s; one uncapped find-references ran 18s),
|
|
21
|
+
* C/C++ needs a compile database before clangd is started at all — without one
|
|
22
|
+
clangd returns clean *empty* results and "[no references]" would be a lie,
|
|
23
|
+
* a server whose process tree outgrows CHAD_LSP_MAX_RSS_MB is recycled after the
|
|
24
|
+
request (pyright hit 4 GB on hot symbols — on a machine wiring ~12 GB of model
|
|
25
|
+
weights that's the same Metal-OOM crash class the repo map fix removed), and
|
|
26
|
+
* decorative callers (disambiguation hints) use `references_decorative`, which
|
|
27
|
+
never *starts* a server except the proven-cheap Python backend.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import atexit
|
|
31
|
+
import os
|
|
32
|
+
import subprocess
|
|
33
|
+
import threading
|
|
34
|
+
from collections import defaultdict
|
|
35
|
+
|
|
36
|
+
from . import config
|
|
37
|
+
from .diag import log
|
|
38
|
+
|
|
39
|
+
# solidlsp pulls a fair bit of machinery; import lazily inside _server() so a normal
|
|
40
|
+
# session / cold start doesn't pay for it unless references are actually requested.
|
|
41
|
+
# It ships in the optional `lsp` extra (serena-agent) — absent, we log once and every
|
|
42
|
+
# caller degrades to the tree-sitter backend.
|
|
43
|
+
|
|
44
|
+
_MISSING_LOGGED = False
|
|
45
|
+
|
|
46
|
+
_IGNORED = ["__pycache__", ".git", ".venv", "venv", "node_modules", ".mypy_cache",
|
|
47
|
+
".pytest_cache", "dist", "build", ".cache", "models"]
|
|
48
|
+
|
|
49
|
+
# Extension -> solidlsp Language enum name. Python is the proven backend (pyright);
|
|
50
|
+
# the others light up automatically once their language server is available.
|
|
51
|
+
_LANG_BY_EXT = {
|
|
52
|
+
".py": "PYTHON", ".pyi": "PYTHON",
|
|
53
|
+
".ts": "TYPESCRIPT", ".tsx": "TYPESCRIPT", ".js": "TYPESCRIPT", ".jsx": "TYPESCRIPT",
|
|
54
|
+
".go": "GO", ".rs": "RUST", ".java": "JAVA", ".rb": "RUBY",
|
|
55
|
+
".cs": "CSHARP", ".php": "PHP", ".kt": "KOTLIN", ".cpp": "CPP", ".cc": "CPP",
|
|
56
|
+
".c": "CPP", ".h": "CPP", ".hpp": "CPP",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def lang_for(path: str):
|
|
61
|
+
return _LANG_BY_EXT.get(os.path.splitext(path)[1].lower())
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_REQUEST_TIMEOUT_S = config.env_float("CHAD_LSP_TIMEOUT", 5.0)
|
|
65
|
+
_MAX_RSS_MB = config.env_int("CHAD_LSP_MAX_RSS_MB", 1536)
|
|
66
|
+
|
|
67
|
+
# Languages worth a server *start* for a decorative annotation. Python's pyright is
|
|
68
|
+
# proven lazy and cheap (~0.9s spawn, flat across repo sizes); other servers can
|
|
69
|
+
# gradle-sync, download binaries, or chew a file for ~10s (measured: clangd), which
|
|
70
|
+
# is never worth a "used by …" hint.
|
|
71
|
+
_DECOR_SPAWN = frozenset({"PYTHON"})
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _has_compile_db(root: str) -> bool:
|
|
75
|
+
"""clangd is only precise with a compilation database; without one it silently
|
|
76
|
+
analyzes each file in isolation and returns clean empty cross-file results."""
|
|
77
|
+
return any(os.path.exists(os.path.join(root, p))
|
|
78
|
+
for p in ("compile_commands.json",
|
|
79
|
+
os.path.join("build", "compile_commands.json"),
|
|
80
|
+
".clangd"))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
_QUIET_HOOK_INSTALLED = False
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _install_quiet_shutdown_hook():
|
|
87
|
+
"""solidlsp sends the LSP shutdown request from a helper thread; when the server
|
|
88
|
+
is busy (typically mid-request after we timed one out) that request itself times
|
|
89
|
+
out and the default threading excepthook dumps a full traceback over the TUI.
|
|
90
|
+
Filter exactly that case — solidlsp still reaps the process — and pass every
|
|
91
|
+
other thread exception through untouched."""
|
|
92
|
+
global _QUIET_HOOK_INSTALLED
|
|
93
|
+
if _QUIET_HOOK_INSTALLED:
|
|
94
|
+
return
|
|
95
|
+
_QUIET_HOOK_INSTALLED = True
|
|
96
|
+
prev = threading.excepthook
|
|
97
|
+
|
|
98
|
+
def hook(args):
|
|
99
|
+
if (args.exc_type is TimeoutError
|
|
100
|
+
and "_send_shutdown" in getattr(args.thread, "name", "")):
|
|
101
|
+
log.info("lsp: shutdown request timed out (server busy); reaped anyway")
|
|
102
|
+
return
|
|
103
|
+
prev(args)
|
|
104
|
+
|
|
105
|
+
threading.excepthook = hook
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _tree_rss_mb(pid: int) -> int:
|
|
109
|
+
"""Resident set of `pid` plus all descendants, in MB (pyright-langserver forks
|
|
110
|
+
node children; the parent alone under-reports)."""
|
|
111
|
+
try:
|
|
112
|
+
out = subprocess.run(["ps", "-axo", "pid=,ppid=,rss="],
|
|
113
|
+
capture_output=True, text=True, timeout=5).stdout
|
|
114
|
+
except Exception: # noqa: BLE001 - a failed sample must never break a request
|
|
115
|
+
return 0
|
|
116
|
+
kids, rss = defaultdict(list), {}
|
|
117
|
+
for ln in out.splitlines():
|
|
118
|
+
try:
|
|
119
|
+
p, pp, r = ln.split()
|
|
120
|
+
kids[int(pp)].append(int(p))
|
|
121
|
+
rss[int(p)] = int(r)
|
|
122
|
+
except ValueError:
|
|
123
|
+
continue
|
|
124
|
+
total, stack, seen = 0, [pid], set()
|
|
125
|
+
while stack:
|
|
126
|
+
cur = stack.pop()
|
|
127
|
+
if cur in seen:
|
|
128
|
+
continue
|
|
129
|
+
seen.add(cur)
|
|
130
|
+
total += rss.get(cur, 0)
|
|
131
|
+
stack.extend(kids.get(cur, ()))
|
|
132
|
+
return total // 1024
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class LspService:
|
|
136
|
+
"""One language server per (root, language), started lazily, reaped on exit."""
|
|
137
|
+
|
|
138
|
+
def __init__(self, root: str = "."):
|
|
139
|
+
self.root = os.path.abspath(root)
|
|
140
|
+
self._servers = {} # lang -> SolidLanguageServer | None (None = tried & failed)
|
|
141
|
+
self._lock = threading.Lock()
|
|
142
|
+
|
|
143
|
+
def _server(self, lang: str):
|
|
144
|
+
"""Lazily create + start the language server for `lang`; None if unavailable.
|
|
145
|
+
A failed start is remembered (cached None) so we don't retry on every call."""
|
|
146
|
+
if lang in self._servers:
|
|
147
|
+
return self._servers[lang]
|
|
148
|
+
with self._lock:
|
|
149
|
+
if lang in self._servers:
|
|
150
|
+
return self._servers[lang]
|
|
151
|
+
if lang == "CPP" and not _has_compile_db(self.root):
|
|
152
|
+
# Without a compile database clangd "works" but sees each file in
|
|
153
|
+
# isolation: cross-file references come back a clean, WRONG empty.
|
|
154
|
+
# Refusing to start keeps callers on the tree-sitter fallback, which
|
|
155
|
+
# labels itself NAME-MATCH ONLY instead of lying with confidence.
|
|
156
|
+
log.info("lsp: no compile_commands.json under %s; not starting clangd "
|
|
157
|
+
"(tree-sitter fallback)", self.root)
|
|
158
|
+
self._servers[lang] = None
|
|
159
|
+
return None
|
|
160
|
+
srv = None
|
|
161
|
+
try:
|
|
162
|
+
from solidlsp import SolidLanguageServer
|
|
163
|
+
from solidlsp.ls_config import Language, LanguageServerConfig
|
|
164
|
+
from solidlsp.settings import SolidLSPSettings
|
|
165
|
+
except ImportError:
|
|
166
|
+
global _MISSING_LOGGED
|
|
167
|
+
if not _MISSING_LOGGED:
|
|
168
|
+
_MISSING_LOGGED = True
|
|
169
|
+
log.info("lsp: solidlsp not installed (install the 'lsp' extra for "
|
|
170
|
+
"precise refs/rename); using tree-sitter fallback")
|
|
171
|
+
self._servers[lang] = None
|
|
172
|
+
return None
|
|
173
|
+
try:
|
|
174
|
+
_install_quiet_shutdown_hook()
|
|
175
|
+
cfg = LanguageServerConfig(code_language=Language[lang],
|
|
176
|
+
ignored_paths=list(_IGNORED))
|
|
177
|
+
srv = SolidLanguageServer.create(cfg, self.root,
|
|
178
|
+
solidlsp_settings=SolidLSPSettings())
|
|
179
|
+
srv.start()
|
|
180
|
+
# A hung/slow request must degrade to the tree-sitter fallback, not
|
|
181
|
+
# stall the agent loop (measured 18s for one uncapped find-references).
|
|
182
|
+
try:
|
|
183
|
+
srv.set_request_timeout(_REQUEST_TIMEOUT_S)
|
|
184
|
+
except Exception: # noqa: BLE001 - older solidlsp without the knob
|
|
185
|
+
pass
|
|
186
|
+
except Exception:
|
|
187
|
+
srv = None
|
|
188
|
+
self._servers[lang] = srv
|
|
189
|
+
return srv
|
|
190
|
+
|
|
191
|
+
def _recycle_if_bloated(self, lang: str, srv):
|
|
192
|
+
"""Stop and forget a server whose process tree outgrew the RSS cap. The next
|
|
193
|
+
request simply starts a fresh one (pyright re-warms in ~2s) — unbounded
|
|
194
|
+
analysis memory next to wired model weights is how the process dies."""
|
|
195
|
+
pid = getattr(getattr(getattr(srv, "server", None), "process", None), "pid", None)
|
|
196
|
+
if not pid:
|
|
197
|
+
return
|
|
198
|
+
mb = _tree_rss_mb(pid)
|
|
199
|
+
if mb > _MAX_RSS_MB:
|
|
200
|
+
log.info("lsp: %s server tree at %d MB (cap %d); recycling", lang, mb, _MAX_RSS_MB)
|
|
201
|
+
try:
|
|
202
|
+
srv.stop()
|
|
203
|
+
except Exception: # noqa: BLE001 - reaping is best-effort
|
|
204
|
+
pass
|
|
205
|
+
with self._lock:
|
|
206
|
+
if self._servers.get(lang) is srv:
|
|
207
|
+
del self._servers[lang]
|
|
208
|
+
|
|
209
|
+
def stop(self):
|
|
210
|
+
for srv in self._servers.values():
|
|
211
|
+
if srv is not None:
|
|
212
|
+
try:
|
|
213
|
+
srv.stop()
|
|
214
|
+
except Exception:
|
|
215
|
+
pass
|
|
216
|
+
self._servers.clear()
|
|
217
|
+
|
|
218
|
+
def available(self, path: str) -> bool:
|
|
219
|
+
lang = lang_for(path)
|
|
220
|
+
return bool(lang) and self._server(lang) is not None
|
|
221
|
+
|
|
222
|
+
def references(self, rel_path: str, name_row: int, name_col: int, timeout=None):
|
|
223
|
+
"""Precise references to the symbol whose identifier sits at the 0-based
|
|
224
|
+
(name_row, name_col) in rel_path. Returns a list of (rel, line1, col1) or
|
|
225
|
+
None if the language server is unavailable / errored / timed out (caller
|
|
226
|
+
should fall back to tree-sitter). An empty list means "ran, found none".
|
|
227
|
+
`timeout` tightens the request deadline below the default for callers with
|
|
228
|
+
their own budget (the disambiguation pass); never loosens it."""
|
|
229
|
+
lang = lang_for(rel_path)
|
|
230
|
+
if not lang:
|
|
231
|
+
return None
|
|
232
|
+
srv = self._server(lang)
|
|
233
|
+
if srv is None:
|
|
234
|
+
return None
|
|
235
|
+
set_to = getattr(srv, "set_request_timeout", None)
|
|
236
|
+
if timeout is not None and set_to is not None:
|
|
237
|
+
try:
|
|
238
|
+
set_to(min(timeout, _REQUEST_TIMEOUT_S))
|
|
239
|
+
except Exception: # noqa: BLE001 - keep the default timeout
|
|
240
|
+
set_to = None
|
|
241
|
+
try:
|
|
242
|
+
locs = srv.request_references(rel_path, name_row, name_col)
|
|
243
|
+
except Exception:
|
|
244
|
+
return None
|
|
245
|
+
finally:
|
|
246
|
+
if timeout is not None and set_to is not None:
|
|
247
|
+
try:
|
|
248
|
+
set_to(_REQUEST_TIMEOUT_S)
|
|
249
|
+
except Exception: # noqa: BLE001
|
|
250
|
+
pass
|
|
251
|
+
self._recycle_if_bloated(lang, srv)
|
|
252
|
+
out = []
|
|
253
|
+
for loc in locs or []:
|
|
254
|
+
rel = loc.get("relativePath")
|
|
255
|
+
if not rel:
|
|
256
|
+
uri = loc.get("uri", "")
|
|
257
|
+
rel = self._rel(uri[7:]) if uri.startswith("file://") else uri
|
|
258
|
+
start = loc.get("range", {}).get("start", {})
|
|
259
|
+
out.append((rel, start.get("line", 0) + 1, start.get("character", 0) + 1))
|
|
260
|
+
return out
|
|
261
|
+
|
|
262
|
+
def references_decorative(self, rel_path: str, name_row: int, name_col: int,
|
|
263
|
+
timeout=None):
|
|
264
|
+
"""`references()` for decoration (the disambiguation "used by …" hints):
|
|
265
|
+
never pays a server *start* for it unless the language is the proven-cheap
|
|
266
|
+
Python backend — other languages are used only if already running. Measured
|
|
267
|
+
before this existed: one view_symbol('main') on a mixed repo spawned clangd
|
|
268
|
+
per C++ candidate and took 86s."""
|
|
269
|
+
lang = lang_for(rel_path)
|
|
270
|
+
if not lang:
|
|
271
|
+
return None
|
|
272
|
+
if lang not in _DECOR_SPAWN and self._servers.get(lang) is None:
|
|
273
|
+
return None # absent (never started) and tried-but-failed both refuse
|
|
274
|
+
return self.references(rel_path, name_row, name_col, timeout=timeout)
|
|
275
|
+
|
|
276
|
+
def definition(self, rel_path: str, row: int, col: int):
|
|
277
|
+
"""Precise go-to-definition for the symbol at 0-based (row, col). Returns a
|
|
278
|
+
list of (rel, line1) or None if unavailable."""
|
|
279
|
+
lang = lang_for(rel_path)
|
|
280
|
+
if not lang:
|
|
281
|
+
return None
|
|
282
|
+
srv = self._server(lang)
|
|
283
|
+
if srv is None:
|
|
284
|
+
return None
|
|
285
|
+
try:
|
|
286
|
+
locs = srv.request_definition(rel_path, row, col)
|
|
287
|
+
except Exception:
|
|
288
|
+
return None
|
|
289
|
+
finally:
|
|
290
|
+
self._recycle_if_bloated(lang, srv)
|
|
291
|
+
out = []
|
|
292
|
+
for loc in locs or []:
|
|
293
|
+
rel = loc.get("relativePath") or loc.get("uri", "")
|
|
294
|
+
start = loc.get("range", {}).get("start", {})
|
|
295
|
+
out.append((rel, start.get("line", 0) + 1))
|
|
296
|
+
return out
|
|
297
|
+
|
|
298
|
+
def _rel(self, p):
|
|
299
|
+
try:
|
|
300
|
+
return os.path.relpath(p, self.root)
|
|
301
|
+
except ValueError:
|
|
302
|
+
return p
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
_SERVICE = None
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def service() -> LspService:
|
|
309
|
+
"""Lazily-bound, cwd-rooted service. Rebinding to a new project root (e.g. each
|
|
310
|
+
eval task) stops the previous language server so pyright processes don't leak."""
|
|
311
|
+
global _SERVICE
|
|
312
|
+
cwd = os.path.abspath(os.getcwd())
|
|
313
|
+
if _SERVICE is None or _SERVICE.root != cwd:
|
|
314
|
+
if _SERVICE is not None:
|
|
315
|
+
_SERVICE.stop()
|
|
316
|
+
_SERVICE = LspService(cwd)
|
|
317
|
+
return _SERVICE
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@atexit.register
|
|
321
|
+
def _shutdown():
|
|
322
|
+
if _SERVICE is not None:
|
|
323
|
+
_SERVICE.stop()
|