chad-code 1.0.6__tar.gz → 1.0.8__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.
Files changed (113) hide show
  1. {chad_code-1.0.6 → chad_code-1.0.8}/PKG-INFO +22 -9
  2. {chad_code-1.0.6 → chad_code-1.0.8}/README.md +20 -4
  3. {chad_code-1.0.6 → chad_code-1.0.8}/pyproject.toml +18 -16
  4. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad/__init__.py +1 -1
  5. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad/levers.py +11 -0
  6. chad_code-1.0.8/src/chad/lsp.py +340 -0
  7. chad_code-1.0.8/src/chad/lspclient.py +341 -0
  8. chad_code-1.0.8/src/chad/lspservers.py +157 -0
  9. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad/mcp.py +81 -31
  10. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad/mcp_oauth.py +37 -16
  11. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad/repomap.py +242 -16
  12. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad/speech.py +22 -1
  13. chad_code-1.0.8/src/chad/symbols.py +162 -0
  14. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad/tools.py +29 -4
  15. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad_code.egg-info/PKG-INFO +22 -9
  16. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad_code.egg-info/SOURCES.txt +5 -0
  17. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad_code.egg-info/requires.txt +1 -5
  18. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_ignore.py +3 -3
  19. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_lever_bite.py +23 -0
  20. chad_code-1.0.8/tests/test_lsp.py +315 -0
  21. chad_code-1.0.8/tests/test_lsp_live.py +108 -0
  22. chad_code-1.0.8/tests/test_lspclient.py +205 -0
  23. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_mcp.py +77 -7
  24. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_mcp_oauth.py +3 -3
  25. chad_code-1.0.8/tests/test_repomap_polyglot.py +111 -0
  26. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_speech.py +18 -1
  27. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_symbols.py +83 -52
  28. chad_code-1.0.6/src/chad/lsp.py +0 -323
  29. chad_code-1.0.6/src/chad/symbols.py +0 -372
  30. chad_code-1.0.6/tests/test_lsp.py +0 -115
  31. {chad_code-1.0.6 → chad_code-1.0.8}/LICENSE +0 -0
  32. {chad_code-1.0.6 → chad_code-1.0.8}/setup.cfg +0 -0
  33. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad/agent.py +0 -0
  34. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad/atif.py +0 -0
  35. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad/base_engine.py +0 -0
  36. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad/bench.py +0 -0
  37. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad/cli.py +0 -0
  38. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad/compaction.py +0 -0
  39. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad/completion_engine.py +0 -0
  40. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad/config.py +0 -0
  41. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad/diag.py +0 -0
  42. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad/engine.py +0 -0
  43. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad/guardrails.py +0 -0
  44. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad/ignore.py +0 -0
  45. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad/mlx_fastpath.py +0 -0
  46. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad/mlx_qsdpa.py +0 -0
  47. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad/parakeet/LICENSE +0 -0
  48. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad/parakeet/__init__.py +0 -0
  49. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad/parakeet/alignment.py +0 -0
  50. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad/parakeet/attention.py +0 -0
  51. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad/parakeet/audio.py +0 -0
  52. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad/parakeet/cache.py +0 -0
  53. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad/parakeet/conformer.py +0 -0
  54. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad/parakeet/ctc.py +0 -0
  55. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad/parakeet/parakeet.py +0 -0
  56. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad/parakeet/rnnt.py +0 -0
  57. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad/parakeet/tokenizer.py +0 -0
  58. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad/parakeet/utils.py +0 -0
  59. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad/profiles.py +0 -0
  60. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad/prompt.py +0 -0
  61. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad/prove.py +0 -0
  62. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad/render.py +0 -0
  63. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad/serve.py +0 -0
  64. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad/session.py +0 -0
  65. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad/skills.py +0 -0
  66. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad/syntaxgate.py +0 -0
  67. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad/toolcall_parse.py +0 -0
  68. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad/tui.py +0 -0
  69. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad/validate.py +0 -0
  70. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad_code.egg-info/dependency_links.txt +0 -0
  71. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad_code.egg-info/entry_points.txt +0 -0
  72. {chad_code-1.0.6 → chad_code-1.0.8}/src/chad_code.egg-info/top_level.txt +0 -0
  73. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_agent.py +0 -0
  74. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_agent_e2e.py +0 -0
  75. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_agent_guards.py +0 -0
  76. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_atif.py +0 -0
  77. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_bench.py +0 -0
  78. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_cli.py +0 -0
  79. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_compact_notice.py +0 -0
  80. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_compaction.py +0 -0
  81. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_completion_engine.py +0 -0
  82. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_config.py +0 -0
  83. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_confirm_preview.py +0 -0
  84. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_done_audit.py +0 -0
  85. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_drift_warn.py +0 -0
  86. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_edit.py +0 -0
  87. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_edit_corruption.py +0 -0
  88. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_engine.py +0 -0
  89. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_engine_kvquant.py +0 -0
  90. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_engine_pld_hybrid.py +0 -0
  91. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_feel_pack.py +0 -0
  92. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_garble_invariant.py +0 -0
  93. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_gate.py +0 -0
  94. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_intent.py +0 -0
  95. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_levers.py +0 -0
  96. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_log_redaction.py +0 -0
  97. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_mlx_fastpath.py +0 -0
  98. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_mlx_qsdpa.py +0 -0
  99. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_plan_review.py +0 -0
  100. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_prove.py +0 -0
  101. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_render.py +0 -0
  102. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_replace_lines.py +0 -0
  103. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_repomap.py +0 -0
  104. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_serve.py +0 -0
  105. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_session.py +0 -0
  106. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_skills.py +0 -0
  107. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_speech_tui.py +0 -0
  108. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_subagent.py +0 -0
  109. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_syntaxgate.py +0 -0
  110. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_toolcall_parse.py +0 -0
  111. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_tools.py +0 -0
  112. {chad_code-1.0.6 → chad_code-1.0.8}/tests/test_tui.py +0 -0
  113. {chad_code-1.0.6 → chad_code-1.0.8}/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.6
