voidx 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. voidx/__init__.py +3 -0
  2. voidx/agent/__init__.py +0 -0
  3. voidx/agent/agents.py +439 -0
  4. voidx/agent/attachments.py +235 -0
  5. voidx/agent/graph.py +463 -0
  6. voidx/agent/graph_components/__init__.py +1 -0
  7. voidx/agent/graph_components/compaction.py +268 -0
  8. voidx/agent/graph_components/permissions.py +139 -0
  9. voidx/agent/graph_components/run_loop.py +532 -0
  10. voidx/agent/graph_components/runtime.py +14 -0
  11. voidx/agent/graph_components/streaming.py +351 -0
  12. voidx/agent/graph_components/subagent.py +278 -0
  13. voidx/agent/graph_components/tool_execution.py +208 -0
  14. voidx/agent/runtime_context.py +368 -0
  15. voidx/agent/slash.py +466 -0
  16. voidx/agent/slash_components/__init__.py +1 -0
  17. voidx/agent/slash_components/code_ide.py +68 -0
  18. voidx/agent/slash_components/lsp.py +105 -0
  19. voidx/agent/slash_components/mcp.py +332 -0
  20. voidx/agent/slash_components/model.py +419 -0
  21. voidx/agent/slash_components/runtime.py +55 -0
  22. voidx/agent/slash_components/skills.py +94 -0
  23. voidx/agent/state.py +32 -0
  24. voidx/agent/task_state.py +278 -0
  25. voidx/agent/tool_filters.py +27 -0
  26. voidx/config.py +707 -0
  27. voidx/llm/__init__.py +0 -0
  28. voidx/llm/catalog.py +188 -0
  29. voidx/llm/compaction.py +267 -0
  30. voidx/llm/context.py +43 -0
  31. voidx/llm/instruction.py +220 -0
  32. voidx/llm/provider.py +312 -0
  33. voidx/llm/usage.py +341 -0
  34. voidx/lsp/__init__.py +30 -0
  35. voidx/lsp/client.py +259 -0
  36. voidx/lsp/config.py +172 -0
  37. voidx/lsp/detector.py +512 -0
  38. voidx/lsp/errors.py +19 -0
  39. voidx/lsp/manager.py +280 -0
  40. voidx/lsp/schema.py +179 -0
  41. voidx/lsp/service.py +103 -0
  42. voidx/main.py +154 -0
  43. voidx/mcp/__init__.py +33 -0
  44. voidx/mcp/client.py +458 -0
  45. voidx/mcp/manager.py +267 -0
  46. voidx/mcp/schema.py +112 -0
  47. voidx/mcp/tool.py +122 -0
  48. voidx/mcp_servers/__init__.py +1 -0
  49. voidx/mcp_servers/web.py +104 -0
  50. voidx/memory/__init__.py +0 -0
  51. voidx/memory/context_frames.py +188 -0
  52. voidx/memory/model_profiles.py +98 -0
  53. voidx/memory/runtime_state.py +240 -0
  54. voidx/memory/session.py +272 -0
  55. voidx/memory/store.py +245 -0
  56. voidx/memory/transcript.py +137 -0
  57. voidx/permission/__init__.py +28 -0
  58. voidx/permission/engine.py +430 -0
  59. voidx/permission/evaluate.py +114 -0
  60. voidx/permission/sandbox.py +280 -0
  61. voidx/permission/schema.py +24 -0
  62. voidx/permission/service.py +314 -0
  63. voidx/permission/wildcard.py +34 -0
  64. voidx/skills/__init__.py +18 -0
  65. voidx/skills/bundled/superpowers/receiving-code-review/SKILL.md +30 -0
  66. voidx/skills/bundled/superpowers/requesting-code-review/SKILL.md +27 -0
  67. voidx/skills/bundled/superpowers/systematic-debugging/SKILL.md +36 -0
  68. voidx/skills/bundled/superpowers/test-driven-development/SKILL.md +33 -0
  69. voidx/skills/bundled/superpowers/verification-before-completion/SKILL.md +31 -0
  70. voidx/skills/bundled/superpowers/writing-plans/SKILL.md +27 -0
  71. voidx/skills/policy.py +97 -0
  72. voidx/skills/registry.py +162 -0
  73. voidx/skills/schema.py +47 -0
  74. voidx/skills/service.py +199 -0
  75. voidx/tools/__init__.py +0 -0
  76. voidx/tools/agent.py +81 -0
  77. voidx/tools/base.py +86 -0
  78. voidx/tools/bash.py +105 -0
  79. voidx/tools/file_ops.py +193 -0
  80. voidx/tools/lsp.py +155 -0
  81. voidx/tools/registry.py +104 -0
  82. voidx/tools/repomap.py +238 -0
  83. voidx/tools/search.py +162 -0
  84. voidx/tools/task_status.py +57 -0
  85. voidx/tools/task_tracker.py +81 -0
  86. voidx/tools/todo.py +82 -0
  87. voidx/tools/web_content.py +357 -0
  88. voidx/tools/web_mcp.py +107 -0
  89. voidx/tools/webfetch.py +155 -0
  90. voidx/tools/websearch.py +276 -0
  91. voidx/ui/__init__.py +0 -0
  92. voidx/ui/app.py +1033 -0
  93. voidx/ui/app_components/__init__.py +1 -0
  94. voidx/ui/app_components/clipboard_image.py +245 -0
  95. voidx/ui/app_components/commands.py +18 -0
  96. voidx/ui/app_components/controls.py +29 -0
  97. voidx/ui/app_components/file_picker.py +115 -0
  98. voidx/ui/app_components/formatting.py +187 -0
  99. voidx/ui/app_components/git_changes.py +51 -0
  100. voidx/ui/app_components/rendering.py +1169 -0
  101. voidx/ui/browse.py +160 -0
  102. voidx/ui/capture.py +169 -0
  103. voidx/ui/code_ide.py +251 -0
  104. voidx/ui/commands.py +83 -0
  105. voidx/ui/console.py +381 -0
  106. voidx/ui/console_components/__init__.py +1 -0
  107. voidx/ui/console_components/formatting.py +96 -0
  108. voidx/ui/console_components/streaming.py +253 -0
  109. voidx/ui/diff.py +331 -0
  110. voidx/ui/dock.py +372 -0
  111. voidx/ui/dock_components/__init__.py +1 -0
  112. voidx/ui/dock_components/formatting.py +123 -0
  113. voidx/ui/dock_components/nodes.py +401 -0
  114. voidx/ui/dock_components/state.py +51 -0
  115. voidx/ui/event_components/__init__.py +1 -0
  116. voidx/ui/event_components/schema.py +249 -0
  117. voidx/ui/events.py +341 -0
  118. voidx/ui/session_changes.py +163 -0
  119. voidx/ui/startup.py +161 -0
  120. voidx/ui/transcript.py +148 -0
  121. voidx/ui/tree.py +316 -0
  122. voidx-1.0.0.dist-info/METADATA +59 -0
  123. voidx-1.0.0.dist-info/RECORD +126 -0
  124. voidx-1.0.0.dist-info/WHEEL +5 -0
  125. voidx-1.0.0.dist-info/entry_points.txt +2 -0
  126. voidx-1.0.0.dist-info/top_level.txt +1 -0
