chad-code 1.0.6__tar.gz → 1.0.7__tar.gz
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_code-1.0.6 → chad_code-1.0.7}/PKG-INFO +2 -5
- {chad_code-1.0.6 → chad_code-1.0.7}/pyproject.toml +10 -12
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/__init__.py +1 -1
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/levers.py +11 -0
- chad_code-1.0.7/src/chad/lsp.py +340 -0
- chad_code-1.0.7/src/chad/lspclient.py +341 -0
- chad_code-1.0.7/src/chad/lspservers.py +157 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/mcp.py +81 -31
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/mcp_oauth.py +37 -16
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/repomap.py +242 -16
- chad_code-1.0.7/src/chad/symbols.py +162 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/tools.py +29 -4
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad_code.egg-info/PKG-INFO +2 -5
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad_code.egg-info/SOURCES.txt +5 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad_code.egg-info/requires.txt +1 -5
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_ignore.py +3 -3
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_lever_bite.py +23 -0
- chad_code-1.0.7/tests/test_lsp.py +315 -0
- chad_code-1.0.7/tests/test_lsp_live.py +108 -0
- chad_code-1.0.7/tests/test_lspclient.py +205 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_mcp.py +77 -7
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_mcp_oauth.py +3 -3
- chad_code-1.0.7/tests/test_repomap_polyglot.py +111 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_symbols.py +83 -52
- chad_code-1.0.6/src/chad/lsp.py +0 -323
- chad_code-1.0.6/src/chad/symbols.py +0 -372
- chad_code-1.0.6/tests/test_lsp.py +0 -115
- {chad_code-1.0.6 → chad_code-1.0.7}/LICENSE +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/README.md +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/setup.cfg +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/agent.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/atif.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/base_engine.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/bench.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/cli.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/compaction.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/completion_engine.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/config.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/diag.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/engine.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/guardrails.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/ignore.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/mlx_fastpath.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/mlx_qsdpa.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/parakeet/LICENSE +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/parakeet/__init__.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/parakeet/alignment.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/parakeet/attention.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/parakeet/audio.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/parakeet/cache.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/parakeet/conformer.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/parakeet/ctc.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/parakeet/parakeet.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/parakeet/rnnt.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/parakeet/tokenizer.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/parakeet/utils.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/profiles.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/prompt.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/prove.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/render.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/serve.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/session.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/skills.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/speech.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/syntaxgate.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/toolcall_parse.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/tui.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/validate.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad_code.egg-info/dependency_links.txt +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad_code.egg-info/entry_points.txt +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/src/chad_code.egg-info/top_level.txt +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_agent.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_agent_e2e.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_agent_guards.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_atif.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_bench.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_cli.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_compact_notice.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_compaction.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_completion_engine.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_config.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_confirm_preview.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_done_audit.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_drift_warn.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_edit.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_edit_corruption.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_engine.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_engine_kvquant.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_engine_pld_hybrid.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_feel_pack.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_garble_invariant.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_gate.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_intent.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_levers.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_log_redaction.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_mlx_fastpath.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_mlx_qsdpa.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_plan_review.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_prove.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_render.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_replace_lines.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_repomap.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_serve.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_session.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_skills.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_speech.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_speech_tui.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_subagent.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_syntaxgate.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_toolcall_parse.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_tools.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_tui.py +0 -0
- {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_validate.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: chad-code
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.7
|
|
4
4
|
Summary: Local MLX-backed, Claude-Code-style coding agent (Apple Silicon, Ornith 35B/9B)
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Repository, https://github.com/nathansutton/chad
|
|
@@ -23,16 +23,13 @@ Requires-Dist: transformers<5.13,>=5.0
|
|
|
23
23
|
Requires-Dist: jinja2>=3.1
|
|
24
24
|
Requires-Dist: numpy<3,>=1.26
|
|
25
25
|
Requires-Dist: huggingface-hub>=1.22.0
|
|
26
|
-
Requires-Dist: jedi>=0.19.2
|
|
27
26
|
Requires-Dist: prompt_toolkit>=3.0.43
|
|
28
27
|
Requires-Dist: tree-sitter>=0.26.0
|
|
29
28
|
Requires-Dist: tree-sitter-language-pack>=1.12.2
|
|
30
29
|
Requires-Dist: rustworkx>=0.15
|
|
31
|
-
Requires-Dist: mcp
|
|
30
|
+
Requires-Dist: mcp<3,>=2
|
|
32
31
|
Provides-Extra: highlight
|
|
33
32
|
Requires-Dist: pygments>=2.17; extra == "highlight"
|
|
34
|
-
Provides-Extra: lsp
|
|
35
|
-
Requires-Dist: serena-agent>=1.5; extra == "lsp"
|
|
36
33
|
Provides-Extra: speech
|
|
37
34
|
Requires-Dist: sounddevice>=0.5; extra == "speech"
|
|
38
35
|
Dynamic: license-file
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
# import name, and command name are independent. `uvx chad-code` runs the alias
|
|
5
5
|
# script added under [project.scripts].
|
|
6
6
|
name = "chad-code"
|
|
7
|
-
version = "1.0.
|
|
7
|
+
version = "1.0.7"
|
|
8
8
|
description = "Local MLX-backed, Claude-Code-style coding agent (Apple Silicon, Ornith 35B/9B)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -54,7 +54,6 @@ dependencies = [
|
|
|
54
54
|
# `chad prove` 4/4); staying open keeps 2.x-floored ASR deps adoptable.
|
|
55
55
|
"numpy>=1.26,<3",
|
|
56
56
|
"huggingface-hub>=1.22.0", # first-run model download (cli.py/engine.py); imported directly, not just transitively
|
|
57
|
-
"jedi>=0.19.2", # in-process symbolic code intelligence (read/search/edit)
|
|
58
57
|
"prompt_toolkit>=3.0.43",
|
|
59
58
|
"tree-sitter>=0.26.0",
|
|
60
59
|
"tree-sitter-language-pack>=1.12.2",
|
|
@@ -63,10 +62,14 @@ dependencies = [
|
|
|
63
62
|
# wheel, zero transitive deps. Benchmarked vs scipy/networkx/igraph on a real
|
|
64
63
|
# 421k-edge graph: identical top-50 ranking, 0.79s -> 0.04s.
|
|
65
64
|
"rustworkx>=0.15",
|
|
66
|
-
#
|
|
67
|
-
# chad
|
|
68
|
-
#
|
|
69
|
-
|
|
65
|
+
# Single 2.x code path (the 1.x cap left with serena-agent, which hard-pinned mcp
|
|
66
|
+
# and was the only thing keeping chad below 2). The 1->2 port was done deliberately,
|
|
67
|
+
# not by a resolver: two of the renames (isError->is_error, readOnlyHint->
|
|
68
|
+
# read_only_hint) FAIL SILENTLY through getattr defaults, so both are pinned by
|
|
69
|
+
# tests that would fail on the old attribute names (tests/test_mcp.py). 2.0 also
|
|
70
|
+
# negotiates protocol 2026-07-28; the 1.x line tops out at 2025-11-25. Capped <3:
|
|
71
|
+
# the same class of break will happen again.
|
|
72
|
+
"mcp>=2,<3",
|
|
70
73
|
]
|
|
71
74
|
|
|
72
75
|
# Optional syntax highlighting in diffs / confirm previews. Pure-Python,
|
|
@@ -76,11 +79,6 @@ dependencies = [
|
|
|
76
79
|
# package. Document uv/git installs only.
|
|
77
80
|
[project.optional-dependencies]
|
|
78
81
|
highlight = ["pygments>=2.17"]
|
|
79
|
-
# Precision code intelligence (docs/design.md "Precision" phase): solidlsp ships inside
|
|
80
|
-
# serena-agent and backs lsp.py's find-refs/rename via pyright. Optional on purpose —
|
|
81
|
-
# heavyweight tree; absent, every caller falls back to the tree-sitter backend.
|
|
82
|
-
# Install with `uv sync --extra lsp`.
|
|
83
|
-
lsp = ["serena-agent>=1.5"]
|
|
84
82
|
# Voice mode (/speech in the TUI): push-to-talk dictation via chad's vendored
|
|
85
83
|
# Parakeet-on-MLX (src/chad/parakeet — the PyPI parakeet-mlx drags librosa's
|
|
86
84
|
# numba/scipy tree for one mel-filterbank call) + spoken replies via macOS
|
|
@@ -153,7 +151,7 @@ ignore = ["E741"] # ambiguous var names (l/I) appear in existing math code
|
|
|
153
151
|
[tool.mypy]
|
|
154
152
|
python_version = "3.11"
|
|
155
153
|
files = ["src/chad"]
|
|
156
|
-
ignore_missing_imports = true # mlx/
|
|
154
|
+
ignore_missing_imports = true # mlx/tree_sitter/mcp ship partial or no stubs
|
|
157
155
|
warn_unused_ignores = true
|
|
158
156
|
implicit_optional = true # `x: T = None` defaults are idiomatic throughout; this
|
|
159
157
|
# restores the pre-PEP-484 reading rather than rewriting
|
|
@@ -4,7 +4,7 @@ A flat collection of cooperating modules behind one console script (``chad``):
|
|
|
4
4
|
the inference engine, the tool layer, the agent loop, and the terminal UI.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
__version__ = "1.0.
|
|
7
|
+
__version__ = "1.0.7"
|
|
8
8
|
|
|
9
9
|
# chad sets no MLX_* runtime vars. MLX_METAL_FAST_SYNCH, MLX_MAX_OPS_PER_BUFFER
|
|
10
10
|
# and MLX_MAX_MB_PER_BUFFER were each measured end-to-end on the 35B and every
|
|
@@ -446,6 +446,17 @@ LEVERS: dict[str, Lever] = {
|
|
|
446
446
|
"a flat file listing, so the model orients without a reflexive step-1 repo_map "
|
|
447
447
|
"call. Degrades to the flat listing when repomap is unavailable (see prompt.py).",
|
|
448
448
|
"ai-codex"),
|
|
449
|
+
|
|
450
|
+
# --- group "ctxengine": the uniform-symbols work. -------------------------------
|
|
451
|
+
"post_edit_diagnostics": Lever(
|
|
452
|
+
"Append the language server's post-edit typecheck errors (~50 tokens, errors "
|
|
453
|
+
"only, capped) to edit/symbol-edit results — the semantic tier above "
|
|
454
|
+
"syntaxgate's parse check. A type error surfaced in the edit result replaces "
|
|
455
|
+
"the multi-thousand-token failed test run the model would otherwise need to "
|
|
456
|
+
"discover it. Never spawns a server (an edit must not pay a cold start): "
|
|
457
|
+
"only a server already warmed by find_refs/hover/disambiguation is "
|
|
458
|
+
"consulted; absent one, silence.",
|
|
459
|
+
"ctxengine"),
|
|
449
460
|
}
|
|
450
461
|
|
|
451
462
|
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""Precise cross-file code intelligence via language servers chad drives itself.
|
|
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, cross-file find-all-references that follow imports and
|
|
6
|
+
respect scoping, hover types, and post-edit diagnostics.
|
|
7
|
+
|
|
8
|
+
The transport is chad's own `lspclient.py` (generic JSON-RPC, zero language
|
|
9
|
+
knowledge); which server speaks for which language is data in `lspservers.py`.
|
|
10
|
+
This module is POLICY: servers start LAZILY on first use (a session that never
|
|
11
|
+
asks for references pays nothing), are bound to one project root, degrade to the
|
|
12
|
+
tree-sitter fallback on any failure, and are reaped on rebind / process exit.
|
|
13
|
+
Precision ships in the base install — no extra, no vendored language server; a
|
|
14
|
+
missing server on PATH degrades per-language, never breaks the harness.
|
|
15
|
+
|
|
16
|
+
Operational guardrails (all measured on an 11k-file repo before they existed):
|
|
17
|
+
* every request carries a timeout (default 5s; one uncapped find-references ran 18s),
|
|
18
|
+
* C/C++ needs a compile database before clangd is started at all — without one
|
|
19
|
+
clangd returns clean *empty* results and "[no references]" would be a lie,
|
|
20
|
+
* a server whose process tree outgrows CHAD_LSP_MAX_RSS_MB is recycled after the
|
|
21
|
+
request (pyright hit 4 GB on hot symbols — on a machine wiring ~12 GB of model
|
|
22
|
+
weights that's the same Metal-OOM crash class the repo map fix removed), and
|
|
23
|
+
* decorative callers (disambiguation hints) use `references_decorative`, which
|
|
24
|
+
never *starts* a server unless the registry marks it `decorative_ok`
|
|
25
|
+
(measured: one view_symbol('main') spawned clangd per candidate: 86s).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import atexit
|
|
29
|
+
import os
|
|
30
|
+
import subprocess
|
|
31
|
+
import threading
|
|
32
|
+
from collections import defaultdict
|
|
33
|
+
|
|
34
|
+
from . import config, lspservers
|
|
35
|
+
from .diag import log
|
|
36
|
+
from .lspservers import lang_for # re-export: the service keys requests off it
|
|
37
|
+
|
|
38
|
+
_REQUEST_TIMEOUT_S = config.env_float("CHAD_LSP_TIMEOUT", 5.0)
|
|
39
|
+
_INIT_TIMEOUT_S = config.env_float("CHAD_LSP_INIT_TIMEOUT", 30.0)
|
|
40
|
+
_DIAG_TIMEOUT_S = config.env_float("CHAD_LSP_DIAG_TIMEOUT", 3.0)
|
|
41
|
+
_MAX_RSS_MB = config.env_int("CHAD_LSP_MAX_RSS_MB", 1536)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _gate_open(spec, root: str) -> bool:
|
|
45
|
+
"""The registry's honesty gate: some servers (clangd without a compile
|
|
46
|
+
database, TS without a tsconfig — both measured) return confidently EMPTY
|
|
47
|
+
cross-file results when the project context is missing. If the row names
|
|
48
|
+
project files, one must exist or the server is not started at all."""
|
|
49
|
+
return not spec.project_gate or any(
|
|
50
|
+
os.path.exists(os.path.join(root, p)) for p in spec.project_gate)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _tree_rss_mb(pid: int) -> int:
|
|
54
|
+
"""Resident set of `pid` plus all descendants, in MB (pyright-langserver forks
|
|
55
|
+
node children; the parent alone under-reports)."""
|
|
56
|
+
try:
|
|
57
|
+
out = subprocess.run(["ps", "-axo", "pid=,ppid=,rss="],
|
|
58
|
+
capture_output=True, text=True, timeout=5).stdout
|
|
59
|
+
except Exception: # noqa: BLE001 - a failed sample must never break a request
|
|
60
|
+
return 0
|
|
61
|
+
kids, rss = defaultdict(list), {}
|
|
62
|
+
for ln in out.splitlines():
|
|
63
|
+
try:
|
|
64
|
+
p, pp, r = ln.split()
|
|
65
|
+
kids[int(pp)].append(int(p))
|
|
66
|
+
rss[int(p)] = int(r)
|
|
67
|
+
except ValueError:
|
|
68
|
+
continue
|
|
69
|
+
total, stack, seen = 0, [pid], set()
|
|
70
|
+
while stack:
|
|
71
|
+
cur = stack.pop()
|
|
72
|
+
if cur in seen:
|
|
73
|
+
continue
|
|
74
|
+
seen.add(cur)
|
|
75
|
+
total += rss.get(cur, 0)
|
|
76
|
+
stack.extend(kids.get(cur, ()))
|
|
77
|
+
return total // 1024
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class LspService:
|
|
81
|
+
"""One language-server process per (root, server_key) — servers that speak
|
|
82
|
+
several languages (tsserver: ts+js; clangd: c+cpp) share one — started
|
|
83
|
+
lazily, reaped on exit."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, root: str = "."):
|
|
86
|
+
self.root = os.path.abspath(root)
|
|
87
|
+
self._servers = {} # server_key -> LspClient | None (None = tried & failed)
|
|
88
|
+
self._lock = threading.Lock()
|
|
89
|
+
|
|
90
|
+
# -- lifecycle ---------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
def _server(self, spec):
|
|
93
|
+
"""Lazily create + start the language server for `spec`; None if
|
|
94
|
+
unavailable. A failed start is remembered (cached None) — no per-call retry."""
|
|
95
|
+
key = spec.server_key
|
|
96
|
+
if key in self._servers:
|
|
97
|
+
return self._servers[key]
|
|
98
|
+
with self._lock:
|
|
99
|
+
if key in self._servers:
|
|
100
|
+
return self._servers[key]
|
|
101
|
+
cli = None
|
|
102
|
+
if not spec.commands:
|
|
103
|
+
log.info("lsp: %s has no supported server (%s); tree-sitter fallback",
|
|
104
|
+
spec.language, spec.unsupported)
|
|
105
|
+
elif not _gate_open(spec, self.root):
|
|
106
|
+
# e.g. clangd without compile_commands.json, TS without a tsconfig:
|
|
107
|
+
# the server would return clean, WRONG empties. Refusing keeps
|
|
108
|
+
# callers on the tree-sitter fallback, which labels itself
|
|
109
|
+
# NAME-MATCH ONLY instead of lying with confidence.
|
|
110
|
+
log.info("lsp: none of %s under %s; not starting %s "
|
|
111
|
+
"(tree-sitter fallback)", spec.project_gate, self.root, key)
|
|
112
|
+
else:
|
|
113
|
+
cands = lspservers.resolve_commands(spec)
|
|
114
|
+
if not cands:
|
|
115
|
+
log.info("lsp: no %s server available for %s (tier: %s); "
|
|
116
|
+
"tree-sitter fallback", key, spec.language, spec.tier)
|
|
117
|
+
for cmd in cands: # a candidate that fails to start must not
|
|
118
|
+
cli = self._start(spec, cmd) # block the next one
|
|
119
|
+
if cli is not None:
|
|
120
|
+
break
|
|
121
|
+
self._servers[key] = cli
|
|
122
|
+
return cli
|
|
123
|
+
|
|
124
|
+
def _start(self, spec, cmd):
|
|
125
|
+
from . import lspclient
|
|
126
|
+
cli = None
|
|
127
|
+
try:
|
|
128
|
+
cli = lspclient.LspClient(cmd, self.root)
|
|
129
|
+
cli.initialize(init_options={}, timeout=_INIT_TIMEOUT_S)
|
|
130
|
+
if spec.settings is not None:
|
|
131
|
+
# pyright asks for workspace/configuration but does not START
|
|
132
|
+
# analysis until settings are pushed (measured: refs hang forever
|
|
133
|
+
# on a client that only ever answers the pull)
|
|
134
|
+
cli.notify("workspace/didChangeConfiguration",
|
|
135
|
+
{"settings": spec.settings})
|
|
136
|
+
if spec.ready_log:
|
|
137
|
+
# e.g. pyright answers references with a confident EMPTY list until
|
|
138
|
+
# its initial scan logs completion; asking earlier returns lies.
|
|
139
|
+
cli.wait_log(spec.ready_log, timeout=spec.ready_timeout_s)
|
|
140
|
+
return cli
|
|
141
|
+
except Exception as e: # noqa: BLE001 - any start failure degrades cleanly
|
|
142
|
+
log.info("lsp: %s failed to start (%s); tree-sitter fallback",
|
|
143
|
+
spec.server_key, e)
|
|
144
|
+
if cli is not None:
|
|
145
|
+
try:
|
|
146
|
+
cli.shutdown()
|
|
147
|
+
except Exception: # noqa: BLE001
|
|
148
|
+
pass
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
def _recycle_if_bloated(self, key, cli):
|
|
152
|
+
"""Stop and forget a server whose process tree outgrew the RSS cap. The next
|
|
153
|
+
request simply starts a fresh one (pyright re-warms in ~2s) — unbounded
|
|
154
|
+
analysis memory next to wired model weights is how the process dies."""
|
|
155
|
+
mb = _tree_rss_mb(cli.pid) if cli.pid else 0
|
|
156
|
+
if mb > _MAX_RSS_MB:
|
|
157
|
+
log.info("lsp: %s server tree at %d MB (cap %d); recycling", key, mb,
|
|
158
|
+
_MAX_RSS_MB)
|
|
159
|
+
self._drop(key, cli)
|
|
160
|
+
|
|
161
|
+
def _drop(self, key, cli):
|
|
162
|
+
try:
|
|
163
|
+
cli.shutdown()
|
|
164
|
+
except Exception: # noqa: BLE001 - reaping is best-effort
|
|
165
|
+
pass
|
|
166
|
+
with self._lock:
|
|
167
|
+
if self._servers.get(key) is cli:
|
|
168
|
+
del self._servers[key]
|
|
169
|
+
|
|
170
|
+
def stop(self):
|
|
171
|
+
for cli in self._servers.values():
|
|
172
|
+
if cli is not None:
|
|
173
|
+
try:
|
|
174
|
+
cli.shutdown()
|
|
175
|
+
except Exception: # noqa: BLE001
|
|
176
|
+
pass
|
|
177
|
+
self._servers.clear()
|
|
178
|
+
|
|
179
|
+
def available(self, path: str) -> bool:
|
|
180
|
+
spec = self._spec(path)
|
|
181
|
+
return spec is not None and self._server(spec) is not None
|
|
182
|
+
|
|
183
|
+
# -- requests ----------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
def _spec(self, rel_path):
|
|
186
|
+
lang = lang_for(rel_path)
|
|
187
|
+
return lspservers.spec_for(lang) if lang else None
|
|
188
|
+
|
|
189
|
+
def _abs(self, rel_path):
|
|
190
|
+
return rel_path if os.path.isabs(rel_path) else os.path.join(self.root, rel_path)
|
|
191
|
+
|
|
192
|
+
def _rel(self, p):
|
|
193
|
+
try:
|
|
194
|
+
return os.path.relpath(p, self.root)
|
|
195
|
+
except ValueError:
|
|
196
|
+
return p
|
|
197
|
+
|
|
198
|
+
def _ask(self, rel_path, fn, timeout=None, sync=True):
|
|
199
|
+
"""Common request plumbing: spec -> server -> didOpen -> fn(cli, abs, t),
|
|
200
|
+
degrading to None on any failure; RSS check + dead-server drop after.
|
|
201
|
+
`sync=False` leaves the didOpen/didChange to fn (diagnostics must read the
|
|
202
|
+
publish generation BEFORE syncing or it can miss its own publish)."""
|
|
203
|
+
spec = self._spec(rel_path)
|
|
204
|
+
if spec is None:
|
|
205
|
+
return None
|
|
206
|
+
cli = self._server(spec)
|
|
207
|
+
if cli is None:
|
|
208
|
+
return None
|
|
209
|
+
t = _REQUEST_TIMEOUT_S if timeout is None else min(timeout, _REQUEST_TIMEOUT_S)
|
|
210
|
+
ap = self._abs(rel_path)
|
|
211
|
+
try:
|
|
212
|
+
if sync:
|
|
213
|
+
cli.sync_doc(ap, spec.language_id)
|
|
214
|
+
return fn(cli, ap, t)
|
|
215
|
+
except Exception: # noqa: BLE001 - timeout/protocol error/dead server
|
|
216
|
+
if not cli.alive():
|
|
217
|
+
self._drop(spec.server_key, cli) # next request starts fresh
|
|
218
|
+
return None
|
|
219
|
+
finally:
|
|
220
|
+
self._recycle_if_bloated(spec.server_key, cli)
|
|
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
|
+
out = self._ask(rel_path,
|
|
230
|
+
lambda cli, ap, t: cli.references(ap, name_row, name_col,
|
|
231
|
+
timeout=t),
|
|
232
|
+
timeout)
|
|
233
|
+
if out is None:
|
|
234
|
+
return None
|
|
235
|
+
return [(self._rel(p), ln, col) for p, ln, col in out]
|
|
236
|
+
|
|
237
|
+
def references_decorative(self, rel_path: str, name_row: int, name_col: int,
|
|
238
|
+
timeout=None):
|
|
239
|
+
"""`references()` for decoration (the disambiguation "used by …" hints):
|
|
240
|
+
never pays a server *start* unless the registry marks the server cheap
|
|
241
|
+
(`decorative_ok`) — others are used only if already running."""
|
|
242
|
+
spec = self._spec(rel_path)
|
|
243
|
+
if spec is None:
|
|
244
|
+
return None
|
|
245
|
+
if not spec.decorative_ok and self._servers.get(spec.server_key) is None:
|
|
246
|
+
return None # absent (never started) and tried-but-failed both refuse
|
|
247
|
+
return self.references(rel_path, name_row, name_col, timeout=timeout)
|
|
248
|
+
|
|
249
|
+
def definition(self, rel_path: str, row: int, col: int):
|
|
250
|
+
"""Precise go-to-definition for the symbol at 0-based (row, col). Returns a
|
|
251
|
+
list of (rel, line1) or None if unavailable."""
|
|
252
|
+
out = self._ask(rel_path,
|
|
253
|
+
lambda cli, ap, t: cli.definition(ap, row, col, timeout=t))
|
|
254
|
+
if out is None:
|
|
255
|
+
return None
|
|
256
|
+
return [(self._rel(p), ln) for p, ln in out]
|
|
257
|
+
|
|
258
|
+
def hover(self, rel_path: str, row: int, col: int):
|
|
259
|
+
"""Hover text (type signature / docs) for the symbol at 0-based (row, col),
|
|
260
|
+
or None if unavailable — the model reads a type in ~30 tokens instead of
|
|
261
|
+
opening the defining file."""
|
|
262
|
+
return self._ask(rel_path,
|
|
263
|
+
lambda cli, ap, t: cli.hover(ap, row, col, timeout=t))
|
|
264
|
+
|
|
265
|
+
def diagnostics_after_edit(self, rel_path: str):
|
|
266
|
+
"""The server's diagnostics for rel_path's CURRENT on-disk content — the
|
|
267
|
+
post-edit typecheck. Returns [(severity, line1, message)] (possibly empty:
|
|
268
|
+
a clean bill), or None when no server is WARM for this language. Stricter
|
|
269
|
+
than the decorative policy: an edit result is on the interactive path, so
|
|
270
|
+
it never pays a server start at all — not even a registry-cheap one (a
|
|
271
|
+
cold uvx fetch inside an edit round trip is exactly the stall the
|
|
272
|
+
decorative rule exists to prevent). The note lights up once find_refs /
|
|
273
|
+
hover / disambiguation has warmed the server."""
|
|
274
|
+
spec = self._spec(rel_path)
|
|
275
|
+
if spec is None:
|
|
276
|
+
return None
|
|
277
|
+
if self._servers.get(spec.server_key) is None:
|
|
278
|
+
return None
|
|
279
|
+
def ask(cli, ap, _t):
|
|
280
|
+
gen = cli.diag_generation(ap) # BEFORE the sync that triggers analysis
|
|
281
|
+
cli.sync_doc(ap, spec.language_id)
|
|
282
|
+
try:
|
|
283
|
+
# pull first (LSP 3.17; synchronous, and unsupported servers refuse
|
|
284
|
+
# instantly) — TS7's native LSP never pushes at all
|
|
285
|
+
diags = cli.pull_diagnostics(ap, timeout=_DIAG_TIMEOUT_S)
|
|
286
|
+
except Exception: # noqa: BLE001 - no pull support: wait for the push
|
|
287
|
+
diags = cli.wait_diagnostics(ap, gen, timeout=_DIAG_TIMEOUT_S)
|
|
288
|
+
if diags is None:
|
|
289
|
+
return None
|
|
290
|
+
sev_names = {1: "error", 2: "warning", 3: "info", 4: "hint"}
|
|
291
|
+
out = []
|
|
292
|
+
for d in diags:
|
|
293
|
+
start = (d.get("range") or {}).get("start") or {}
|
|
294
|
+
out.append((sev_names.get(d.get("severity", 1), "error"),
|
|
295
|
+
start.get("line", 0) + 1, str(d.get("message", ""))))
|
|
296
|
+
return out
|
|
297
|
+
return self._ask(rel_path, ask, sync=False)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def diagnostics_note(path: str) -> str:
|
|
301
|
+
"""A compact post-edit typecheck note for edit-tool results, or "". Errors only
|
|
302
|
+
(warnings are style noise mid-edit), capped at 5, first line of each message —
|
|
303
|
+
~50 tokens that can replace the failed test run the model would otherwise burn
|
|
304
|
+
to discover a type error. Empty when no server is warm for the language (see
|
|
305
|
+
diagnostics_after_edit's spawn policy) or diagnostics are disabled
|
|
306
|
+
(CHAD_LSP_DIAG_TIMEOUT=0)."""
|
|
307
|
+
if _DIAG_TIMEOUT_S <= 0:
|
|
308
|
+
return ""
|
|
309
|
+
try:
|
|
310
|
+
diags = service().diagnostics_after_edit(path)
|
|
311
|
+
except Exception: # noqa: BLE001 - a diag failure must never break an edit result
|
|
312
|
+
return ""
|
|
313
|
+
errs = [(ln, msg) for sev, ln, msg in diags or [] if sev == "error"]
|
|
314
|
+
if not errs:
|
|
315
|
+
return ""
|
|
316
|
+
lines = [f" line {ln}: {msg.splitlines()[0][:160]}" for ln, msg in errs[:5]]
|
|
317
|
+
more = f"\n … (+{len(errs) - 5} more)" if len(errs) > 5 else ""
|
|
318
|
+
return (f"\n[typecheck — {len(errs)} error(s) (language server):\n"
|
|
319
|
+
+ "\n".join(lines) + more + "]")
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
_SERVICE = None
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def service() -> LspService:
|
|
326
|
+
"""Lazily-bound, cwd-rooted service. Rebinding to a new project root (e.g. each
|
|
327
|
+
eval task) stops the previous language server so pyright processes don't leak."""
|
|
328
|
+
global _SERVICE
|
|
329
|
+
cwd = os.path.abspath(os.getcwd())
|
|
330
|
+
if _SERVICE is None or _SERVICE.root != cwd:
|
|
331
|
+
if _SERVICE is not None:
|
|
332
|
+
_SERVICE.stop()
|
|
333
|
+
_SERVICE = LspService(cwd)
|
|
334
|
+
return _SERVICE
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
@atexit.register
|
|
338
|
+
def _shutdown():
|
|
339
|
+
if _SERVICE is not None:
|
|
340
|
+
_SERVICE.stop()
|