3
+ Version: 1.0.8
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>=1.27
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
@@ -142,6 +139,21 @@ uv run chad "add a --json flag to main.py and update the tests" # one-shot, he
142
139
  uv run chad -c # resume this directory's last conversation
143
140
  ```
144
141
 
142
+ **Optional extras.** Everything core is in the base install; two features are opt-in
143
+ because they pull deps not every install wants — `speech` (voice mode: a mic library,
144
+ no torch) and `highlight` (syntax colour in diffs/previews). An extra rides on the
145
+ **install spec**, not on a separate command, so how you add it depends on how you
146
+ installed chad:
147
+
148
+ ```bash
149
+ uv tool install --force 'chad-code[speech]' # add to an existing `uv tool` install
150
+ uvx --from 'chad-code[speech]' chad # one-off run, nothing installed
151
+ uv sync --extra speech # from a clone
152
+ ```
153
+
154
+ `/speech` in the TUI prints whichever of those matches your install, so you never have
155
+ to work it out from here.
156
+
145
157
  **The model.** chad picks one for you by RAM and downloads it once into the shared Hugging
146
158
  Face cache (`~/.cache/huggingface`, reused across every project). Override with
147
159
  `--model 9b` / `--model 35b`, or `--model <repo or local dir>` for anything else.
@@ -167,8 +179,8 @@ re-downloads the model.
167
179
  **Development.** `uv sync` once, then `uv run pytest -q` — the fast unit gate loads **no
168
180
  model weights**, runs in seconds, and is what CI runs. Throughput on your own machine:
169
181
  `uv run chad-bench` (see [Throughput & performance](docs/benchmarks.md)). LSP-precise
170
- find-references / rename need the `lsp` extra (`uv tool install 'chad-code[lsp]'`); without
171
- it chad uses the tree-sitter fallback automatically.
182
+ find-references / rename need no extra — chad fetches pyright via `uvx` on first use and
183
+ falls back to tree-sitter when a language server can't start.
172
184
 
173
185
  ## Interactive UX
174
186
 
@@ -194,8 +206,9 @@ it chad uses the tree-sitter fallback automatically.
194
206
  isn't clipped, and a personal word table (`~/.chad/speech_words.json`) teaches it your
195
207
  identifiers — `{"pie test": "pytest"}`. Dictation cost is linear in take length, so a
196
208
  long thought is fine; `/speech` off releases both the mic and the weights. Nothing
197
- leaves the machine. Needs the `speech` extra (`uv sync --extra speech` — just a mic
198
- library; no torch, no numba).
209
+ leaves the machine. Needs the `speech` extra — just a mic library; no torch, no numba
210
+ (see [Installing & upgrading](#installing--upgrading); on a `uv tool` install that's
211
+ `uv tool install --force 'chad-code[speech]'`).
199
212
 
200
213
  **Usage.** `uv run chad --help` is the source of truth:
201
214
 
@@ -103,6 +103,21 @@ uv run chad "add a --json flag to main.py and update the tests" # one-shot, he
103
103
  uv run chad -c # resume this directory's last conversation
104
104
  ```
105
105
 
106
+ **Optional extras.** Everything core is in the base install; two features are opt-in
107
+ because they pull deps not every install wants — `speech` (voice mode: a mic library,
108
+ no torch) and `highlight` (syntax colour in diffs/previews). An extra rides on the
109
+ **install spec**, not on a separate command, so how you add it depends on how you
110
+ installed chad:
111
+
112
+ ```bash
113
+ uv tool install --force 'chad-code[speech]' # add to an existing `uv tool` install
114
+ uvx --from 'chad-code[speech]' chad # one-off run, nothing installed
115
+ uv sync --extra speech # from a clone
116
+ ```
117
+
118
+ `/speech` in the TUI prints whichever of those matches your install, so you never have
119
+ to work it out from here.
120
+
106
121
  **The model.** chad picks one for you by RAM and downloads it once into the shared Hugging