voidx/llm/usage.py ADDED
@@ -0,0 +1,341 @@
1
+ """Token usage accounting for context and LLM calls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+ from voidx.llm.context import count_messages_tokens
9
+
10
+
11
+ @dataclass
12
+ class TokenUsage:
13
+ input_tokens: int = 0
14
+ output_tokens: int = 0
15
+ total_tokens: int = 0
16
+ reasoning_tokens: int = 0
17
+ cache_read_tokens: int = 0
18
+ cache_write_tokens: int = 0
19
+ cache_tokens_reported: bool = False
20
+
21
+
22
+ @dataclass
23
+ class UsageStats:
24
+ context_tokens: int = 0
25
+ context_limit: int = 128_000
26
+ last_input_tokens: int = 0
27
+ last_output_tokens: int = 0
28
+ last_cache_read_tokens: int = 0
29
+ last_cache_write_tokens: int = 0
30
+ last_estimated_cache_read_tokens: int = 0
31
+ total_input_tokens: int = 0
32
+ total_output_tokens: int = 0
33
+ total_cache_read_tokens: int = 0
34
+ total_cache_write_tokens: int = 0
35
+ total_estimated_cache_read_tokens: int = 0
36
+ total_cache_metric_calls: int = 0
37
+ estimated_cache_calls: int = 0
38
+ total_calls: int = 0
39
+ _cache_context_history: dict[str, list[list[dict[str, str]]]] = field(
40
+ default_factory=dict,
41
+ repr=False,
42
+ compare=False,
43
+ )
44
+
45
+ @property
46
+ def total_tokens(self) -> int:
47
+ return self.total_input_tokens + self.total_output_tokens
48
+
49
+ @property
50
+ def cache_observed_tokens(self) -> int:
51
+ return self.total_cache_read_tokens + self.total_cache_write_tokens
52
+
53
+ @property
54
+ def cache_hit_rate(self) -> float | None:
55
+ actual = self.actual_cache_hit_rate
56
+ if actual is not None:
57
+ return actual
58
+ return self.estimated_cache_hit_rate
59
+
60
+ @property
61
+ def actual_cache_hit_rate(self) -> float | None:
62
+ if self.total_cache_metric_calls <= 0 and self.cache_observed_tokens <= 0:
63
+ return None
64
+ denominator = max(self.total_input_tokens, self.cache_observed_tokens)
65
+ if denominator <= 0:
66
+ return None
67
+ return self.total_cache_read_tokens / denominator
68
+
69
+ @property
70
+ def estimated_cache_hit_rate(self) -> float | None:
71
+ if self.estimated_cache_calls <= 0 or self.total_input_tokens <= 0:
72
+ return None
73
+ return self.total_estimated_cache_read_tokens / self.total_input_tokens
74
+
75
+ @property
76
+ def cache_hit_rate_is_estimated(self) -> bool:
77
+ return self.actual_cache_hit_rate is None and self.estimated_cache_hit_rate is not None
78
+
79
+ def reset(self) -> None:
80
+ self.context_tokens = 0
81
+ self.last_input_tokens = 0
82
+ self.last_output_tokens = 0
83
+ self.last_cache_read_tokens = 0
84
+ self.last_cache_write_tokens = 0
85
+ self.last_estimated_cache_read_tokens = 0
86
+ self.total_input_tokens = 0
87
+ self.total_output_tokens = 0
88
+ self.total_cache_read_tokens = 0
89
+ self.total_cache_write_tokens = 0
90
+ self.total_estimated_cache_read_tokens = 0
91
+ self.total_cache_metric_calls = 0
92
+ self.estimated_cache_calls = 0
93
+ self.total_calls = 0
94
+ self._cache_context_history.clear()
95
+
96
+ def update_context(self, tokens: int, limit: int | None = None) -> None:
97
+ self.context_tokens = max(tokens, 0)
98
+ if limit is not None:
99
+ self.context_limit = max(limit, 0)
100
+
101
+ def record_call(
102
+ self,
103
+ usage: TokenUsage,
104
+ *,
105
+ fallback_input_tokens: int = 0,
106
+ fallback_output_tokens: int = 0,
107
+ messages: list | None = None,
108
+ model: str = "",
109
+ cache_key: str = "",
110
+ ) -> None:
111
+ cache_key = cache_key or model or "default"
112
+ current_context = _messages_for_count(messages) if messages is not None else None
113
+ estimated_cache_read_tokens = (
114
+ self._estimate_cache_reuse_tokens(current_context, model, cache_key)
115
+ if current_context is not None else None
116
+ )
117
+ input_tokens = usage.input_tokens or fallback_input_tokens
118
+ output_tokens = usage.output_tokens or fallback_output_tokens
119
+ self.last_input_tokens = max(input_tokens, 0)
120
+ self.last_output_tokens = max(output_tokens, 0)
121
+ self.last_cache_read_tokens = max(usage.cache_read_tokens, 0)
122
+ self.last_cache_write_tokens = max(usage.cache_write_tokens, 0)
123
+ self.last_estimated_cache_read_tokens = 0
124
+ self.total_input_tokens += self.last_input_tokens
125
+ self.total_output_tokens += self.last_output_tokens
126
+ self.total_cache_read_tokens += self.last_cache_read_tokens
127
+ self.total_cache_write_tokens += self.last_cache_write_tokens
128
+ if usage.cache_tokens_reported:
129
+ self.total_cache_metric_calls += 1
130
+ elif estimated_cache_read_tokens is not None and self.last_input_tokens > 0:
131
+ self.last_estimated_cache_read_tokens = min(
132
+ max(estimated_cache_read_tokens, 0),
133
+ self.last_input_tokens,
134
+ )
135
+ self.total_estimated_cache_read_tokens += self.last_estimated_cache_read_tokens
136
+ self.estimated_cache_calls += 1
137
+ self.total_calls += 1
138
+ if usage.input_tokens:
139
+ self.context_tokens = usage.input_tokens
140
+ if current_context is not None:
141
+ history = self._cache_context_history.setdefault(cache_key, [])
142
+ history.append(current_context)
143
+ del history[:-8]
144
+
145
+ def _estimate_cache_reuse_tokens(
146
+ self,
147
+ current_context: list[dict[str, str]],
148
+ model: str,
149
+ cache_key: str,
150
+ ) -> int:
151
+ best = 0
152
+ for previous_context in self._cache_context_history.get(cache_key, []):
153
+ common_prefix = _common_prefix_context(previous_context, current_context)
154
+ if common_prefix:
155
+ best = max(best, count_messages_tokens(common_prefix, model))
156
+ return best
157
+
158
+
159
+ def extract_token_usage(message: object) -> TokenUsage:
160
+ """Extract provider token usage from LangChain message metadata."""
161
+ usage = TokenUsage()
162
+ sources = _usage_sources(message)
163
+ for source in sources:
164
+ usage.input_tokens = usage.input_tokens or _first_int(
165
+ source,
166
+ "input_tokens",
167
+ "prompt_tokens",
168
+ "input",
169
+ )
170
+ usage.output_tokens = usage.output_tokens or _first_int(
171
+ source,
172
+ "output_tokens",
173
+ "completion_tokens",
174
+ "output",
175
+ )
176
+ usage.total_tokens = usage.total_tokens or _first_int(source, "total_tokens", "total")
177
+ usage.reasoning_tokens = usage.reasoning_tokens or _nested_first_int(
178
+ source,
179
+ ("output_token_details", "reasoning"),
180
+ ("completion_tokens_details", "reasoning_tokens"),
181
+ ("completion_token_details", "reasoning_tokens"),
182
+ ("reasoning",),
183
+ )
184
+ usage.cache_read_tokens = usage.cache_read_tokens or _nested_first_int(
185
+ source,
186
+ ("input_token_details", "cache_read"),
187
+ ("input_token_details", "cached_tokens"),
188
+ ("prompt_tokens_details", "cached_tokens"),
189
+ ("cache_read_input_tokens",),
190
+ )
191
+ usage.cache_write_tokens = usage.cache_write_tokens or _nested_first_int(
192
+ source,
193
+ ("input_token_details", "cache_creation"),
194
+ ("cache_creation_input_tokens",),
195
+ )
196
+ usage.cache_tokens_reported = usage.cache_tokens_reported or _has_nested_key(
197
+ source,
198
+ ("input_token_details", "cache_read"),
199
+ ("input_token_details", "cached_tokens"),
200
+ ("prompt_tokens_details", "cached_tokens"),
201
+ ("cache_read_input_tokens",),
202
+ ("input_token_details", "cache_creation"),
203
+ ("cache_creation_input_tokens",),
204
+ )
205
+ if not usage.total_tokens and (usage.input_tokens or usage.output_tokens):
206
+ usage.total_tokens = usage.input_tokens + usage.output_tokens + usage.reasoning_tokens
207
+ return usage
208
+
209
+
210
+ def estimate_context_tokens(messages: list, model: str = "") -> int:
211
+ return count_messages_tokens(_messages_for_count(messages), model)
212
+
213
+
214
+ def estimate_message_tokens(message: object, model: str = "") -> int:
215
+ return count_messages_tokens([_message_for_count(message)], model)
216
+
217
+
218
+ def format_token_count(value: int | None) -> str:
219
+ value = max(int(value or 0), 0)
220
+ if value >= 1_000_000:
221
+ return _format_scaled(value, 1_000_000, "m")
222
+ if value >= 1_000:
223
+ return _format_scaled(value, 1_000, "k")
224
+ return str(value)
225
+
226
+
227
+ def format_cache_hit_rate(stats: UsageStats) -> str:
228
+ rate = stats.cache_hit_rate
229
+ if rate is None:
230
+ return "--"
231
+ percent = max(0, min(round(rate * 100), 100))
232
+ prefix = "~" if stats.cache_hit_rate_is_estimated else ""
233
+ return f"{prefix}{percent}%"
234
+
235
+
236
+ def _format_scaled(value: int, divisor: int, suffix: str) -> str:
237
+ if value % divisor == 0:
238
+ return f"{value // divisor}{suffix}"
239
+ return f"{value / divisor:.1f}{suffix}"
240
+
241
+
242
+ def _usage_sources(message: object) -> list[dict[str, Any]]:
243
+ sources: list[dict[str, Any]] = []
244
+ for value in (
245
+ getattr(message, "usage_metadata", None),
246
+ getattr(message, "response_metadata", None),
247
+ getattr(message, "additional_kwargs", None),
248
+ ):
249
+ if isinstance(value, dict):
250
+ sources.append(value)
251
+ for key in ("usage", "token_usage"):
252
+ nested = value.get(key)
253
+ if isinstance(nested, dict):
254
+ sources.append(nested)
255
+ return sources
256
+
257
+
258
+ def _first_int(source: dict[str, Any], *keys: str) -> int:
259
+ for key in keys:
260
+ value = source.get(key)
261
+ if isinstance(value, int):
262
+ return value
263
+ return 0
264
+
265
+
266
+ def _nested_first_int(source: dict[str, Any], *paths: tuple[str, ...]) -> int:
267
+ for path in paths:
268
+ current: Any = source
269
+ for part in path:
270
+ if not isinstance(current, dict):
271
+ current = None
272
+ break
273
+ current = current.get(part)
274
+ if isinstance(current, int):
275
+ return current
276
+ return 0
277
+
278
+
279
+ def _has_nested_key(source: dict[str, Any], *paths: tuple[str, ...]) -> bool:
280
+ for path in paths:
281
+ current: Any = source
282
+ for part in path:
283
+ if not isinstance(current, dict) or part not in current:
284
+ current = None
285
+ break
286
+ current = current[part]
287
+ if current is not None:
288
+ return True
289
+ return False
290
+
291
+
292
+ def _messages_for_count(messages: list) -> list[dict[str, str]]:
293
+ return [_message_for_count(message) for message in messages]
294
+
295
+
296
+ def _common_prefix_context(
297
+ previous_context: list[dict[str, str]],
298
+ current_context: list[dict[str, str]],
299
+ ) -> list[dict[str, str]]:
300
+ common: list[dict[str, str]] = []
301
+ for previous, current in zip(previous_context, current_context):
302
+ if previous != current:
303
+ break
304
+ common.append(current)
305
+ return common
306
+
307
+
308
+ def _message_for_count(message: object) -> dict[str, str]:
309
+ role = getattr(message, "type", "") or message.__class__.__name__.removesuffix("Message").lower()
310
+ parts = [str(_message_content_for_count(message))]
311
+ tool_calls = getattr(message, "tool_calls", None)
312
+ if tool_calls:
313
+ parts.append(str(tool_calls))
314
+ return {"role": role, "content": "\n".join(parts)}
315
+
316
+
317
+ def _message_content_for_count(message: object) -> object:
318
+ if isinstance(message, dict):
319
+ return message.get("content", "")
320
+ return _content_for_count(getattr(message, "content", str(message)))
321
+
322
+
323
+ def _content_for_count(content: object) -> str:
324
+ if isinstance(content, str):
325
+ return content
326
+ if isinstance(content, list):
327
+ parts: list[str] = []
328
+ for item in content:
329
+ if isinstance(item, str):
330
+ parts.append(item)
331
+ elif isinstance(item, dict):
332
+ if item.get("type") == "text":
333
+ parts.append(str(item.get("text", "")))
334
+ elif item.get("type") in {"image", "image_url"}:
335
+ parts.append("[image]")
336
+ else:
337
+ parts.append(str(item))
338
+ else:
339
+ parts.append(str(item))
340
+ return "\n".join(parts)
341
+ return str(content)
voidx/lsp/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ """Language Server Protocol integration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from voidx.lsp.config import LSP_CONFIG_FILE, load_lsp_servers
6
+ from voidx.lsp.manager import LspManager
7
+ from voidx.lsp.schema import (
8
+ LspDiagnostic,
9
+ LspDoctorCheck,
10
+ LspLocation,
11
+ LspPosition,
12
+ LspRange,
13
+ LspRuntimeStatus,
14
+ LspServerConfig,
15
+ LspSymbol,
16
+ )
17
+
18
+ __all__ = [
19
+ "LSP_CONFIG_FILE",
20
+ "LspDiagnostic",
21
+ "LspDoctorCheck",
22
+ "LspLocation",
23
+ "LspManager",
24
+ "LspPosition",
25
+ "LspRange",
26
+ "LspRuntimeStatus",
27
+ "LspServerConfig",
28
+ "LspSymbol",
29
+ "load_lsp_servers",
30
+ ]
voidx/lsp/client.py ADDED
@@ -0,0 +1,259 @@
1
+ """Async stdio JSON-RPC client for Language Server Protocol."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import os
9
+ from collections.abc import Callable
10
+ from typing import Any
11
+
12
+ from voidx.lsp.errors import LspConnectionError, LspRequestError
13
+ from voidx.lsp.schema import LspServerConfig, parse_diagnostics
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+ NotificationHandler = Callable[[str, dict[str, Any]], None]
18
+
19
+
20
+ def encode_lsp_message(payload: dict[str, Any]) -> bytes:
21
+ body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
22
+ header = f"Content-Length: {len(body)}\r\n\r\n".encode("ascii")
23
+ return header + body
24
+
25
+
26
+ async def read_lsp_message(reader: asyncio.StreamReader) -> dict[str, Any] | None:
27
+ headers: dict[str, str] = {}
28
+ while True:
29
+ line = await reader.readline()
30
+ if line == b"":
31
+ return None
32
+ if line in (b"\r\n", b"\n"):
33
+ break
34
+ key, sep, value = line.decode("ascii", errors="replace").partition(":")
35
+ if sep:
36
+ headers[key.strip().lower()] = value.strip()
37
+
38
+ raw_length = headers.get("content-length")
39
+ if raw_length is None:
40
+ raise LspConnectionError("LSP message missing Content-Length header")
41
+ try:
42
+ length = int(raw_length)
43
+ except ValueError as exc:
44
+ raise LspConnectionError(f"Invalid Content-Length: {raw_length}") from exc
45
+ body = await reader.readexactly(length)
46
+ return json.loads(body.decode("utf-8"))
47
+
48
+
49
+ class LspClient:
50
+ def __init__(
51
+ self,
52
+ config: LspServerConfig,
53
+ *,
54
+ cwd: str,
55
+ notification_handler: NotificationHandler | None = None,
56
+ ) -> None:
57
+ self.config = config
58
+ self.cwd = cwd
59
+ self._notification_handler = notification_handler
60
+ self._process: asyncio.subprocess.Process | None = None
61
+ self._reader_task: asyncio.Task | None = None
62
+ self._stderr_task: asyncio.Task | None = None
63
+ self._pending: dict[int, asyncio.Future] = {}
64
+ self._next_id = 1
65
+ self._diagnostics: dict[str, list] = {}
66
+ self._capabilities: dict[str, Any] = {}
67
+ self._error_message = ""
68
+
69
+ @property
70
+ def pid(self) -> int | None:
71
+ return self._process.pid if self._process is not None else None
72
+
73
+ @property
74
+ def connected(self) -> bool:
75
+ return self._process is not None and self._process.returncode is None
76
+
77
+ @property
78
+ def status(self) -> str:
79
+ if self.connected:
80
+ return "connected"
81
+ if self._error_message:
82
+ return "error"
83
+ return "disconnected"
84
+
85
+ @property
86
+ def error_message(self) -> str:
87
+ return self._error_message
88
+
89
+ @property
90
+ def capabilities(self) -> dict[str, Any]:
91
+ return self._capabilities
92
+
93
+ async def start(self, *, root_uri: str, timeout: float = 10.0) -> None:
94
+ if self.connected:
95
+ return
96
+ try:
97
+ self._process = await asyncio.create_subprocess_exec(
98
+ self.config.command,
99
+ *self.config.args,
100
+ cwd=self.cwd,
101
+ env=os.environ.copy(),
102
+ stdin=asyncio.subprocess.PIPE,
103
+ stdout=asyncio.subprocess.PIPE,
104
+ stderr=asyncio.subprocess.PIPE,
105
+ )
106
+ except OSError as exc:
107
+ self._error_message = str(exc)
108
+ raise LspConnectionError(f"Could not start {self.config.language} LSP: {exc}") from exc
109
+
110
+ self._reader_task = asyncio.create_task(self._read_loop())
111
+ self._stderr_task = asyncio.create_task(self._drain_stderr())
112
+ result = await self.request("initialize", {
113
+ "processId": None,
114
+ "rootUri": root_uri,
115
+ "capabilities": _client_capabilities(),
116
+ "workspaceFolders": [{"uri": root_uri, "name": "workspace"}],
117
+ }, timeout=timeout)
118
+ self._capabilities = result.get("capabilities", {}) if isinstance(result, dict) else {}
119
+ await self.notify("initialized", {})
120
+
121
+ async def stop(self) -> None:
122
+ if self._process is None:
123
+ return
124
+ if self.connected:
125
+ try:
126
+ await self.request("shutdown", None, timeout=2.0)
127
+ except Exception:
128
+ pass
129
+ try:
130
+ await self.notify("exit", {})
131
+ except Exception:
132
+ pass
133
+ try:
134
+ await asyncio.wait_for(self._process.wait(), timeout=2.0)
135
+ except asyncio.TimeoutError:
136
+ self._process.terminate()
137
+ try:
138
+ await asyncio.wait_for(self._process.wait(), timeout=2.0)
139
+ except asyncio.TimeoutError:
140
+ self._process.kill()
141
+ await self._process.wait()
142
+ self._process = None
143
+ self._cancel_tasks()
144
+
145
+ async def request(self, method: str, params: Any = None, *, timeout: float = 10.0) -> Any:
146
+ if self._process is None or self._process.stdin is None:
147
+ raise LspConnectionError("LSP client is not started")
148
+ if self._process.returncode is not None:
149
+ raise LspConnectionError(f"LSP server exited with code {self._process.returncode}")
150
+ req_id = self._next_id
151
+ self._next_id += 1
152
+ future = asyncio.get_running_loop().create_future()
153
+ self._pending[req_id] = future
154
+ await self._send({"jsonrpc": "2.0", "id": req_id, "method": method, "params": params})
155
+ try:
156
+ response = await asyncio.wait_for(future, timeout=timeout)
157
+ except asyncio.TimeoutError as exc:
158
+ self._pending.pop(req_id, None)
159
+ raise LspConnectionError(f"LSP request timed out: {method}") from exc
160
+ if isinstance(response, dict) and response.get("error"):
161
+ error = response["error"]
162
+ message = error.get("message", str(error)) if isinstance(error, dict) else str(error)
163
+ raise LspRequestError(f"{method}: {message}")
164
+ return response.get("result") if isinstance(response, dict) else response
165
+
166
+ async def notify(self, method: str, params: Any = None) -> None:
167
+ await self._send({"jsonrpc": "2.0", "method": method, "params": params})
168
+
169
+ def diagnostics_for(self, uri: str) -> list:
170
+ return list(self._diagnostics.get(uri, []))
171
+
172
+ def all_diagnostics(self) -> list:
173
+ result: list = []
174
+ for diagnostics in self._diagnostics.values():
175
+ result.extend(diagnostics)
176
+ return result
177
+
178
+ async def _send(self, payload: dict[str, Any]) -> None:
179
+ if self._process is None or self._process.stdin is None:
180
+ raise LspConnectionError("LSP client is not started")
181
+ self._process.stdin.write(encode_lsp_message(payload))
182
+ await self._process.stdin.drain()
183
+
184
+ async def _read_loop(self) -> None:
185
+ assert self._process is not None and self._process.stdout is not None
186
+ try:
187
+ while True:
188
+ message = await read_lsp_message(self._process.stdout)
189
+ if message is None:
190
+ break
191
+ self._handle_message(message)
192
+ except asyncio.CancelledError:
193
+ raise
194
+ except Exception as exc:
195
+ self._error_message = str(exc)
196
+ log.debug("LSP read loop failed for %s: %s", self.config.language, exc)
197
+ finally:
198
+ for future in self._pending.values():
199
+ if not future.done():
200
+ future.set_exception(LspConnectionError("LSP connection closed"))
201
+ self._pending.clear()
202
+
203
+ def _handle_message(self, message: dict[str, Any]) -> None:
204
+ msg_id = message.get("id")
205
+ if msg_id in self._pending:
206
+ future = self._pending.pop(msg_id)
207
+ if not future.done():
208
+ future.set_result(message)
209
+ return
210
+
211
+ method = message.get("method")
212
+ params = message.get("params")
213
+ if method == "textDocument/publishDiagnostics" and isinstance(params, dict):
214
+ uri = params.get("uri")
215
+ if isinstance(uri, str):
216
+ self._diagnostics[uri] = parse_diagnostics(uri, params.get("diagnostics", []))
217
+ if isinstance(method, str) and isinstance(params, dict) and self._notification_handler:
218
+ self._notification_handler(method, params)
219
+
220
+ async def _drain_stderr(self) -> None:
221
+ assert self._process is not None and self._process.stderr is not None
222
+ try:
223
+ while True:
224
+ line = await self._process.stderr.readline()
225
+ if not line:
226
+ break
227
+ if not self._error_message:
228
+ self._error_message = line.decode("utf-8", errors="replace").strip()
229
+ except asyncio.CancelledError:
230
+ raise
231
+ except Exception:
232
+ pass
233
+
234
+ def _cancel_tasks(self) -> None:
235
+ for task in (self._reader_task, self._stderr_task):
236
+ if task is not None and not task.done():
237
+ task.cancel()
238
+ self._reader_task = None
239
+ self._stderr_task = None
240
+
241
+
242
+ def _client_capabilities() -> dict[str, Any]:
243
+ return {
244
+ "textDocument": {
245
+ "documentSymbol": {"hierarchicalDocumentSymbolSupport": True},
246
+ "definition": {"linkSupport": True},
247
+ "references": {},
248
+ "formatting": {},
249
+ "publishDiagnostics": {"relatedInformation": True},
250
+ "synchronization": {
251
+ "didSave": True,
252
+ "dynamicRegistration": False,
253
+ },
254
+ },
255
+ "workspace": {
256
+ "symbol": {"resolveSupport": {"properties": []}},
257
+ "workspaceFolders": True,
258
+ },
259
+ }