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.
Files changed (113) hide show
  1. {chad_code-1.0.6 → chad_code-1.0.7}/PKG-INFO +2 -5
  2. {chad_code-1.0.6 → chad_code-1.0.7}/pyproject.toml +10 -12
  3. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/__init__.py +1 -1
  4. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/levers.py +11 -0
  5. chad_code-1.0.7/src/chad/lsp.py +340 -0
  6. chad_code-1.0.7/src/chad/lspclient.py +341 -0
  7. chad_code-1.0.7/src/chad/lspservers.py +157 -0
  8. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/mcp.py +81 -31
  9. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/mcp_oauth.py +37 -16
  10. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/repomap.py +242 -16
  11. chad_code-1.0.7/src/chad/symbols.py +162 -0
  12. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/tools.py +29 -4
  13. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad_code.egg-info/PKG-INFO +2 -5
  14. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad_code.egg-info/SOURCES.txt +5 -0
  15. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad_code.egg-info/requires.txt +1 -5
  16. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_ignore.py +3 -3
  17. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_lever_bite.py +23 -0
  18. chad_code-1.0.7/tests/test_lsp.py +315 -0
  19. chad_code-1.0.7/tests/test_lsp_live.py +108 -0
  20. chad_code-1.0.7/tests/test_lspclient.py +205 -0
  21. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_mcp.py +77 -7
  22. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_mcp_oauth.py +3 -3
  23. chad_code-1.0.7/tests/test_repomap_polyglot.py +111 -0
  24. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_symbols.py +83 -52
  25. chad_code-1.0.6/src/chad/lsp.py +0 -323
  26. chad_code-1.0.6/src/chad/symbols.py +0 -372
  27. chad_code-1.0.6/tests/test_lsp.py +0 -115
  28. {chad_code-1.0.6 → chad_code-1.0.7}/LICENSE +0 -0
  29. {chad_code-1.0.6 → chad_code-1.0.7}/README.md +0 -0
  30. {chad_code-1.0.6 → chad_code-1.0.7}/setup.cfg +0 -0
  31. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/agent.py +0 -0
  32. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/atif.py +0 -0
  33. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/base_engine.py +0 -0
  34. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/bench.py +0 -0
  35. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/cli.py +0 -0
  36. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/compaction.py +0 -0
  37. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/completion_engine.py +0 -0
  38. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/config.py +0 -0
  39. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/diag.py +0 -0
  40. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/engine.py +0 -0
  41. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/guardrails.py +0 -0
  42. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/ignore.py +0 -0
  43. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/mlx_fastpath.py +0 -0
  44. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/mlx_qsdpa.py +0 -0
  45. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/parakeet/LICENSE +0 -0
  46. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/parakeet/__init__.py +0 -0
  47. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/parakeet/alignment.py +0 -0
  48. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/parakeet/attention.py +0 -0
  49. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/parakeet/audio.py +0 -0
  50. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/parakeet/cache.py +0 -0
  51. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/parakeet/conformer.py +0 -0
  52. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/parakeet/ctc.py +0 -0
  53. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/parakeet/parakeet.py +0 -0
  54. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/parakeet/rnnt.py +0 -0
  55. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/parakeet/tokenizer.py +0 -0
  56. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/parakeet/utils.py +0 -0
  57. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/profiles.py +0 -0
  58. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/prompt.py +0 -0
  59. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/prove.py +0 -0
  60. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/render.py +0 -0
  61. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/serve.py +0 -0
  62. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/session.py +0 -0
  63. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/skills.py +0 -0
  64. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/speech.py +0 -0
  65. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/syntaxgate.py +0 -0
  66. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/toolcall_parse.py +0 -0
  67. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/tui.py +0 -0
  68. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad/validate.py +0 -0
  69. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad_code.egg-info/dependency_links.txt +0 -0
  70. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad_code.egg-info/entry_points.txt +0 -0
  71. {chad_code-1.0.6 → chad_code-1.0.7}/src/chad_code.egg-info/top_level.txt +0 -0
  72. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_agent.py +0 -0
  73. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_agent_e2e.py +0 -0
  74. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_agent_guards.py +0 -0
  75. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_atif.py +0 -0
  76. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_bench.py +0 -0
  77. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_cli.py +0 -0
  78. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_compact_notice.py +0 -0
  79. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_compaction.py +0 -0
  80. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_completion_engine.py +0 -0
  81. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_config.py +0 -0
  82. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_confirm_preview.py +0 -0
  83. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_done_audit.py +0 -0
  84. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_drift_warn.py +0 -0
  85. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_edit.py +0 -0
  86. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_edit_corruption.py +0 -0
  87. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_engine.py +0 -0
  88. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_engine_kvquant.py +0 -0
  89. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_engine_pld_hybrid.py +0 -0
  90. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_feel_pack.py +0 -0
  91. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_garble_invariant.py +0 -0
  92. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_gate.py +0 -0
  93. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_intent.py +0 -0
  94. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_levers.py +0 -0
  95. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_log_redaction.py +0 -0
  96. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_mlx_fastpath.py +0 -0
  97. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_mlx_qsdpa.py +0 -0
  98. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_plan_review.py +0 -0
  99. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_prove.py +0 -0
  100. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_render.py +0 -0
  101. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_replace_lines.py +0 -0
  102. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_repomap.py +0 -0
  103. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_serve.py +0 -0
  104. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_session.py +0 -0
  105. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_skills.py +0 -0
  106. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_speech.py +0 -0
  107. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_speech_tui.py +0 -0
  108. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_subagent.py +0 -0
  109. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_syntaxgate.py +0 -0
  110. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_toolcall_parse.py +0 -0
  111. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_tools.py +0 -0
  112. {chad_code-1.0.6 → chad_code-1.0.7}/tests/test_tui.py +0 -0
  113. {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.6
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>=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
@@ -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.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
- # 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
 
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/jedi/tree_sitter/mcp ship partial or no stubs
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.6"
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()