107
122
  Face cache (`~/.cache/huggingface`, reused across every project). Override with
108
123
  `--model 9b` / `--model 35b`, or `--model <repo or local dir>` for anything else.
@@ -128,8 +143,8 @@ re-downloads the model.
128
143
  **Development.** `uv sync` once, then `uv run pytest -q` — the fast unit gate loads **no
129
144
  model weights**, runs in seconds, and is what CI runs. Throughput on your own machine:
130
145
  `uv run chad-bench` (see [Throughput & performance](docs/benchmarks.md)). LSP-precise
131
- find-references / rename need the `lsp` extra (`uv tool install 'chad-code[lsp]'`); without
132
- it chad uses the tree-sitter fallback automatically.
146
+ find-references / rename need no extra — chad fetches pyright via `uvx` on first use and
147
+ falls back to tree-sitter when a language server can't start.
133
148
 
134
149
  ## Interactive UX
135
150
 
@@ -155,8 +170,9 @@ it chad uses the tree-sitter fallback automatically.
155
170
  isn't clipped, and a personal word table (`~/.chad/speech_words.json`) teaches it your
156
171
  identifiers — `{"pie test": "pytest"}`. Dictation cost is linear in take length, so a
157
172
  long thought is fine; `/speech` off releases both the mic and the weights. Nothing
158
- leaves the machine. Needs the `speech` extra (`uv sync --extra speech` — just a mic
159
- library; no torch, no numba).
173
+ leaves the machine. Needs the `speech` extra — just a mic library; no torch, no numba
174
+ (see [Installing & upgrading](#installing--upgrading); on a `uv tool` install that's
175
+ `uv tool install --force 'chad-code[speech]'`).
160
176
 
161
177
  **Usage.** `uv run chad --help` is the source of truth:
162
178
 
@@ -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.6"
7
+ version = "1.0.8"
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,31 +62,34 @@ 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
- # Floor lowered from 1.28.1: serena-agent (the `lsp` extra) hard-pins mcp==1.27.0, and
67
- # chad's mcp surface (ClientSession, stdio/streamable-http clients, OAuthClientProvider)
68
- # is stable across 1.27+. The 1.27->1.28 delta is only deprecations chad doesn't touch.
69
- "mcp>=1.27",
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
 
75
+ # Extras ride on the INSTALL SPEC, so the command depends on how chad was installed:
76
+ # `uv tool install --force 'chad-code[<extra>]'` (or `uvx --from 'chad-code[<extra>]' chad`)
77
+ # for a PyPI install, `uv sync --extra <extra>` only in a clone. speech.install_hint()
78
+ # derives the right one at runtime — keep it in step with any extra added here.
79
+ # NOTE: the PyPI distribution is `chad-code`; 'pip install chad' installs an unrelated
80
+ # squatted package.
81
+ #
72
82
  # Optional syntax highlighting in diffs / confirm previews. Pure-Python,
73
83
  # import-guarded in render.py: absent, output is byte-identical to the un-highlighted path.
74
- # Install with `uv sync --extra highlight`.
75
- # NOTE: no PyPI release exists; 'pip install chad' installs an unrelated squatted
76
- # package. Document uv/git installs only.
77
84
  [project.optional-dependencies]
78
85
  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
86
  # Voice mode (/speech in the TUI): push-to-talk dictation via chad's vendored
85
87
  # Parakeet-on-MLX (src/chad/parakeet — the PyPI parakeet-mlx drags librosa's
86
88
  # numba/scipy tree for one mel-filterbank call) + spoken replies via macOS
87
89
  # `say`. All on-device, nothing leaves the machine. mlx/numpy/huggingface-hub
88
90
  # are already core deps, so the extra adds only the mic (sounddevice/PortAudio).
89
91
  # Optional and import-guarded in speech.py: absent, /speech prints the install
90
- # hint and everything else is untouched. Install with `uv sync --extra speech`.
92
+ # hint and everything else is untouched.
91
93
  speech = [
92
94
  "sounddevice>=0.5",
93
95
  ]
@@ -153,7 +155,7 @@ ignore = ["E741"] # ambiguous var names (l/I) appear in existing math code
153
155
  [tool.mypy]
154
156
  python_version = "3.11"
155
157
  files = ["src/chad"]
156
- ignore_missing_imports = true # mlx/jedi/tree_sitter/mcp ship partial or no stubs
158
+ ignore_missing_imports = true # mlx/tree_sitter/mcp ship partial or no stubs
157
159
  warn_unused_ignores = true
158
160
  implicit_optional = true # `x: T = None` defaults are idiomatic throughout; this
159
161
  # 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.6"
7
+ __version__ = "1.0.8"
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()