python-delphi-lsp 2.0.4__py3-none-any.whl → 2.0.5__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.
- delphi_lsp/_version.py +1 -1
- delphi_lsp/agent_cache.py +710 -0
- delphi_lsp/agent_cli.py +145 -3
- delphi_lsp/agent_context.py +22 -0
- delphi_lsp/agent_relations.py +0 -1
- delphi_lsp/agent_workspace.py +7 -0
- delphi_lsp/binary.py +0 -1
- delphi_lsp/lsp_server.py +0 -2
- delphi_lsp/semantic.py +1 -1
- {python_delphi_lsp-2.0.4.dist-info → python_delphi_lsp-2.0.5.dist-info}/METADATA +60 -3
- {python_delphi_lsp-2.0.4.dist-info → python_delphi_lsp-2.0.5.dist-info}/RECORD +15 -14
- {python_delphi_lsp-2.0.4.dist-info → python_delphi_lsp-2.0.5.dist-info}/WHEEL +0 -0
- {python_delphi_lsp-2.0.4.dist-info → python_delphi_lsp-2.0.5.dist-info}/entry_points.txt +0 -0
- {python_delphi_lsp-2.0.4.dist-info → python_delphi_lsp-2.0.5.dist-info}/licenses/LICENSE +0 -0
- {python_delphi_lsp-2.0.4.dist-info → python_delphi_lsp-2.0.5.dist-info}/top_level.txt +0 -0
delphi_lsp/_version.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "2.0.
|
|
1
|
+
__version__ = "2.0.5"
|
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Collection, Mapping
|
|
4
|
+
from dataclasses import dataclass, fields, is_dataclass, replace
|
|
5
|
+
import argparse
|
|
6
|
+
import contextlib
|
|
7
|
+
import hmac
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import secrets
|
|
12
|
+
import socket
|
|
13
|
+
import stat
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import re
|
|
17
|
+
import tempfile
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
from types import ModuleType
|
|
21
|
+
|
|
22
|
+
from ._version import __version__
|
|
23
|
+
from .agent_context import AgentContext
|
|
24
|
+
from .agent_protocol import AgentProtocolError
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
DEFAULT_MAX_MEMORY_BYTES = 512 * 1024**2
|
|
28
|
+
WARNING_THRESHOLD_PERCENT = 80
|
|
29
|
+
DAEMON_SCHEMA = 1
|
|
30
|
+
DEFAULT_IDLE_TIMEOUT = 1800
|
|
31
|
+
_MAX_MESSAGE_BYTES = 1024 * 1024
|
|
32
|
+
_CONNECTION_TIMEOUT = 2.0
|
|
33
|
+
_MEMORY_SIZE = re.compile(r"^(?P<count>[1-9][0-9]*)(?P<suffix>[KMG]?)$", re.IGNORECASE)
|
|
34
|
+
_STARTUP_DIAGNOSTIC_BYTES = 16 * 1024
|
|
35
|
+
_STARTUP_TOKEN_RE = re.compile(r"(?i)(token\b[^\n\r]*?:?\s*['\"]?[A-Za-z0-9_-]+['\"]?|\b[a-zA-Z0-9_-]{32,})")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def estimate_deep_size(value: object) -> int:
|
|
39
|
+
"""Estimate the memory retained by an owned object graph.
|
|
40
|
+
|
|
41
|
+
The traversal is intentionally best-effort: unsupported or opaque values
|
|
42
|
+
still count themselves, but do not cause cache accounting to fail.
|
|
43
|
+
"""
|
|
44
|
+
total = 0
|
|
45
|
+
seen: set[int] = set()
|
|
46
|
+
pending: list[object] = [value]
|
|
47
|
+
|
|
48
|
+
while pending:
|
|
49
|
+
current = pending.pop()
|
|
50
|
+
if isinstance(current, (ModuleType, type)) or callable(current):
|
|
51
|
+
continue
|
|
52
|
+
identifier = id(current)
|
|
53
|
+
if identifier in seen:
|
|
54
|
+
continue
|
|
55
|
+
seen.add(identifier)
|
|
56
|
+
try:
|
|
57
|
+
total += sys.getsizeof(current)
|
|
58
|
+
except Exception:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
pending.extend(_children(current))
|
|
63
|
+
except Exception:
|
|
64
|
+
continue
|
|
65
|
+
return total
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class CacheStats:
|
|
70
|
+
requests: int = 0
|
|
71
|
+
warm_hits: int = 0
|
|
72
|
+
rebuilds: int = 0
|
|
73
|
+
invalidations: int = 0
|
|
74
|
+
evictions: int = 0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class BudgetResult:
|
|
79
|
+
retained_bytes: int
|
|
80
|
+
utilization_percent: float
|
|
81
|
+
peak_utilization_percent: float
|
|
82
|
+
warning_active: bool
|
|
83
|
+
warning_triggered: bool
|
|
84
|
+
compacted: bool
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class CacheBudget:
|
|
89
|
+
max_bytes: int = DEFAULT_MAX_MEMORY_BYTES
|
|
90
|
+
warning_percent: int = WARNING_THRESHOLD_PERCENT
|
|
91
|
+
|
|
92
|
+
def __post_init__(self) -> None:
|
|
93
|
+
if self.max_bytes <= 0:
|
|
94
|
+
raise ValueError("max_bytes must be greater than zero.")
|
|
95
|
+
if not 0 < self.warning_percent <= 100:
|
|
96
|
+
raise ValueError("warning_percent must be between 1 and 100.")
|
|
97
|
+
|
|
98
|
+
def enforce(
|
|
99
|
+
self,
|
|
100
|
+
*,
|
|
101
|
+
measure: Callable[[], int],
|
|
102
|
+
evict_auxiliary: Callable[[], None],
|
|
103
|
+
evict_navigation: Callable[[], None],
|
|
104
|
+
) -> BudgetResult:
|
|
105
|
+
initial_retained = measure()
|
|
106
|
+
initial_utilization_percent = initial_retained * 100.0 / self.max_bytes
|
|
107
|
+
compacted = False
|
|
108
|
+
retained = initial_retained
|
|
109
|
+
|
|
110
|
+
if initial_retained > self.max_bytes:
|
|
111
|
+
evict_auxiliary()
|
|
112
|
+
compacted = True
|
|
113
|
+
retained = measure()
|
|
114
|
+
if retained > self.max_bytes:
|
|
115
|
+
evict_navigation()
|
|
116
|
+
retained = measure()
|
|
117
|
+
|
|
118
|
+
current_utilization_percent = retained * 100.0 / self.max_bytes
|
|
119
|
+
return BudgetResult(
|
|
120
|
+
retained_bytes=retained,
|
|
121
|
+
utilization_percent=current_utilization_percent,
|
|
122
|
+
peak_utilization_percent=initial_utilization_percent,
|
|
123
|
+
warning_active=current_utilization_percent >= self.warning_percent,
|
|
124
|
+
warning_triggered=initial_utilization_percent >= self.warning_percent,
|
|
125
|
+
compacted=compacted,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def parse_memory_size(value: str) -> int:
|
|
130
|
+
match = _MEMORY_SIZE.fullmatch(value.strip())
|
|
131
|
+
if match is None:
|
|
132
|
+
raise ValueError("Memory size must be a positive integer with optional K, M, or G suffix.")
|
|
133
|
+
|
|
134
|
+
multiplier = {
|
|
135
|
+
"": 1,
|
|
136
|
+
"K": 1024,
|
|
137
|
+
"M": 1024**2,
|
|
138
|
+
"G": 1024**3,
|
|
139
|
+
}
|
|
140
|
+
return int(match.group("count")) * multiplier[match.group("suffix").upper()]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def cache_warning(result: BudgetResult, max_bytes: int) -> str:
|
|
144
|
+
if not result.warning_triggered:
|
|
145
|
+
return ""
|
|
146
|
+
|
|
147
|
+
if result.compacted:
|
|
148
|
+
state = (
|
|
149
|
+
f"Warning: Delphi cache peaked at {result.peak_utilization_percent:.1f}% of the {max_bytes} byte budget; "
|
|
150
|
+
f"{result.retained_bytes} bytes remain retained after compaction."
|
|
151
|
+
f"{' Cache compacted.' if result.compacted else ''}"
|
|
152
|
+
)
|
|
153
|
+
else:
|
|
154
|
+
state = (
|
|
155
|
+
f"Warning: Delphi cache currently at {result.utilization_percent:.1f}% of the {max_bytes} byte budget; "
|
|
156
|
+
f"{result.retained_bytes} bytes retained."
|
|
157
|
+
)
|
|
158
|
+
return (
|
|
159
|
+
f"{state} "
|
|
160
|
+
"Increase --max-memory, stop unused daemons, or allow compact mode."
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _children(value: object) -> tuple[object, ...]:
|
|
165
|
+
children: list[object] = []
|
|
166
|
+
if isinstance(value, Mapping):
|
|
167
|
+
try:
|
|
168
|
+
for key, item in value.items():
|
|
169
|
+
children.extend((key, item))
|
|
170
|
+
except Exception:
|
|
171
|
+
pass
|
|
172
|
+
elif isinstance(value, Collection) and not isinstance(value, (str, bytes, bytearray)):
|
|
173
|
+
try:
|
|
174
|
+
children.extend(value)
|
|
175
|
+
except Exception:
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
is_dataclass_instance = is_dataclass(value) and not isinstance(value, type)
|
|
179
|
+
dataclass_field_names: frozenset[str] = frozenset()
|
|
180
|
+
if is_dataclass_instance:
|
|
181
|
+
try:
|
|
182
|
+
dataclass_fields = fields(value)
|
|
183
|
+
except Exception:
|
|
184
|
+
dataclass_fields = ()
|
|
185
|
+
dataclass_field_names = frozenset(field.name for field in dataclass_fields)
|
|
186
|
+
for field in dataclass_fields:
|
|
187
|
+
try:
|
|
188
|
+
children.append(getattr(value, field.name))
|
|
189
|
+
except Exception:
|
|
190
|
+
continue
|
|
191
|
+
else:
|
|
192
|
+
try:
|
|
193
|
+
instance_values = vars(value)
|
|
194
|
+
except Exception:
|
|
195
|
+
instance_values = None
|
|
196
|
+
if isinstance(instance_values, Mapping):
|
|
197
|
+
children.extend(instance_values.values())
|
|
198
|
+
|
|
199
|
+
for slot in _slot_names(type(value)):
|
|
200
|
+
if slot in dataclass_field_names:
|
|
201
|
+
continue
|
|
202
|
+
try:
|
|
203
|
+
children.append(getattr(value, slot))
|
|
204
|
+
except Exception:
|
|
205
|
+
continue
|
|
206
|
+
return tuple(children)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _slot_names(value_type: type[object]) -> tuple[str, ...]:
|
|
210
|
+
names: list[str] = []
|
|
211
|
+
for base in value_type.__mro__:
|
|
212
|
+
try:
|
|
213
|
+
slots = getattr(base, "__slots__", ())
|
|
214
|
+
except Exception:
|
|
215
|
+
continue
|
|
216
|
+
if isinstance(slots, str):
|
|
217
|
+
slots = (slots,)
|
|
218
|
+
try:
|
|
219
|
+
names.extend(name for name in slots if isinstance(name, str))
|
|
220
|
+
except Exception:
|
|
221
|
+
continue
|
|
222
|
+
return tuple(names)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@dataclass(frozen=True)
|
|
226
|
+
class CacheMetadata:
|
|
227
|
+
schema: int
|
|
228
|
+
root: str
|
|
229
|
+
pid: int
|
|
230
|
+
port: int
|
|
231
|
+
token: str
|
|
232
|
+
version: str
|
|
233
|
+
project_file: str
|
|
234
|
+
max_memory_bytes: int
|
|
235
|
+
idle_timeout: int
|
|
236
|
+
started_at: float
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@dataclass(frozen=True)
|
|
240
|
+
class CacheClientResponse:
|
|
241
|
+
payload: dict[str, object]
|
|
242
|
+
warning: str = ""
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class CacheClientError(RuntimeError):
|
|
246
|
+
def __init__(self, code: str, message: str) -> None:
|
|
247
|
+
self.code = code
|
|
248
|
+
self.message = message
|
|
249
|
+
super().__init__(message)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def cache_metadata_path(root: str | Path) -> Path:
|
|
253
|
+
return Path(root).resolve() / ".delphi-lsp" / "agent-cache" / "daemon.json"
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _safe_metadata_path(root: str | Path, *, create: bool = False) -> Path:
|
|
257
|
+
root_path = Path(root).resolve()
|
|
258
|
+
for path in (root_path / ".delphi-lsp", root_path / ".delphi-lsp" / "agent-cache"):
|
|
259
|
+
if path.exists() and path.is_symlink():
|
|
260
|
+
raise CacheClientError("unsafe_metadata", "Cache metadata path is unsafe.")
|
|
261
|
+
if create:
|
|
262
|
+
path.mkdir(mode=0o700, exist_ok=True)
|
|
263
|
+
if os.name != "nt":
|
|
264
|
+
os.chmod(path, 0o700)
|
|
265
|
+
result = root_path / ".delphi-lsp" / "agent-cache" / "daemon.json"
|
|
266
|
+
if result.exists() and result.is_symlink():
|
|
267
|
+
raise CacheClientError("unsafe_metadata", "Cache metadata path is unsafe.")
|
|
268
|
+
return result
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _metadata_mapping(metadata: CacheMetadata) -> dict[str, object]:
|
|
272
|
+
return {field.name: getattr(metadata, field.name) for field in fields(metadata)}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _write_metadata(metadata: CacheMetadata) -> None:
|
|
276
|
+
path = _safe_metadata_path(metadata.root, create=True)
|
|
277
|
+
data = json.dumps(_metadata_mapping(metadata), sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
278
|
+
descriptor, temporary = tempfile.mkstemp(prefix=".daemon-", dir=path.parent)
|
|
279
|
+
try:
|
|
280
|
+
if os.name != "nt":
|
|
281
|
+
os.fchmod(descriptor, 0o600)
|
|
282
|
+
with os.fdopen(descriptor, "wb") as stream:
|
|
283
|
+
stream.write(data)
|
|
284
|
+
stream.flush()
|
|
285
|
+
os.fsync(stream.fileno())
|
|
286
|
+
os.replace(temporary, path)
|
|
287
|
+
if os.name != "nt":
|
|
288
|
+
os.chmod(path, 0o600)
|
|
289
|
+
finally:
|
|
290
|
+
with contextlib.suppress(FileNotFoundError):
|
|
291
|
+
os.unlink(temporary)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _read_metadata(root: str | Path) -> CacheMetadata | None:
|
|
295
|
+
path = _safe_metadata_path(root)
|
|
296
|
+
if not path.exists():
|
|
297
|
+
return None
|
|
298
|
+
try:
|
|
299
|
+
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
|
300
|
+
descriptor = os.open(path, flags)
|
|
301
|
+
try:
|
|
302
|
+
info = os.fstat(descriptor)
|
|
303
|
+
if os.name != "nt" and (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077):
|
|
304
|
+
raise CacheClientError("unsafe_metadata", "Cache metadata path is unsafe.")
|
|
305
|
+
raw = json.loads(os.read(descriptor, _MAX_MESSAGE_BYTES).decode("utf-8"))
|
|
306
|
+
finally:
|
|
307
|
+
os.close(descriptor)
|
|
308
|
+
except CacheClientError:
|
|
309
|
+
raise
|
|
310
|
+
except (OSError, ValueError, UnicodeError):
|
|
311
|
+
return None
|
|
312
|
+
required = {field.name for field in fields(CacheMetadata)}
|
|
313
|
+
if not isinstance(raw, dict) or set(raw) != required:
|
|
314
|
+
return None
|
|
315
|
+
values = tuple(raw[name] for name in ("schema", "root", "pid", "port", "token", "version", "project_file", "max_memory_bytes", "idle_timeout", "started_at"))
|
|
316
|
+
if (type(values[0]) is not int or values[0] != DAEMON_SCHEMA or not isinstance(values[1], str)
|
|
317
|
+
or type(values[2]) is not int or values[2] <= 0 or type(values[3]) is not int or not 0 < values[3] < 65536
|
|
318
|
+
or not isinstance(values[4], str) or len(values[4]) < 32 or not isinstance(values[5], str)
|
|
319
|
+
or not isinstance(values[6], str) or type(values[7]) is not int or values[7] <= 0
|
|
320
|
+
or type(values[8]) is not int or values[8] <= 0 or type(values[9]) not in (int, float)):
|
|
321
|
+
return None
|
|
322
|
+
canonical = str(Path(root).resolve())
|
|
323
|
+
if values[1] != canonical:
|
|
324
|
+
return None
|
|
325
|
+
return CacheMetadata(*values)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _remove_metadata_if_owned(metadata: CacheMetadata) -> None:
|
|
329
|
+
path = _safe_metadata_path(metadata.root)
|
|
330
|
+
current = _read_metadata(metadata.root)
|
|
331
|
+
if current is not None and current.pid == metadata.pid and hmac.compare_digest(current.token, metadata.token):
|
|
332
|
+
with contextlib.suppress(FileNotFoundError):
|
|
333
|
+
path.unlink()
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _pid_alive(pid: int) -> bool:
|
|
337
|
+
try:
|
|
338
|
+
os.kill(pid, 0)
|
|
339
|
+
except OSError:
|
|
340
|
+
return False
|
|
341
|
+
return True
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _start_lock_path(root: str | Path) -> Path:
|
|
345
|
+
return _safe_metadata_path(root, create=True).with_name("start.lock")
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
@contextlib.contextmanager
|
|
349
|
+
def _start_lock(root: str | Path):
|
|
350
|
+
path = _start_lock_path(root)
|
|
351
|
+
token = secrets.token_urlsafe(24)
|
|
352
|
+
deadline = time.monotonic() + 10
|
|
353
|
+
while True:
|
|
354
|
+
try:
|
|
355
|
+
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
356
|
+
try:
|
|
357
|
+
payload = json.dumps({"pid": os.getpid(), "token": token, "started_at": time.time()}).encode("utf-8")
|
|
358
|
+
os.write(descriptor, payload)
|
|
359
|
+
os.fsync(descriptor)
|
|
360
|
+
finally:
|
|
361
|
+
os.close(descriptor)
|
|
362
|
+
break
|
|
363
|
+
except FileExistsError:
|
|
364
|
+
try:
|
|
365
|
+
record = json.loads(path.read_text(encoding="utf-8"))
|
|
366
|
+
owner = record.get("pid") if isinstance(record, dict) else None
|
|
367
|
+
started = record.get("started_at") if isinstance(record, dict) else None
|
|
368
|
+
stale = type(owner) is not int or not _pid_alive(owner) or type(started) not in (int, float) or time.time() - started > 30
|
|
369
|
+
except (OSError, ValueError, UnicodeError):
|
|
370
|
+
stale = True
|
|
371
|
+
if stale:
|
|
372
|
+
with contextlib.suppress(FileNotFoundError):
|
|
373
|
+
path.unlink()
|
|
374
|
+
continue
|
|
375
|
+
if time.monotonic() >= deadline:
|
|
376
|
+
raise CacheClientError("startup_locked", "Cache startup is busy.")
|
|
377
|
+
time.sleep(0.05)
|
|
378
|
+
try:
|
|
379
|
+
yield
|
|
380
|
+
finally:
|
|
381
|
+
try:
|
|
382
|
+
current = json.loads(path.read_text(encoding="utf-8"))
|
|
383
|
+
if isinstance(current, dict) and hmac.compare_digest(str(current.get("token", "")), token):
|
|
384
|
+
path.unlink()
|
|
385
|
+
except (OSError, ValueError, UnicodeError):
|
|
386
|
+
pass
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _client_exchange(metadata: CacheMetadata, request: dict[str, object]) -> CacheClientResponse:
|
|
390
|
+
try:
|
|
391
|
+
with socket.create_connection(("127.0.0.1", metadata.port), timeout=2) as connection:
|
|
392
|
+
connection.settimeout(3)
|
|
393
|
+
request_without_token = {key: value for key, value in request.items() if key != "token"}
|
|
394
|
+
connection.sendall(json.dumps({"token": metadata.token, **request_without_token}, separators=(",", ":")).encode("utf-8") + b"\n")
|
|
395
|
+
response = _read_line(connection)
|
|
396
|
+
except OSError as error:
|
|
397
|
+
raise CacheClientError("unavailable", "Cache daemon is unavailable.") from error
|
|
398
|
+
try:
|
|
399
|
+
decoded = json.loads(response.decode("utf-8"))
|
|
400
|
+
except (UnicodeError, ValueError) as error:
|
|
401
|
+
raise CacheClientError("invalid_response", "Cache daemon returned an invalid response.") from error
|
|
402
|
+
if not isinstance(decoded, dict):
|
|
403
|
+
raise CacheClientError("invalid_response", "Cache daemon returned an invalid response.")
|
|
404
|
+
if "error" in decoded:
|
|
405
|
+
error = decoded["error"]
|
|
406
|
+
if isinstance(error, dict):
|
|
407
|
+
raise CacheClientError(str(error.get("code", "error")), str(error.get("message", "Cache request failed.")))
|
|
408
|
+
raise CacheClientError("error", "Cache request failed.")
|
|
409
|
+
payload = decoded.get("payload")
|
|
410
|
+
if not isinstance(payload, dict):
|
|
411
|
+
raise CacheClientError("invalid_response", "Cache daemon returned an invalid response.")
|
|
412
|
+
return CacheClientResponse(payload, str(decoded.get("warning", "")))
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _read_line(connection: socket.socket) -> bytes:
|
|
416
|
+
chunks = bytearray()
|
|
417
|
+
while len(chunks) <= _MAX_MESSAGE_BYTES:
|
|
418
|
+
piece = connection.recv(min(65536, _MAX_MESSAGE_BYTES + 1 - len(chunks)))
|
|
419
|
+
if not piece:
|
|
420
|
+
break
|
|
421
|
+
chunks.extend(piece)
|
|
422
|
+
if b"\n" in piece:
|
|
423
|
+
line, _, _ = chunks.partition(b"\n")
|
|
424
|
+
return line
|
|
425
|
+
raise CacheClientError("invalid_response", "Cache daemon returned an invalid response.")
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _truncate_and_sanitize_startup_diagnostics(raw: bytes, *, max_bytes: int = _STARTUP_DIAGNOSTIC_BYTES) -> str:
|
|
429
|
+
if not raw:
|
|
430
|
+
return ""
|
|
431
|
+
text = raw.decode("utf-8", "replace")
|
|
432
|
+
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
433
|
+
if max_bytes > 0 and len(text) > max_bytes:
|
|
434
|
+
text = text[-max_bytes:]
|
|
435
|
+
text = re.sub(r"[\x00-\x09\x0b-\x1f\x7f-\x9f]", " ", text)
|
|
436
|
+
text = _STARTUP_TOKEN_RE.sub("<redacted>", text)
|
|
437
|
+
text = re.sub(r"\s+", " ", text).strip()
|
|
438
|
+
return text
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _read_startup_tail(diagnostics: object, *, max_bytes: int = _STARTUP_DIAGNOSTIC_BYTES) -> bytes:
|
|
442
|
+
with contextlib.suppress(Exception):
|
|
443
|
+
diagnostics.seek(0, os.SEEK_END)
|
|
444
|
+
end = diagnostics.tell()
|
|
445
|
+
diagnostics.seek(max(0, end - max_bytes), os.SEEK_SET)
|
|
446
|
+
return diagnostics.read()
|
|
447
|
+
return b""
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
class _CacheService:
|
|
451
|
+
def __init__(self, metadata: CacheMetadata) -> None:
|
|
452
|
+
self.metadata = metadata
|
|
453
|
+
self.context = AgentContext.open(metadata.root, metadata.project_file or None)
|
|
454
|
+
self.budget = CacheBudget(metadata.max_memory_bytes)
|
|
455
|
+
self.stats = CacheStats()
|
|
456
|
+
self.lock = threading.Lock()
|
|
457
|
+
self.started = time.monotonic()
|
|
458
|
+
self.last_activity = self.started
|
|
459
|
+
self.cache_state = "warming"
|
|
460
|
+
self.last_revision = self.context.workspace.workspace_revision
|
|
461
|
+
self.last_budget = BudgetResult(0, 0.0, 0.0, False, False, False)
|
|
462
|
+
self.shutdown = threading.Event()
|
|
463
|
+
|
|
464
|
+
def prewarm(self) -> None:
|
|
465
|
+
try:
|
|
466
|
+
self.context.handle({"action": "find", "query": "", "max_items": 1, "max_chars": 256})
|
|
467
|
+
self.last_revision = self.context.workspace.workspace_revision
|
|
468
|
+
self.cache_state = "warm"
|
|
469
|
+
except AgentProtocolError as error:
|
|
470
|
+
if error.code != "project_required":
|
|
471
|
+
raise
|
|
472
|
+
self.cache_state = "ready"
|
|
473
|
+
self.last_budget = self.budget.enforce(
|
|
474
|
+
measure=lambda: estimate_deep_size(self.context.cache_roots()),
|
|
475
|
+
evict_auxiliary=self.context.evict_auxiliary_caches,
|
|
476
|
+
evict_navigation=self.context.evict_navigation_caches,
|
|
477
|
+
)
|
|
478
|
+
if self.last_budget.compacted:
|
|
479
|
+
self.stats.evictions += 1
|
|
480
|
+
self.cache_state = "compact"
|
|
481
|
+
|
|
482
|
+
def request(self, request: dict[str, object]) -> CacheClientResponse:
|
|
483
|
+
with self.lock:
|
|
484
|
+
action = request.get("action")
|
|
485
|
+
if action == "status":
|
|
486
|
+
warning = "" if request.get("_startup_probe") is True else self._consume_warning()
|
|
487
|
+
return CacheClientResponse(self.status(), warning)
|
|
488
|
+
if action == "stop":
|
|
489
|
+
self.shutdown.set()
|
|
490
|
+
return CacheClientResponse({"stopping": True})
|
|
491
|
+
self.last_activity = time.monotonic()
|
|
492
|
+
before = self.last_revision
|
|
493
|
+
was_warm = self.context.navigation_cache_is_warm
|
|
494
|
+
try:
|
|
495
|
+
response = self.context.handle(request).to_mapping()
|
|
496
|
+
except AgentProtocolError as error:
|
|
497
|
+
raise CacheClientError(error.code, error.message) from None
|
|
498
|
+
except (OSError, UnicodeError):
|
|
499
|
+
raise CacheClientError("source_unavailable", "Source is unavailable.") from None
|
|
500
|
+
except Exception:
|
|
501
|
+
raise CacheClientError("internal_error", "Internal cache error.") from None
|
|
502
|
+
after = str(response["workspace_revision"])
|
|
503
|
+
self.last_revision = after
|
|
504
|
+
self.stats.requests += 1
|
|
505
|
+
if was_warm:
|
|
506
|
+
self.stats.warm_hits += 1
|
|
507
|
+
else:
|
|
508
|
+
self.stats.rebuilds += 1
|
|
509
|
+
if before != after:
|
|
510
|
+
self.stats.invalidations += 1
|
|
511
|
+
self.last_budget = self.budget.enforce(
|
|
512
|
+
measure=lambda: estimate_deep_size(self.context.cache_roots()),
|
|
513
|
+
evict_auxiliary=self.context.evict_auxiliary_caches,
|
|
514
|
+
evict_navigation=self.context.evict_navigation_caches,
|
|
515
|
+
)
|
|
516
|
+
if self.last_budget.compacted:
|
|
517
|
+
self.stats.evictions += 1
|
|
518
|
+
self.cache_state = "compact"
|
|
519
|
+
else:
|
|
520
|
+
self.cache_state = "warm" if self.context.navigation_cache_is_warm else "ready"
|
|
521
|
+
return CacheClientResponse(response, self._consume_warning())
|
|
522
|
+
|
|
523
|
+
def _consume_warning(self) -> str:
|
|
524
|
+
warning = cache_warning(self.last_budget, self.budget.max_bytes)
|
|
525
|
+
if self.last_budget.warning_triggered and not self.last_budget.warning_active:
|
|
526
|
+
self.last_budget = replace(self.last_budget, warning_triggered=False)
|
|
527
|
+
return warning
|
|
528
|
+
|
|
529
|
+
def status(self) -> dict[str, object]:
|
|
530
|
+
now = time.monotonic()
|
|
531
|
+
idle = max(0.0, now - self.last_activity)
|
|
532
|
+
return {
|
|
533
|
+
"pid": self.metadata.pid, "root": self.metadata.root, "project_file": self.metadata.project_file,
|
|
534
|
+
"version": self.metadata.version, "uptime": now - self.started, "idle_seconds": idle,
|
|
535
|
+
"last_activity_at": time.time() - idle,
|
|
536
|
+
"max_memory_bytes": self.budget.max_bytes, "current_bytes": self.last_budget.retained_bytes,
|
|
537
|
+
"current_utilization_percent": self.last_budget.utilization_percent,
|
|
538
|
+
"peak_utilization_percent": self.last_budget.peak_utilization_percent,
|
|
539
|
+
"warning_active": self.last_budget.warning_active, "warning_threshold_percent": WARNING_THRESHOLD_PERCENT,
|
|
540
|
+
"cache_state": self.cache_state, "requests": self.stats.requests, "warm_hits": self.stats.warm_hits,
|
|
541
|
+
"rebuilds": self.stats.rebuilds, "invalidations": self.stats.invalidations, "evictions": self.stats.evictions,
|
|
542
|
+
"idle_timeout": self.metadata.idle_timeout, "idle_remaining": max(0.0, self.metadata.idle_timeout - idle),
|
|
543
|
+
"workspace_revision": self.context.workspace.workspace_revision,
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _serve_connection(connection: socket.socket, service: _CacheService) -> None:
|
|
548
|
+
try:
|
|
549
|
+
line = _read_line(connection)
|
|
550
|
+
request = json.loads(line.decode("utf-8"))
|
|
551
|
+
if not isinstance(request, dict) or not hmac.compare_digest(str(request.pop("token", "")), service.metadata.token):
|
|
552
|
+
response: dict[str, object] = {"error": {"code": "authentication_failed", "message": "authentication failed"}}
|
|
553
|
+
else:
|
|
554
|
+
try:
|
|
555
|
+
result = service.request(request)
|
|
556
|
+
response = {"payload": result.payload, "warning": result.warning}
|
|
557
|
+
except CacheClientError as error:
|
|
558
|
+
response = {"error": {"code": error.code, "message": error.message}}
|
|
559
|
+
except Exception:
|
|
560
|
+
response = {"error": {"code": "invalid_request", "message": "Invalid cache request."}}
|
|
561
|
+
with contextlib.suppress(OSError):
|
|
562
|
+
connection.sendall(json.dumps(response, separators=(",", ":")).encode("utf-8") + b"\n")
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def run_cache_daemon(root: str | Path, *, project_file: str = "", max_memory_bytes: int = DEFAULT_MAX_MEMORY_BYTES, idle_timeout: int = DEFAULT_IDLE_TIMEOUT) -> None:
|
|
566
|
+
canonical = str(Path(root).resolve())
|
|
567
|
+
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
568
|
+
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
569
|
+
listener.bind(("127.0.0.1", 0))
|
|
570
|
+
listener.listen(16)
|
|
571
|
+
listener.settimeout(0.25)
|
|
572
|
+
metadata = CacheMetadata(DAEMON_SCHEMA, canonical, os.getpid(), listener.getsockname()[1], secrets.token_urlsafe(32), __version__, project_file, max_memory_bytes, idle_timeout, time.time())
|
|
573
|
+
try:
|
|
574
|
+
service = _CacheService(metadata)
|
|
575
|
+
service.prewarm()
|
|
576
|
+
_write_metadata(metadata)
|
|
577
|
+
while not service.shutdown.is_set() and time.monotonic() - service.last_activity < idle_timeout:
|
|
578
|
+
try:
|
|
579
|
+
connection, _ = listener.accept()
|
|
580
|
+
except socket.timeout:
|
|
581
|
+
continue
|
|
582
|
+
with connection:
|
|
583
|
+
connection.settimeout(_CONNECTION_TIMEOUT)
|
|
584
|
+
_serve_connection(connection, service)
|
|
585
|
+
finally:
|
|
586
|
+
listener.close()
|
|
587
|
+
_remove_metadata_if_owned(metadata)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _start_cache_unlocked(root: str | Path, *, project_file: str | Path | None = None, max_memory_bytes: int = DEFAULT_MAX_MEMORY_BYTES, idle_timeout: int = DEFAULT_IDLE_TIMEOUT) -> CacheMetadata:
|
|
591
|
+
canonical = str(Path(root).resolve())
|
|
592
|
+
project = str((Path(canonical) / project_file).resolve()) if project_file and not Path(project_file).is_absolute() else (str(Path(project_file).resolve()) if project_file else "")
|
|
593
|
+
existing = _read_metadata(canonical)
|
|
594
|
+
if existing and _pid_alive(existing.pid):
|
|
595
|
+
try:
|
|
596
|
+
_client_exchange(existing, {"action": "status", "_startup_probe": True})
|
|
597
|
+
except CacheClientError as error:
|
|
598
|
+
raise CacheClientError("unavailable", "Live cache daemon is unavailable.") from error
|
|
599
|
+
if (existing.project_file, existing.max_memory_bytes, existing.idle_timeout) != (project, max_memory_bytes, idle_timeout):
|
|
600
|
+
raise CacheClientError("configuration_conflict", "A live cache daemon has conflicting configuration.")
|
|
601
|
+
return existing
|
|
602
|
+
if existing:
|
|
603
|
+
_remove_metadata_if_owned(existing)
|
|
604
|
+
command = [sys.executable, "-m", "delphi_lsp.agent_cache", "serve", "--root", canonical, "--max-memory", str(max_memory_bytes), "--idle-timeout", str(idle_timeout)]
|
|
605
|
+
if project:
|
|
606
|
+
command.extend(("--project-file", project))
|
|
607
|
+
options: dict[str, object] = {"stdin": subprocess.DEVNULL, "stdout": subprocess.DEVNULL}
|
|
608
|
+
if os.name == "nt":
|
|
609
|
+
options["creationflags"] = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
|
|
610
|
+
else:
|
|
611
|
+
options["start_new_session"] = True
|
|
612
|
+
with tempfile.TemporaryFile() as diagnostics:
|
|
613
|
+
options["stderr"] = diagnostics
|
|
614
|
+
process = subprocess.Popen(command, **options)
|
|
615
|
+
deadline = time.monotonic() + 10
|
|
616
|
+
startup_ready = False
|
|
617
|
+
try:
|
|
618
|
+
while time.monotonic() < deadline:
|
|
619
|
+
metadata = _read_metadata(canonical)
|
|
620
|
+
if metadata:
|
|
621
|
+
try:
|
|
622
|
+
_client_exchange(metadata, {"action": "status", "_startup_probe": True})
|
|
623
|
+
startup_ready = True
|
|
624
|
+
return metadata
|
|
625
|
+
except CacheClientError:
|
|
626
|
+
pass
|
|
627
|
+
if process.poll() is not None:
|
|
628
|
+
break
|
|
629
|
+
time.sleep(0.05)
|
|
630
|
+
if process.poll() is None:
|
|
631
|
+
process.kill()
|
|
632
|
+
with contextlib.suppress(Exception):
|
|
633
|
+
process.wait()
|
|
634
|
+
metadata = _read_metadata(canonical)
|
|
635
|
+
if metadata and metadata.pid == process.pid:
|
|
636
|
+
_remove_metadata_if_owned(metadata)
|
|
637
|
+
diagnostics.seek(0)
|
|
638
|
+
raw = _read_startup_tail(diagnostics)
|
|
639
|
+
message = _truncate_and_sanitize_startup_diagnostics(raw)
|
|
640
|
+
base = "Cache daemon did not become ready."
|
|
641
|
+
if message:
|
|
642
|
+
raise CacheClientError("startup_failed", f"{base} {message}")
|
|
643
|
+
raise CacheClientError("startup_failed", base)
|
|
644
|
+
finally:
|
|
645
|
+
if not startup_ready:
|
|
646
|
+
if process.poll() is None:
|
|
647
|
+
process.kill()
|
|
648
|
+
with contextlib.suppress(Exception):
|
|
649
|
+
process.wait()
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def start_cache(root: str | Path, *, project_file: str | Path | None = None, max_memory_bytes: int = DEFAULT_MAX_MEMORY_BYTES, idle_timeout: int = DEFAULT_IDLE_TIMEOUT) -> CacheMetadata:
|
|
653
|
+
with _start_lock(root):
|
|
654
|
+
return _start_cache_unlocked(root, project_file=project_file, max_memory_bytes=max_memory_bytes, idle_timeout=idle_timeout)
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def query_cache(root: str | Path, request: dict[str, object]) -> CacheClientResponse:
|
|
658
|
+
metadata = _read_metadata(root)
|
|
659
|
+
if metadata is None:
|
|
660
|
+
raise CacheClientError("cache_not_running", "Cache daemon is not running.")
|
|
661
|
+
if not _pid_alive(metadata.pid):
|
|
662
|
+
_remove_metadata_if_owned(metadata)
|
|
663
|
+
raise CacheClientError("cache_not_running", "Cache daemon is not running.")
|
|
664
|
+
return _client_exchange(metadata, request)
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def cache_status(root: str | Path) -> dict[str, object]:
|
|
668
|
+
return query_cache(root, {"action": "status"}).payload
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def stop_cache(root: str | Path) -> None:
|
|
672
|
+
metadata = _read_metadata(root)
|
|
673
|
+
if metadata is None:
|
|
674
|
+
return
|
|
675
|
+
if not _pid_alive(metadata.pid):
|
|
676
|
+
_remove_metadata_if_owned(metadata)
|
|
677
|
+
return
|
|
678
|
+
try:
|
|
679
|
+
_client_exchange(metadata, {"action": "stop"})
|
|
680
|
+
except CacheClientError:
|
|
681
|
+
pass
|
|
682
|
+
deadline = time.monotonic() + 3
|
|
683
|
+
stopped = False
|
|
684
|
+
while _pid_alive(metadata.pid) and time.monotonic() < deadline:
|
|
685
|
+
with contextlib.suppress(ChildProcessError):
|
|
686
|
+
if os.waitpid(metadata.pid, os.WNOHANG)[0] == metadata.pid:
|
|
687
|
+
stopped = True
|
|
688
|
+
break
|
|
689
|
+
time.sleep(0.05)
|
|
690
|
+
if not stopped and _pid_alive(metadata.pid):
|
|
691
|
+
raise CacheClientError("stop_failed", "Cache daemon did not stop.")
|
|
692
|
+
_remove_metadata_if_owned(metadata)
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def main(argv: list[str] | None = None) -> int:
|
|
696
|
+
parser = argparse.ArgumentParser()
|
|
697
|
+
command = parser.add_subparsers(dest="command", required=True)
|
|
698
|
+
serve = command.add_parser("serve")
|
|
699
|
+
serve.add_argument("--root", required=True)
|
|
700
|
+
serve.add_argument("--project-file", default="")
|
|
701
|
+
serve.add_argument("--max-memory", type=int, default=DEFAULT_MAX_MEMORY_BYTES)
|
|
702
|
+
serve.add_argument("--idle-timeout", type=int, default=DEFAULT_IDLE_TIMEOUT)
|
|
703
|
+
args = parser.parse_args(argv)
|
|
704
|
+
if args.command == "serve":
|
|
705
|
+
run_cache_daemon(args.root, project_file=args.project_file, max_memory_bytes=args.max_memory, idle_timeout=args.idle_timeout)
|
|
706
|
+
return 0
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
if __name__ == "__main__":
|
|
710
|
+
raise SystemExit(main())
|
delphi_lsp/agent_cli.py
CHANGED
|
@@ -8,8 +8,18 @@ from pathlib import Path
|
|
|
8
8
|
from typing import BinaryIO, TextIO
|
|
9
9
|
|
|
10
10
|
from .agent_context import AgentContext
|
|
11
|
+
from .agent_cache import (
|
|
12
|
+
CacheClientError,
|
|
13
|
+
DEFAULT_IDLE_TIMEOUT,
|
|
14
|
+
DEFAULT_MAX_MEMORY_BYTES,
|
|
15
|
+
parse_memory_size,
|
|
16
|
+
query_cache,
|
|
17
|
+
run_cache_daemon,
|
|
18
|
+
start_cache,
|
|
19
|
+
stop_cache,
|
|
20
|
+
)
|
|
11
21
|
from .agent_layers import build_codebase_index, layer_payload, render_layer
|
|
12
|
-
from .agent_protocol import AgentProtocolError
|
|
22
|
+
from .agent_protocol import AgentProtocolError, SUPPORTED_ACTIONS, SUPPORTED_DETAILS, SUPPORTED_RELATIONS
|
|
13
23
|
from .agent_templates import install_opencode_support, install_skill
|
|
14
24
|
|
|
15
25
|
|
|
@@ -87,20 +97,58 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
87
97
|
worker.add_argument("--project-file", type=Path)
|
|
88
98
|
worker.set_defaults(func=_worker)
|
|
89
99
|
|
|
100
|
+
cache = subcommands.add_parser("cache", help="Manage the shared Protocol v2 cache daemon.")
|
|
101
|
+
cache_commands = cache.add_subparsers(dest="cache_command", required=True)
|
|
102
|
+
cache_start = cache_commands.add_parser("start", help="Start the cache daemon if needed.")
|
|
103
|
+
_add_cache_start_arguments(cache_start)
|
|
104
|
+
cache_start.set_defaults(func=_cache_start)
|
|
105
|
+
cache_status_parser = cache_commands.add_parser("status", help="Show cache daemon status.")
|
|
106
|
+
cache_status_parser.add_argument("--root", type=Path, default=Path("."))
|
|
107
|
+
cache_status_parser.add_argument("--format", choices=["text", "json"], default="text")
|
|
108
|
+
cache_status_parser.set_defaults(func=_cache_status)
|
|
109
|
+
cache_stop = cache_commands.add_parser("stop", help="Stop the cache daemon if it is running.")
|
|
110
|
+
cache_stop.add_argument("--root", type=Path, default=Path("."))
|
|
111
|
+
cache_stop.set_defaults(func=_cache_stop)
|
|
112
|
+
cache_serve = cache_commands.add_parser("serve", help=argparse.SUPPRESS)
|
|
113
|
+
cache_serve.add_argument("--root", type=Path, required=True)
|
|
114
|
+
cache_serve.add_argument("--project-file", type=Path)
|
|
115
|
+
cache_serve.add_argument("--max-memory", type=parse_memory_size, required=True)
|
|
116
|
+
cache_serve.add_argument("--idle-timeout", type=int, required=True)
|
|
117
|
+
cache_serve.set_defaults(func=_cache_serve)
|
|
118
|
+
|
|
119
|
+
query = subcommands.add_parser("query", help="Send an ergonomic request to a running cache daemon.")
|
|
120
|
+
query.add_argument("--root", type=Path, default=Path("."))
|
|
121
|
+
query.add_argument("action", choices=SUPPORTED_ACTIONS)
|
|
122
|
+
query.add_argument("value", nargs="?", default="")
|
|
123
|
+
query.add_argument("--project-id", default="")
|
|
124
|
+
query.add_argument("--detail", choices=SUPPORTED_DETAILS, default="summary")
|
|
125
|
+
query.add_argument("--relation", choices=SUPPORTED_RELATIONS)
|
|
126
|
+
query.add_argument("--cursor", default="")
|
|
127
|
+
query.add_argument("--max-items", type=int, default=12)
|
|
128
|
+
query.add_argument("--max-chars", type=int, default=12000)
|
|
129
|
+
query.set_defaults(func=_query)
|
|
130
|
+
|
|
90
131
|
return parser
|
|
91
132
|
|
|
92
133
|
|
|
134
|
+
def _add_cache_start_arguments(parser: argparse.ArgumentParser) -> None:
|
|
135
|
+
parser.add_argument("--root", type=Path, default=Path("."))
|
|
136
|
+
parser.add_argument("--project-file", type=Path)
|
|
137
|
+
parser.add_argument("--max-memory", type=parse_memory_size, default=DEFAULT_MAX_MEMORY_BYTES)
|
|
138
|
+
parser.add_argument("--idle-timeout", type=int, default=DEFAULT_IDLE_TIMEOUT)
|
|
139
|
+
|
|
140
|
+
|
|
93
141
|
def main(argv: list[str] | None = None) -> int:
|
|
94
142
|
parser = build_parser()
|
|
95
143
|
args = parser.parse_args(argv)
|
|
96
144
|
try:
|
|
97
|
-
args.func(args)
|
|
145
|
+
result = args.func(args)
|
|
98
146
|
if not getattr(sys.stdout, "closed", False):
|
|
99
147
|
sys.stdout.flush()
|
|
100
148
|
except BrokenPipeError:
|
|
101
149
|
_discard_broken_stdout()
|
|
102
150
|
os._exit(1)
|
|
103
|
-
return 0
|
|
151
|
+
return result if isinstance(result, int) else 0
|
|
104
152
|
|
|
105
153
|
|
|
106
154
|
def _discard_broken_stdout() -> None:
|
|
@@ -169,6 +217,100 @@ def _worker(args: argparse.Namespace) -> None:
|
|
|
169
217
|
raise BrokenPipeError from error
|
|
170
218
|
|
|
171
219
|
|
|
220
|
+
def _write_json(payload: object) -> None:
|
|
221
|
+
sys.stdout.write(json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _write_warning(warning: str) -> None:
|
|
225
|
+
if warning:
|
|
226
|
+
sys.stderr.write(warning + "\n")
|
|
227
|
+
sys.stderr.flush()
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _cache_error(error: CacheClientError) -> int:
|
|
231
|
+
sys.stderr.write(f"cache_error:{error.code}: {error.message}\n")
|
|
232
|
+
sys.stderr.flush()
|
|
233
|
+
return 1
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _cache_start(args: argparse.Namespace) -> int:
|
|
237
|
+
try:
|
|
238
|
+
start_cache(args.root, project_file=args.project_file, max_memory_bytes=args.max_memory, idle_timeout=args.idle_timeout)
|
|
239
|
+
response = query_cache(args.root, {"action": "status"})
|
|
240
|
+
except CacheClientError as error:
|
|
241
|
+
return _cache_error(error)
|
|
242
|
+
_write_json(response.payload)
|
|
243
|
+
_write_warning(response.warning)
|
|
244
|
+
return 0
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _cache_status(args: argparse.Namespace) -> int:
|
|
248
|
+
try:
|
|
249
|
+
response = query_cache(args.root, {"action": "status"})
|
|
250
|
+
except CacheClientError as error:
|
|
251
|
+
return _cache_error(error)
|
|
252
|
+
if args.format == "json":
|
|
253
|
+
_write_json(response.payload)
|
|
254
|
+
else:
|
|
255
|
+
status = response.payload
|
|
256
|
+
sys.stdout.write(
|
|
257
|
+
f"running pid={status['pid']} state={status['cache_state']} "
|
|
258
|
+
f"memory={status['current_utilization_percent']:.1f}%\n"
|
|
259
|
+
)
|
|
260
|
+
_write_warning(response.warning)
|
|
261
|
+
return 0
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _cache_stop(args: argparse.Namespace) -> int:
|
|
265
|
+
try:
|
|
266
|
+
response = query_cache(args.root, {"action": "status"})
|
|
267
|
+
except CacheClientError as error:
|
|
268
|
+
if error.code in {"unavailable", "cache_not_running"}:
|
|
269
|
+
try:
|
|
270
|
+
stop_cache(args.root)
|
|
271
|
+
except CacheClientError as cleanup_error:
|
|
272
|
+
return _cache_error(cleanup_error)
|
|
273
|
+
_write_json({"stopped": False})
|
|
274
|
+
return 0
|
|
275
|
+
return _cache_error(error)
|
|
276
|
+
_write_warning(response.warning)
|
|
277
|
+
try:
|
|
278
|
+
stop_cache(args.root)
|
|
279
|
+
except CacheClientError as error:
|
|
280
|
+
return _cache_error(error)
|
|
281
|
+
_write_json({"stopped": True})
|
|
282
|
+
return 0
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _cache_serve(args: argparse.Namespace) -> int:
|
|
286
|
+
run_cache_daemon(args.root, project_file=str(args.project_file or ""), max_memory_bytes=args.max_memory, idle_timeout=args.idle_timeout)
|
|
287
|
+
return 0
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _query(args: argparse.Namespace) -> int:
|
|
291
|
+
request: dict[str, object] = {"action": args.action}
|
|
292
|
+
if args.value:
|
|
293
|
+
if args.action in {"find", "metrics"}:
|
|
294
|
+
request["query"] = args.value
|
|
295
|
+
elif args.action in {"focus", "inspect", "trace"}:
|
|
296
|
+
request["target_id"] = args.value
|
|
297
|
+
else:
|
|
298
|
+
sys.stderr.write(f"cache_error:invalid_request: {args.action} does not accept a value.\n")
|
|
299
|
+
sys.stderr.flush()
|
|
300
|
+
return 1
|
|
301
|
+
for argument, field in (("project_id", "project_id"), ("detail", "detail"), ("relation", "relation"), ("cursor", "cursor"), ("max_items", "max_items"), ("max_chars", "max_chars")):
|
|
302
|
+
value = getattr(args, argument)
|
|
303
|
+
if value is not None:
|
|
304
|
+
request[field] = value
|
|
305
|
+
try:
|
|
306
|
+
response = query_cache(args.root, request)
|
|
307
|
+
except CacheClientError as error:
|
|
308
|
+
return _cache_error(error)
|
|
309
|
+
_write_json(response.payload)
|
|
310
|
+
_write_warning(response.warning)
|
|
311
|
+
return 0
|
|
312
|
+
|
|
313
|
+
|
|
172
314
|
def _serve_worker(context: AgentContext, input_stream: BinaryIO, output_stream: BinaryIO, error_stream: TextIO) -> None:
|
|
173
315
|
discarding_oversize_record = False
|
|
174
316
|
while True:
|
delphi_lsp/agent_context.py
CHANGED
|
@@ -261,6 +261,28 @@ class AgentContext:
|
|
|
261
261
|
def workspace(self) -> AgentWorkspace:
|
|
262
262
|
return self._workspace
|
|
263
263
|
|
|
264
|
+
@property
|
|
265
|
+
def navigation_cache_is_warm(self) -> bool:
|
|
266
|
+
return self._registry is not None
|
|
267
|
+
|
|
268
|
+
def cache_roots(self) -> tuple[object, ...]:
|
|
269
|
+
return (
|
|
270
|
+
*self._workspace.cache_roots(),
|
|
271
|
+
self._registry,
|
|
272
|
+
self._relation_index,
|
|
273
|
+
self._metrics,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def evict_auxiliary_caches(self) -> None:
|
|
277
|
+
self._relation_index = None
|
|
278
|
+
self._metrics = None
|
|
279
|
+
self._metrics_revision = ""
|
|
280
|
+
|
|
281
|
+
def evict_navigation_caches(self) -> None:
|
|
282
|
+
self.evict_auxiliary_caches()
|
|
283
|
+
self._registry = None
|
|
284
|
+
self._workspace.evict_recomputable_caches()
|
|
285
|
+
|
|
264
286
|
def handle(self, request: AgentRequest | Mapping[str, object]) -> AgentResponse:
|
|
265
287
|
parsed = _validated_request(request)
|
|
266
288
|
revision = self._refresh_workspace(parsed.project_id)
|
delphi_lsp/agent_relations.py
CHANGED
delphi_lsp/agent_workspace.py
CHANGED
|
@@ -176,6 +176,13 @@ class AgentWorkspace:
|
|
|
176
176
|
def active_project_id(self) -> str:
|
|
177
177
|
return self._active_project_id
|
|
178
178
|
|
|
179
|
+
def cache_roots(self) -> tuple[object, ...]:
|
|
180
|
+
return (self._project_cache, self._active_result)
|
|
181
|
+
|
|
182
|
+
def evict_recomputable_caches(self) -> None:
|
|
183
|
+
self._project_cache.clear()
|
|
184
|
+
self._active_result = None
|
|
185
|
+
|
|
179
186
|
@property
|
|
180
187
|
def focus(self) -> Focus:
|
|
181
188
|
return self._focus
|
delphi_lsp/binary.py
CHANGED
delphi_lsp/lsp_server.py
CHANGED
delphi_lsp/semantic.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-delphi-lsp
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.5
|
|
4
4
|
Summary: Python Delphi/Object Pascal parser, semantic indexer, and language server.
|
|
5
5
|
Author: Dark Light
|
|
6
6
|
License-Expression: MPL-2.0
|
|
@@ -41,7 +41,7 @@ Dynamic: license-file
|
|
|
41
41
|
|
|
42
42
|
`python-delphi-lsp` parses Delphi/Object Pascal, builds semantic and project
|
|
43
43
|
indexes, serves LSP, and provides bounded codebase navigation for agents.
|
|
44
|
-
Version 2.0.
|
|
44
|
+
Version 2.0.5 is authored by Dark Light and supports Windows, macOS, and Linux.
|
|
45
45
|
|
|
46
46
|
## Install and quick start
|
|
47
47
|
|
|
@@ -185,15 +185,67 @@ problems; paths are not guessed.
|
|
|
185
185
|
`delphi-lsp-agent` has these subcommands and options:
|
|
186
186
|
|
|
187
187
|
```text
|
|
188
|
+
delphi-lsp-agent cache start --root PATH [--project-file FILE] [--max-memory 512M]
|
|
189
|
+
[--idle-timeout 1800]
|
|
190
|
+
delphi-lsp-agent cache status --root PATH [--format text|json]
|
|
191
|
+
delphi-lsp-agent cache stop --root PATH
|
|
188
192
|
delphi-lsp-agent view --root PATH [--project-file FILE] --layer LAYER
|
|
189
193
|
[--query TEXT] [--format markdown|json] [--deep-projects]
|
|
190
194
|
delphi-lsp-agent index --root PATH [--project-file FILE] [--out FILE]
|
|
195
|
+
delphi-lsp-agent query --root PATH ACTION [VALUE]
|
|
196
|
+
[--project-id FILE] [--detail summary|declaration|members|context|body|implementations]
|
|
197
|
+
[--relation references|callers|callees|uses|used_by|inherits|implements]
|
|
198
|
+
[--cursor TEXT] [--max-items INT] [--max-chars INT]
|
|
191
199
|
delphi-lsp-agent skill install [--target PATH] [--force]
|
|
192
200
|
delphi-lsp-agent opencode install [--target PATH] [--python PYTHON]
|
|
193
201
|
[--force] [--write-agent|--write-config]
|
|
194
202
|
delphi-lsp-agent worker --root PATH [--project-file FILE]
|
|
195
203
|
```
|
|
196
204
|
|
|
205
|
+
The `cache` commands manage one daemon per canonical root. Use these:
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
delphi-lsp-agent cache start --root PATH
|
|
209
|
+
delphi-lsp-agent cache status --root PATH
|
|
210
|
+
delphi-lsp-agent cache stop --root PATH
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
`cache start` outputs cache lifecycle JSON; runtime warnings are still on stderr.
|
|
214
|
+
`cache status --format json` outputs status JSON to stdout and the same warning stream on stderr.
|
|
215
|
+
`cache stop` outputs stop status JSON and may include warnings on stderr.
|
|
216
|
+
`query` outputs Protocol v2 JSON responses and writes warnings to stderr.
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
delphi-lsp-agent query --root PATH find TCustomer
|
|
220
|
+
delphi-lsp-agent query --root PATH focus TARGET_ID
|
|
221
|
+
delphi-lsp-agent query --root PATH inspect
|
|
222
|
+
delphi-lsp-agent query --root PATH trace TARGET_ID --relation callers
|
|
223
|
+
delphi-lsp-agent query --root PATH metrics
|
|
224
|
+
delphi-lsp-agent query --root PATH metrics UNIT_QUERY
|
|
225
|
+
delphi-lsp-agent cache status --root PATH --format json
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
`inspect` uses the currently focused target, so call `focus TARGET_ID` before
|
|
229
|
+
`inspect` unless a previous request already selected it.
|
|
230
|
+
|
|
231
|
+
The cache daemon prewarms the navigation cache at startup so first `find` requests are
|
|
232
|
+
fast. The cache retained-cache budget is `512 MiB` by default and tracks retained
|
|
233
|
+
cache usage only, not a hard RSS/parse peak. Warnings are emitted on stderr at or
|
|
234
|
+
above 80 percent.
|
|
235
|
+
|
|
236
|
+
Eviction is ordered: auxiliary caches are evicted first, navigation caches second.
|
|
237
|
+
If compaction removes navigable data, the daemon rebuilds the navigation state on demand
|
|
238
|
+
while preserving focus state for the next request.
|
|
239
|
+
|
|
240
|
+
The daemon tracks a 30-minute idle timeout; idle state shows in JSON status (`cache status`).
|
|
241
|
+
`source revision` changes on source edits and invalidate reused request caches.
|
|
242
|
+
Workspace state appears in status as `requests`, `warm_hits`, `rebuilds`, `invalidations`,
|
|
243
|
+
`evictions`, and `cache_state`.
|
|
244
|
+
|
|
245
|
+
Metadata is stored in `.delphi-lsp/agent-cache/daemon.json` with owner-only token and
|
|
246
|
+
permissions (`daemon.json` mode 600 and parent 700). Do not copy or share this token
|
|
247
|
+
outside the root workspace.
|
|
248
|
+
|
|
197
249
|
`view --layer` accepts `overview`, `projects`, `units`, `unit`,
|
|
198
250
|
`symbols`, `symbol`, `implementation`, `references`, `problems`, and
|
|
199
251
|
`metrics`. For example, `delphi-lsp-agent view --layer metrics --format json`
|
|
@@ -227,7 +279,7 @@ Focus preserves the selected project, unit, or target. Cursors bind a workspace
|
|
|
227
279
|
revision and request fingerprint, so source changes and cross-target or
|
|
228
280
|
cross-detail reuse invalidate them. `max_items` and `max_chars` bound each
|
|
229
281
|
response. A `sound_partial` relation is sound but incomplete: unresolved and
|
|
230
|
-
ambiguous relations are never fabricated.
|
|
282
|
+
ambiguous relations are never fabricated. Unsupported relations are rejected.
|
|
231
283
|
|
|
232
284
|
For every source size the navigator builds an outline first, loads source detail
|
|
233
285
|
lazily for a selected target, and returns only selected fragments. Typed source
|
|
@@ -262,6 +314,11 @@ The plugin maintains one worker per session/root, reusing focus and indexes.
|
|
|
262
314
|
During compaction it restores the focus and summary into the new context.
|
|
263
315
|
Transport failure, session deletion, and plugin disposal clean up the worker.
|
|
264
316
|
|
|
317
|
+
OpenCode history: 1.1.0 and 1.1.1 used a spawned view per call model.
|
|
318
|
+
Persistent session/root worker support first shipped in 2.0.0.
|
|
319
|
+
This is the same persistent session/root worker boundary.
|
|
320
|
+
The OpenCode worker stays separate from CLI daemon, and current plugin behavior is unchanged.
|
|
321
|
+
|
|
265
322
|
A generated OpenCode agent starts with this Markdown frontmatter:
|
|
266
323
|
|
|
267
324
|
```markdown
|
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
delphi_lsp/__init__.py,sha256=mq3tF0NrXDgbkJXDuf5HhW6sOnmP7DQxs3zl8fGoAmo,3867
|
|
2
|
-
delphi_lsp/_version.py,sha256=
|
|
3
|
-
delphi_lsp/
|
|
4
|
-
delphi_lsp/
|
|
2
|
+
delphi_lsp/_version.py,sha256=xEb7Z4b8xalXXExBg42XPAhbJKniHzcsEPjp-6S3ppg,22
|
|
3
|
+
delphi_lsp/agent_cache.py,sha256=wWPdAnKtenNyjLXu8QDG5QO0GPo8MdR8cIltfaL8g3A,28700
|
|
4
|
+
delphi_lsp/agent_cli.py,sha256=tr-MmWRPkevbtNKSnmcPJghTqD7zWJNPBVRvFicmb4M,15169
|
|
5
|
+
delphi_lsp/agent_context.py,sha256=7f4J6oqs5hRlIqiTLy-OYZhqIJPAG73G-xm9BJq_MFw,78346
|
|
5
6
|
delphi_lsp/agent_layers.py,sha256=ngFn-zH2Jv6CBjOhd3UkoyVgsrdXW0BChnQxSVmkRRc,25131
|
|
6
7
|
delphi_lsp/agent_metrics.py,sha256=6y9DrSnKitZbwRFQrofanw6k89YGqbvEacqgSKHgh2c,2695
|
|
7
8
|
delphi_lsp/agent_protocol.py,sha256=cZ1LIMhrkOb5CqiGcjSH1eZUNqtJCYySUc54h-PCyOs,12551
|
|
8
|
-
delphi_lsp/agent_relations.py,sha256=
|
|
9
|
+
delphi_lsp/agent_relations.py,sha256=aOeRYa6pw8VO0OyQZLk1wHE529k9DIDm3ENPM5ZOJiw,32879
|
|
9
10
|
delphi_lsp/agent_templates.py,sha256=LXtbAPj0Nxvp3ycZARPELEb51BFoi2vtiCe-pYHNteE,21834
|
|
10
|
-
delphi_lsp/agent_workspace.py,sha256
|
|
11
|
-
delphi_lsp/binary.py,sha256=
|
|
11
|
+
delphi_lsp/agent_workspace.py,sha256=-WgopvaWX1Imycxnjf9Ie58sHMFhnnlh7D-nhl8tmmo,23990
|
|
12
|
+
delphi_lsp/binary.py,sha256=s-c0tstGtkbjxgyiS6vteqh5D40qmVEenoECtBCsDcU,8495
|
|
12
13
|
delphi_lsp/comment_builder.py,sha256=XvFW7WTbYy7yGN3MG40gtabTvhGj1ea1MYkySDbVMDA,832
|
|
13
14
|
delphi_lsp/consts.py,sha256=qM40iwxXmvEileWHJVLMYlQSkLoLyQaqM-Zscz3VJbs,5958
|
|
14
15
|
delphi_lsp/grammar.py,sha256=QWEMSUKr0e1xlJOveZtp7K-MvhlkvDgxkD0iUKIyxKU,18577
|
|
15
16
|
delphi_lsp/lark_builder.py,sha256=c0_kFB_o3U64fwsC2BnsFibAojnqxjaZSrzASg6sTSM,119202
|
|
16
17
|
delphi_lsp/lark_tokens.py,sha256=xT_GES2z6p1IdN3EnoKE9RcUM6suu1RV3J-0FEJN2r8,4176
|
|
17
|
-
delphi_lsp/lsp_server.py,sha256=
|
|
18
|
+
delphi_lsp/lsp_server.py,sha256=hfuv8QXGQIO2s4oWYWzOjujejXNcS_ITPQ9ikw4Ua1U,71468
|
|
18
19
|
delphi_lsp/metrics.py,sha256=f70B1awypqVWZIW_SNpFHdShoS8OzDW5DWX5ftQMI0c,26166
|
|
19
20
|
delphi_lsp/nodes.py,sha256=LW8KUgNCg9MHK_2NmzrgCMMJNcBwa4C76XwKoS7kt4E,13556
|
|
20
21
|
delphi_lsp/parser.py,sha256=4hckHD_fsiiUe3oeSRJwqbY8bDWqJaYLsU10NYDIW6E,6025
|
|
@@ -22,14 +23,14 @@ delphi_lsp/preprocessor.py,sha256=aXJiabmCvWoUdrxYAA4EVVxDDQXl_IeFUezoDdMlFyU,30
|
|
|
22
23
|
delphi_lsp/progress.py,sha256=FF3lqdDKLvq3b7cOhuSc3P0FDzVnX0miD8U8_3xt5iY,535
|
|
23
24
|
delphi_lsp/project_discovery.py,sha256=UpezFlNJDaF5sW8tkG_HUxHpAULSf1GSWXJTlUF8vMM,20230
|
|
24
25
|
delphi_lsp/project_indexer.py,sha256=J_GA1GlLrmxAajS7zHeeVHbVH7MEcf8278Yd9hcIh7o,13137
|
|
25
|
-
delphi_lsp/semantic.py,sha256=
|
|
26
|
+
delphi_lsp/semantic.py,sha256=FZ9x_QsIS_u-1DlzVlYgRqH9gQZ5ySnDAD-xHwzblU8,9808
|
|
26
27
|
delphi_lsp/semantic_builder.py,sha256=ulA7xHJpltjc6cTo5wh9yTcYazuIykM-YLMXD1h-wzk,58680
|
|
27
28
|
delphi_lsp/source_reader.py,sha256=HWiw25sLVZ6s1GGpMl17uZ-o68HoxTEd3Z0__m1vsSE,520
|
|
28
29
|
delphi_lsp/workspace.py,sha256=TU__SujItRlW8hB2kuYC7ZJx1WQVOj7frYxvo7rDqeI,2137
|
|
29
30
|
delphi_lsp/writer.py,sha256=j-cnyiHFNOTFECL7Ehm-m43ye7ev7qFeCCscFMjrAOY,2503
|
|
30
|
-
python_delphi_lsp-2.0.
|
|
31
|
-
python_delphi_lsp-2.0.
|
|
32
|
-
python_delphi_lsp-2.0.
|
|
33
|
-
python_delphi_lsp-2.0.
|
|
34
|
-
python_delphi_lsp-2.0.
|
|
35
|
-
python_delphi_lsp-2.0.
|
|
31
|
+
python_delphi_lsp-2.0.5.dist-info/licenses/LICENSE,sha256=-rPda9qyJvHAhjCx3ZF-Efy07F4eAg4sFvg6ChOGPoU,16726
|
|
32
|
+
python_delphi_lsp-2.0.5.dist-info/METADATA,sha256=T3eZ2AtK8UFsL-6RaVdGxWCh00c8MA06urr2pgRLYHo,18327
|
|
33
|
+
python_delphi_lsp-2.0.5.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
34
|
+
python_delphi_lsp-2.0.5.dist-info/entry_points.txt,sha256=rh_yGH7diowge_acQcST87A-ewH1QezEsAYC8T5ineY,103
|
|
35
|
+
python_delphi_lsp-2.0.5.dist-info/top_level.txt,sha256=0Zp0sjw5Qtic3EQX9zNeegURga8NuXk6c2e9PN-3OTs,11
|
|
36
|
+
python_delphi_lsp-2.0.5.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|