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 CHANGED
@@ -1 +1 @@
1
- __version__ = "2.0.4"
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:
@@ -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)
@@ -23,7 +23,6 @@ from .semantic import (
23
23
  Symbol,
24
24
  SymbolIndex,
25
25
  SymbolKind,
26
- SymbolReference,
27
26
  TypeRef,
28
27
  )
29
28
  from .source_reader import read_source_text
@@ -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
@@ -1,6 +1,5 @@
1
1
  from __future__ import annotations
2
2
 
3
- from dataclasses import dataclass
4
3
  from enum import IntEnum
5
4
  import io
6
5
  import struct
delphi_lsp/lsp_server.py CHANGED
@@ -18,8 +18,6 @@ from .semantic import (
18
18
  SymbolKind,
19
19
  SymbolReference,
20
20
  TypeRef,
21
- NamedTypeRef,
22
- GenericInstanceTypeRef,
23
21
  UnknownTypeRef,
24
22
  Visibility,
25
23
  )
delphi_lsp/semantic.py CHANGED
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass, field
4
4
  from enum import Enum
5
- from typing import Iterable, Optional
5
+ from typing import Optional
6
6
 
7
7
 
8
8
  def normalize_name(name: str) -> str:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-delphi-lsp
3
- Version: 2.0.4
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.4 is authored by Dark Light and supports Windows, macOS, and Linux.
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=ZOg41aCgKEuObK5WhegikXektFWPJaeZ-TMDh0Ke95M,22
3
- delphi_lsp/agent_cli.py,sha256=Mma4C1Ca_Hd_9dl0mePT7uIypw6j6gDWdKu9XxIMXQQ,9226
4
- delphi_lsp/agent_context.py,sha256=BfmsCbTIWSaMNFkPuyC31yh7wkzCzBI5xt3BE8vsIa4,77715
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=pXhuCjfwmicG3VG7lYtGvKKv-mFPQ_hJ3w0eavqMoV0,32900
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=HvCWfMVv08Jlx257my4hkkXCLUNoPcy5AAnOnl_mNUU,23761
11
- delphi_lsp/binary.py,sha256=40mEpE0SBYmifwEoNHwSU7IU_D2usVyWYWHJ73aDfoo,8529
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=kxm0A-VNDkkNZKb6TSqFxur6wNcvh1m34EKIKfOr2LU,71514
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=egQ-_AjmnAATvL1hRUfjd4FDapFxdwi5rt8QExb0POc,9818
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.4.dist-info/licenses/LICENSE,sha256=-rPda9qyJvHAhjCx3ZF-Efy07F4eAg4sFvg6ChOGPoU,16726
31
- python_delphi_lsp-2.0.4.dist-info/METADATA,sha256=y1GTsjlMI2yUCd5Z9ZmGOHiiuRxQlf2IIQEmM5G1TyY,15390
32
- python_delphi_lsp-2.0.4.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
33
- python_delphi_lsp-2.0.4.dist-info/entry_points.txt,sha256=rh_yGH7diowge_acQcST87A-ewH1QezEsAYC8T5ineY,103
34
- python_delphi_lsp-2.0.4.dist-info/top_level.txt,sha256=0Zp0sjw5Qtic3EQX9zNeegURga8NuXk6c2e9PN-3OTs,11
35
- python_delphi_lsp-2.0.4.dist-info/RECORD,,
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,,