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.
- voidx/__init__.py +3 -0
- voidx/agent/__init__.py +0 -0
- voidx/agent/agents.py +439 -0
- voidx/agent/attachments.py +235 -0
- voidx/agent/graph.py +463 -0
- voidx/agent/graph_components/__init__.py +1 -0
- voidx/agent/graph_components/compaction.py +268 -0
- voidx/agent/graph_components/permissions.py +139 -0
- voidx/agent/graph_components/run_loop.py +532 -0
- voidx/agent/graph_components/runtime.py +14 -0
- voidx/agent/graph_components/streaming.py +351 -0
- voidx/agent/graph_components/subagent.py +278 -0
- voidx/agent/graph_components/tool_execution.py +208 -0
- voidx/agent/runtime_context.py +368 -0
- voidx/agent/slash.py +466 -0
- voidx/agent/slash_components/__init__.py +1 -0
- voidx/agent/slash_components/code_ide.py +68 -0
- voidx/agent/slash_components/lsp.py +105 -0
- voidx/agent/slash_components/mcp.py +332 -0
- voidx/agent/slash_components/model.py +419 -0
- voidx/agent/slash_components/runtime.py +55 -0
- voidx/agent/slash_components/skills.py +94 -0
- voidx/agent/state.py +32 -0
- voidx/agent/task_state.py +278 -0
- voidx/agent/tool_filters.py +27 -0
- voidx/config.py +707 -0
- voidx/llm/__init__.py +0 -0
- voidx/llm/catalog.py +188 -0
- voidx/llm/compaction.py +267 -0
- voidx/llm/context.py +43 -0
- voidx/llm/instruction.py +220 -0
- voidx/llm/provider.py +312 -0
- voidx/llm/usage.py +341 -0
- voidx/lsp/__init__.py +30 -0
- voidx/lsp/client.py +259 -0
- voidx/lsp/config.py +172 -0
- voidx/lsp/detector.py +512 -0
- voidx/lsp/errors.py +19 -0
- voidx/lsp/manager.py +280 -0
- voidx/lsp/schema.py +179 -0
- voidx/lsp/service.py +103 -0
- voidx/main.py +154 -0
- voidx/mcp/__init__.py +33 -0
- voidx/mcp/client.py +458 -0
- voidx/mcp/manager.py +267 -0
- voidx/mcp/schema.py +112 -0
- voidx/mcp/tool.py +122 -0
- voidx/mcp_servers/__init__.py +1 -0
- voidx/mcp_servers/web.py +104 -0
- voidx/memory/__init__.py +0 -0
- voidx/memory/context_frames.py +188 -0
- voidx/memory/model_profiles.py +98 -0
- voidx/memory/runtime_state.py +240 -0
- voidx/memory/session.py +272 -0
- voidx/memory/store.py +245 -0
- voidx/memory/transcript.py +137 -0
- voidx/permission/__init__.py +28 -0
- voidx/permission/engine.py +430 -0
- voidx/permission/evaluate.py +114 -0
- voidx/permission/sandbox.py +280 -0
- voidx/permission/schema.py +24 -0
- voidx/permission/service.py +314 -0
- voidx/permission/wildcard.py +34 -0
- voidx/skills/__init__.py +18 -0
- voidx/skills/bundled/superpowers/receiving-code-review/SKILL.md +30 -0
- voidx/skills/bundled/superpowers/requesting-code-review/SKILL.md +27 -0
- voidx/skills/bundled/superpowers/systematic-debugging/SKILL.md +36 -0
- voidx/skills/bundled/superpowers/test-driven-development/SKILL.md +33 -0
- voidx/skills/bundled/superpowers/verification-before-completion/SKILL.md +31 -0
- voidx/skills/bundled/superpowers/writing-plans/SKILL.md +27 -0
- voidx/skills/policy.py +97 -0
- voidx/skills/registry.py +162 -0
- voidx/skills/schema.py +47 -0
- voidx/skills/service.py +199 -0
- voidx/tools/__init__.py +0 -0
- voidx/tools/agent.py +81 -0
- voidx/tools/base.py +86 -0
- voidx/tools/bash.py +105 -0
- voidx/tools/file_ops.py +193 -0
- voidx/tools/lsp.py +155 -0
- voidx/tools/registry.py +104 -0
- voidx/tools/repomap.py +238 -0
- voidx/tools/search.py +162 -0
- voidx/tools/task_status.py +57 -0
- voidx/tools/task_tracker.py +81 -0
- voidx/tools/todo.py +82 -0
- voidx/tools/web_content.py +357 -0
- voidx/tools/web_mcp.py +107 -0
- voidx/tools/webfetch.py +155 -0
- voidx/tools/websearch.py +276 -0
- voidx/ui/__init__.py +0 -0
- voidx/ui/app.py +1033 -0
- voidx/ui/app_components/__init__.py +1 -0
- voidx/ui/app_components/clipboard_image.py +245 -0
- voidx/ui/app_components/commands.py +18 -0
- voidx/ui/app_components/controls.py +29 -0
- voidx/ui/app_components/file_picker.py +115 -0
- voidx/ui/app_components/formatting.py +187 -0
- voidx/ui/app_components/git_changes.py +51 -0
- voidx/ui/app_components/rendering.py +1169 -0
- voidx/ui/browse.py +160 -0
- voidx/ui/capture.py +169 -0
- voidx/ui/code_ide.py +251 -0
- voidx/ui/commands.py +83 -0
- voidx/ui/console.py +381 -0
- voidx/ui/console_components/__init__.py +1 -0
- voidx/ui/console_components/formatting.py +96 -0
- voidx/ui/console_components/streaming.py +253 -0
- voidx/ui/diff.py +331 -0
- voidx/ui/dock.py +372 -0
- voidx/ui/dock_components/__init__.py +1 -0
- voidx/ui/dock_components/formatting.py +123 -0
- voidx/ui/dock_components/nodes.py +401 -0
- voidx/ui/dock_components/state.py +51 -0
- voidx/ui/event_components/__init__.py +1 -0
- voidx/ui/event_components/schema.py +249 -0
- voidx/ui/events.py +341 -0
- voidx/ui/session_changes.py +163 -0
- voidx/ui/startup.py +161 -0
- voidx/ui/transcript.py +148 -0
- voidx/ui/tree.py +316 -0
- voidx-1.0.0.dist-info/METADATA +59 -0
- voidx-1.0.0.dist-info/RECORD +126 -0
- voidx-1.0.0.dist-info/WHEEL +5 -0
- voidx-1.0.0.dist-info/entry_points.txt +2 -0
- 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
|
+
}
|