python-delphi-lsp 2.3.0__py3-none-any.whl → 2.3.2__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.3.0"
1
+ __version__ = "2.3.2"
delphi_lsp/agent_cache.py CHANGED
@@ -45,6 +45,88 @@ _WATCHED_SUFFIXES = frozenset(
45
45
  )
46
46
 
47
47
 
48
+ def current_process_rss_bytes() -> int:
49
+ """Return the current resident set size, or zero when unavailable."""
50
+ try:
51
+ if sys.platform == "darwin":
52
+ return _darwin_process_rss_bytes()
53
+ if sys.platform.startswith("linux"):
54
+ statm = Path("/proc/self/statm").read_text(encoding="ascii").split()
55
+ return int(statm[1]) * int(os.sysconf("SC_PAGE_SIZE"))
56
+ if os.name == "nt":
57
+ return _windows_process_rss_bytes()
58
+ except Exception:
59
+ pass
60
+ return 0
61
+
62
+
63
+ def _darwin_process_rss_bytes() -> int:
64
+ import ctypes
65
+
66
+ class TimeValue(ctypes.Structure):
67
+ _fields_ = [("seconds", ctypes.c_int), ("microseconds", ctypes.c_int)]
68
+
69
+ class MachTaskBasicInfo(ctypes.Structure):
70
+ _fields_ = [
71
+ ("virtual_size", ctypes.c_uint64),
72
+ ("resident_size", ctypes.c_uint64),
73
+ ("resident_size_max", ctypes.c_uint64),
74
+ ("user_time", TimeValue),
75
+ ("system_time", TimeValue),
76
+ ("policy", ctypes.c_int),
77
+ ("suspend_count", ctypes.c_int),
78
+ ]
79
+
80
+ library = ctypes.CDLL(None)
81
+ library.mach_task_self.restype = ctypes.c_uint
82
+ library.task_info.argtypes = (
83
+ ctypes.c_uint,
84
+ ctypes.c_int,
85
+ ctypes.c_void_p,
86
+ ctypes.POINTER(ctypes.c_uint),
87
+ )
88
+ library.task_info.restype = ctypes.c_int
89
+ info = MachTaskBasicInfo()
90
+ count = ctypes.c_uint(ctypes.sizeof(info) // ctypes.sizeof(ctypes.c_int))
91
+ result = library.task_info(
92
+ library.mach_task_self(),
93
+ 20,
94
+ ctypes.byref(info),
95
+ ctypes.byref(count),
96
+ )
97
+ return int(info.resident_size) if result == 0 else 0
98
+
99
+
100
+ def _windows_process_rss_bytes() -> int:
101
+ import ctypes
102
+ from ctypes import wintypes
103
+
104
+ class ProcessMemoryCounters(ctypes.Structure):
105
+ _fields_ = [
106
+ ("cb", wintypes.DWORD),
107
+ ("PageFaultCount", wintypes.DWORD),
108
+ ("PeakWorkingSetSize", ctypes.c_size_t),
109
+ ("WorkingSetSize", ctypes.c_size_t),
110
+ ("QuotaPeakPagedPoolUsage", ctypes.c_size_t),
111
+ ("QuotaPagedPoolUsage", ctypes.c_size_t),
112
+ ("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t),
113
+ ("QuotaNonPagedPoolUsage", ctypes.c_size_t),
114
+ ("PagefileUsage", ctypes.c_size_t),
115
+ ("PeakPagefileUsage", ctypes.c_size_t),
116
+ ]
117
+
118
+ counters = ProcessMemoryCounters()
119
+ counters.cb = ctypes.sizeof(counters)
120
+ process = ctypes.windll.kernel32.GetCurrentProcess()
121
+ if not ctypes.windll.psapi.GetProcessMemoryInfo(
122
+ process,
123
+ ctypes.byref(counters),
124
+ counters.cb,
125
+ ):
126
+ return 0
127
+ return int(counters.WorkingSetSize)
128
+
129
+
48
130
  def estimate_deep_size(value: object) -> int:
49
131
  """Estimate the memory retained by an owned object graph.
50
132
 
@@ -292,6 +374,11 @@ def _safe_navigation_cache_path(root: str | Path, *, create: bool = False) -> Pa
292
374
  return result
293
375
 
294
376
 
377
+ def navigation_cache_path(root: str | Path, *, create: bool = False) -> Path:
378
+ """Return the validated persistent navigation-cache directory."""
379
+ return _safe_navigation_cache_path(root, create=create)
380
+
381
+
295
382
  def _metadata_mapping(metadata: CacheMetadata) -> dict[str, object]:
296
383
  return {field.name: getattr(metadata, field.name) for field in fields(metadata)}
297
384
 
@@ -552,16 +639,32 @@ def _read_startup_tail(diagnostics: object, *, max_bytes: int = _STARTUP_DIAGNOS
552
639
  return b""
553
640
 
554
641
 
642
+ def _daemon_process_options() -> dict[str, object]:
643
+ options: dict[str, object] = {
644
+ "stdin": subprocess.DEVNULL,
645
+ "stdout": subprocess.DEVNULL,
646
+ }
647
+ if os.name == "nt":
648
+ options["creationflags"] = (
649
+ subprocess.CREATE_NO_WINDOW
650
+ | subprocess.CREATE_NEW_PROCESS_GROUP
651
+ )
652
+ else:
653
+ options["start_new_session"] = True
654
+ return options
655
+
656
+
555
657
  class _CacheService:
556
658
  def __init__(self, metadata: CacheMetadata) -> None:
557
659
  self.metadata = metadata
660
+ self._rss_baseline_bytes = current_process_rss_bytes()
558
661
  self.context = AgentContext.open(
559
662
  metadata.root,
560
663
  metadata.project_file or None,
561
664
  workers=metadata.workers,
562
665
  worker_memory_budget_bytes=metadata.max_memory_bytes,
563
666
  revision_check_interval_seconds=_CACHE_REVISION_CHECK_INTERVAL_SECONDS,
564
- navigation_cache_dir=_safe_navigation_cache_path(
667
+ navigation_cache_dir=navigation_cache_path(
565
668
  metadata.root,
566
669
  create=True,
567
670
  ),
@@ -577,6 +680,15 @@ class _CacheService:
577
680
  self.prewarm_seconds = 0.0
578
681
  self.shutdown = threading.Event()
579
682
 
683
+ def _measure_retained_bytes(self) -> int:
684
+ process_rss = current_process_rss_bytes()
685
+ process_growth = (
686
+ max(0, process_rss - self._rss_baseline_bytes)
687
+ if process_rss > 0 and self._rss_baseline_bytes > 0
688
+ else 0
689
+ )
690
+ return max(self.context.estimated_cache_bytes, process_growth)
691
+
580
692
  def prewarm(self) -> None:
581
693
  started = time.monotonic()
582
694
  try:
@@ -587,7 +699,7 @@ class _CacheService:
587
699
  raise
588
700
  self.cache_state = "ready"
589
701
  self.last_budget = self.budget.enforce(
590
- measure=lambda: self.context.estimated_cache_bytes,
702
+ measure=self._measure_retained_bytes,
591
703
  evict_auxiliary=self.context.evict_auxiliary_caches,
592
704
  evict_navigation=self.context.evict_navigation_caches,
593
705
  )
@@ -628,7 +740,7 @@ class _CacheService:
628
740
  if before != after:
629
741
  self.stats.invalidations += 1
630
742
  self.last_budget = self.budget.enforce(
631
- measure=lambda: self.context.estimated_cache_bytes,
743
+ measure=self._measure_retained_bytes,
632
744
  evict_auxiliary=self.context.evict_auxiliary_caches,
633
745
  evict_navigation=self.context.evict_navigation_caches,
634
746
  )
@@ -675,21 +787,34 @@ def _watch_filter(_change: object, path: str) -> bool:
675
787
  return Path(path).suffix.casefold() in _WATCHED_SUFFIXES
676
788
 
677
789
 
678
- def _watch_workspace(service: _CacheService) -> None:
790
+ def watch_workspace_changes(
791
+ root: str | Path,
792
+ *,
793
+ stop_event: threading.Event,
794
+ on_change: Callable[[], None],
795
+ ) -> None:
679
796
  try:
680
797
  for changes in watch(
681
- service.metadata.root,
798
+ root,
682
799
  watch_filter=_watch_filter,
683
- stop_event=service.shutdown,
800
+ stop_event=stop_event,
684
801
  debounce=50,
685
802
  step=20,
686
803
  recursive=True,
687
804
  raise_interrupt=False,
688
805
  ):
689
806
  if changes:
690
- service.context.invalidate_revision_cache()
807
+ on_change()
691
808
  except (OSError, RuntimeError):
692
- service.context.invalidate_revision_cache()
809
+ on_change()
810
+
811
+
812
+ def _watch_workspace(service: _CacheService) -> None:
813
+ watch_workspace_changes(
814
+ service.metadata.root,
815
+ stop_event=service.shutdown,
816
+ on_change=service.context.invalidate_revision_cache,
817
+ )
693
818
 
694
819
 
695
820
  def _serve_connection(connection: socket.socket, service: _CacheService) -> None:
@@ -811,11 +936,7 @@ def _start_cache_unlocked(
811
936
  ]
812
937
  if project:
813
938
  command.extend(("--project-file", project))
814
- options: dict[str, object] = {"stdin": subprocess.DEVNULL, "stdout": subprocess.DEVNULL}
815
- if os.name == "nt":
816
- options["creationflags"] = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
817
- else:
818
- options["start_new_session"] = True
939
+ options = _daemon_process_options()
819
940
  with tempfile.TemporaryFile() as diagnostics:
820
941
  options["stderr"] = diagnostics
821
942
  process = subprocess.Popen(command, **options)
@@ -867,6 +988,8 @@ def start_cache(
867
988
  ) -> CacheMetadata:
868
989
  if type(workers) is not int or not 0 <= workers <= 32:
869
990
  raise ValueError("workers must be auto or an integer from 1 through 32.")
991
+ if type(idle_timeout) is not int or idle_timeout <= 0:
992
+ raise ValueError("idle_timeout must be greater than zero.")
870
993
  if not math.isfinite(startup_timeout) or startup_timeout <= 0:
871
994
  raise ValueError("startup_timeout must be greater than zero.")
872
995
  with _start_lock(root, startup_timeout):
@@ -930,7 +1053,7 @@ def main(argv: list[str] | None = None) -> int:
930
1053
  serve.add_argument("--project-file", default="")
931
1054
  serve.add_argument("--max-memory", type=int, default=DEFAULT_MAX_MEMORY_BYTES)
932
1055
  serve.add_argument("--workers", type=int, default=0)
933
- serve.add_argument("--idle-timeout", type=int, default=DEFAULT_IDLE_TIMEOUT)
1056
+ serve.add_argument("--idle-timeout", type=_positive_integer, default=DEFAULT_IDLE_TIMEOUT)
934
1057
  args = parser.parse_args(argv)
935
1058
  if args.command == "serve":
936
1059
  run_cache_daemon(
@@ -943,5 +1066,12 @@ def main(argv: list[str] | None = None) -> int:
943
1066
  return 0
944
1067
 
945
1068
 
1069
+ def _positive_integer(value: str) -> int:
1070
+ parsed = int(value)
1071
+ if parsed <= 0:
1072
+ raise argparse.ArgumentTypeError("value must be greater than zero")
1073
+ return parsed
1074
+
1075
+
946
1076
  if __name__ == "__main__":
947
1077
  raise SystemExit(main())
delphi_lsp/agent_cli.py CHANGED
@@ -5,6 +5,7 @@ import json
5
5
  import math
6
6
  import os
7
7
  import sys
8
+ import threading
8
9
  from pathlib import Path
9
10
  from typing import BinaryIO, TextIO
10
11
 
@@ -15,15 +16,17 @@ from .agent_cache import (
15
16
  DEFAULT_MAX_MEMORY_BYTES,
16
17
  DEFAULT_STARTUP_TIMEOUT,
17
18
  parse_memory_size,
19
+ navigation_cache_path,
18
20
  query_cache,
19
21
  run_cache_daemon,
20
22
  start_cache,
21
23
  stop_cache,
24
+ watch_workspace_changes,
22
25
  )
23
26
  from .agent_layers import build_codebase_index, layer_payload, render_layer
24
27
  from .agent_protocol import AgentProtocolError, SUPPORTED_ACTIONS, SUPPORTED_DETAILS, SUPPORTED_RELATIONS
25
28
  from .agent_templates import install_opencode_support, install_skill
26
- from .parallel_outline import parse_worker_setting
29
+ from .parallel_outline import ParallelOutlineError, parse_worker_setting
27
30
 
28
31
 
29
32
  _MAX_WORKER_RECORD_BYTES = 1024 * 1024
@@ -32,6 +35,14 @@ _INVALID_ENCODING_MESSAGE = "Invalid UTF-8 request."
32
35
  _REQUEST_TOO_LARGE_MESSAGE = "Request exceeds the 1 MiB limit."
33
36
  _INTERNAL_ERROR_MESSAGE = "Internal request error."
34
37
  _SOURCE_UNAVAILABLE_MESSAGE = "Selected source is unavailable."
38
+ _WORKER_REVISION_CHECK_INTERVAL_SECONDS = 30.0
39
+
40
+
41
+ class _CliError(RuntimeError):
42
+ def __init__(self, code: str, message: str) -> None:
43
+ self.code = code
44
+ self.message = message
45
+ super().__init__(message)
35
46
 
36
47
 
37
48
  def build_parser() -> argparse.ArgumentParser:
@@ -120,7 +131,7 @@ def build_parser() -> argparse.ArgumentParser:
120
131
  cache_serve.add_argument("--project-file", type=Path)
121
132
  cache_serve.add_argument("--max-memory", type=parse_memory_size, required=True)
122
133
  cache_serve.add_argument("--workers", type=parse_worker_setting, required=True)
123
- cache_serve.add_argument("--idle-timeout", type=int, required=True)
134
+ cache_serve.add_argument("--idle-timeout", type=_positive_integer, required=True)
124
135
  cache_serve.set_defaults(func=_cache_serve)
125
136
 
126
137
  query = subcommands.add_parser("query", help="Send an ergonomic request to a running cache daemon.")
@@ -131,8 +142,8 @@ def build_parser() -> argparse.ArgumentParser:
131
142
  query.add_argument("--detail", choices=SUPPORTED_DETAILS, default="summary")
132
143
  query.add_argument("--relation", choices=SUPPORTED_RELATIONS)
133
144
  query.add_argument("--cursor", default="")
134
- query.add_argument("--max-items", type=int, default=12)
135
- query.add_argument("--max-chars", type=int, default=12000)
145
+ query.add_argument("--max-items", type=_max_items, default=12)
146
+ query.add_argument("--max-chars", type=_max_chars, default=12000)
136
147
  query.set_defaults(func=_query)
137
148
 
138
149
  return parser
@@ -140,10 +151,17 @@ def build_parser() -> argparse.ArgumentParser:
140
151
 
141
152
  def _add_cache_start_arguments(parser: argparse.ArgumentParser) -> None:
142
153
  parser.add_argument("--root", type=Path, default=Path("."))
143
- parser.add_argument("--project-file", type=Path)
154
+ parser.add_argument(
155
+ "--project-file",
156
+ type=Path,
157
+ help=(
158
+ "Optionally select a .dproj, .dpr, or .dpk file, relative to "
159
+ "--root if needed."
160
+ ),
161
+ )
144
162
  parser.add_argument("--max-memory", type=parse_memory_size, default=DEFAULT_MAX_MEMORY_BYTES)
145
163
  parser.add_argument("--workers", type=parse_worker_setting, default=0)
146
- parser.add_argument("--idle-timeout", type=int, default=DEFAULT_IDLE_TIMEOUT)
164
+ parser.add_argument("--idle-timeout", type=_positive_integer, default=DEFAULT_IDLE_TIMEOUT)
147
165
  parser.add_argument("--startup-timeout", type=_positive_float, default=DEFAULT_STARTUP_TIMEOUT)
148
166
 
149
167
 
@@ -154,6 +172,27 @@ def _positive_float(value: str) -> float:
154
172
  return parsed
155
173
 
156
174
 
175
+ def _positive_integer(value: str) -> int:
176
+ parsed = int(value)
177
+ if parsed <= 0:
178
+ raise argparse.ArgumentTypeError("value must be greater than zero")
179
+ return parsed
180
+
181
+
182
+ def _max_items(value: str) -> int:
183
+ parsed = int(value)
184
+ if not 1 <= parsed <= 50:
185
+ raise argparse.ArgumentTypeError("value must be between 1 and 50")
186
+ return parsed
187
+
188
+
189
+ def _max_chars(value: str) -> int:
190
+ parsed = int(value)
191
+ if not 256 <= parsed <= 40000:
192
+ raise argparse.ArgumentTypeError("value must be between 256 and 40000")
193
+ return parsed
194
+
195
+
157
196
  def main(argv: list[str] | None = None) -> int:
158
197
  parser = build_parser()
159
198
  args = parser.parse_args(argv)
@@ -161,9 +200,23 @@ def main(argv: list[str] | None = None) -> int:
161
200
  result = args.func(args)
162
201
  if not getattr(sys.stdout, "closed", False):
163
202
  sys.stdout.flush()
203
+ except _CliError as error:
204
+ sys.stderr.write(f"cli_error:{error.code}: {error.message}\n")
205
+ sys.stderr.flush()
206
+ return 1
207
+ except CacheClientError as error:
208
+ return _cache_error(error)
209
+ except ParallelOutlineError as error:
210
+ sys.stderr.write(f"cli_error:parallel_failed: {error}\n")
211
+ sys.stderr.flush()
212
+ return 1
164
213
  except BrokenPipeError:
165
214
  _discard_broken_stdout()
166
215
  os._exit(1)
216
+ except OSError as error:
217
+ sys.stderr.write(f"cli_error:io_error: {error}\n")
218
+ sys.stderr.flush()
219
+ return 1
167
220
  return result if isinstance(result, int) else 0
168
221
 
169
222
 
@@ -189,6 +242,7 @@ def _discard_broken_stdout() -> None:
189
242
 
190
243
 
191
244
  def _view(args: argparse.Namespace) -> None:
245
+ args.root = _workspace_root(args.root)
192
246
  index = build_codebase_index(
193
247
  args.root,
194
248
  project_file=args.project_file,
@@ -199,6 +253,7 @@ def _view(args: argparse.Namespace) -> None:
199
253
 
200
254
 
201
255
  def _index(args: argparse.Namespace) -> None:
256
+ args.root = _workspace_root(args.root)
202
257
  index = build_codebase_index(
203
258
  args.root,
204
259
  project_file=args.project_file,
@@ -216,33 +271,72 @@ def _index(args: argparse.Namespace) -> None:
216
271
 
217
272
 
218
273
  def _skill_install(args: argparse.Namespace) -> None:
219
- skill_path = install_skill(args.target, force=args.force)
274
+ try:
275
+ skill_path = install_skill(args.target, force=args.force)
276
+ except FileExistsError as error:
277
+ raise _CliError("install_conflict", str(error)) from None
220
278
  print(skill_path)
221
279
 
222
280
 
223
281
  def _opencode_install(args: argparse.Namespace) -> None:
224
- skill_path, plugin_path, agent_path = install_opencode_support(
225
- args.target,
226
- python_executable=args.python,
227
- force=args.force,
228
- write_config=args.write_config,
229
- )
282
+ try:
283
+ skill_path, plugin_path, agent_path = install_opencode_support(
284
+ args.target,
285
+ python_executable=args.python,
286
+ force=args.force,
287
+ write_config=args.write_config,
288
+ )
289
+ except FileExistsError as error:
290
+ raise _CliError("install_conflict", str(error)) from None
230
291
  print(skill_path)
231
292
  print(plugin_path)
232
293
  print(agent_path)
233
294
 
234
295
 
235
296
  def _worker(args: argparse.Namespace) -> None:
236
- context = AgentContext.open(args.root, args.project_file, workers=args.workers)
297
+ args.root = _workspace_root(args.root)
298
+ context = _open_worker_context(args.root, args.project_file, args.workers)
299
+ watcher_stop = threading.Event()
300
+ watcher = threading.Thread(
301
+ target=watch_workspace_changes,
302
+ kwargs={
303
+ "root": args.root,
304
+ "stop_event": watcher_stop,
305
+ "on_change": context.invalidate_revision_cache,
306
+ },
307
+ name="delphi-worker-watcher",
308
+ daemon=True,
309
+ )
310
+ watcher.start()
237
311
  try:
238
312
  _serve_worker(context, sys.stdin.buffer, sys.stdout.buffer, sys.stderr)
239
313
  finally:
314
+ watcher_stop.set()
315
+ watcher.join(timeout=2.0)
240
316
  try:
241
317
  sys.stdout.close()
242
318
  except (BrokenPipeError, OSError) as error:
243
319
  raise BrokenPipeError from error
244
320
 
245
321
 
322
+ def _open_worker_context(
323
+ root: Path,
324
+ project_file: Path | None,
325
+ workers: int,
326
+ ) -> AgentContext:
327
+ try:
328
+ cache_dir: Path | None = navigation_cache_path(root, create=True)
329
+ except (CacheClientError, OSError):
330
+ cache_dir = None
331
+ return AgentContext.open(
332
+ root,
333
+ project_file,
334
+ workers=workers,
335
+ revision_check_interval_seconds=_WORKER_REVISION_CHECK_INTERVAL_SECONDS,
336
+ navigation_cache_dir=cache_dir,
337
+ )
338
+
339
+
246
340
  def _write_json(payload: object) -> None:
247
341
  sys.stdout.write(json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n")
248
342
 
@@ -260,6 +354,7 @@ def _cache_error(error: CacheClientError) -> int:
260
354
 
261
355
 
262
356
  def _cache_start(args: argparse.Namespace) -> int:
357
+ args.root = _workspace_root(args.root)
263
358
  try:
264
359
  start_cache(
265
360
  args.root,
@@ -278,6 +373,7 @@ def _cache_start(args: argparse.Namespace) -> int:
278
373
 
279
374
 
280
375
  def _cache_status(args: argparse.Namespace) -> int:
376
+ args.root = _workspace_root(args.root)
281
377
  try:
282
378
  response = query_cache(args.root, {"action": "status"})
283
379
  except CacheClientError as error:
@@ -295,6 +391,7 @@ def _cache_status(args: argparse.Namespace) -> int:
295
391
 
296
392
 
297
393
  def _cache_stop(args: argparse.Namespace) -> int:
394
+ args.root = _workspace_root(args.root)
298
395
  try:
299
396
  response = query_cache(args.root, {"action": "status"})
300
397
  except CacheClientError as error:
@@ -316,6 +413,7 @@ def _cache_stop(args: argparse.Namespace) -> int:
316
413
 
317
414
 
318
415
  def _cache_serve(args: argparse.Namespace) -> int:
416
+ args.root = _workspace_root(args.root)
319
417
  run_cache_daemon(
320
418
  args.root,
321
419
  project_file=str(args.project_file or ""),
@@ -327,6 +425,7 @@ def _cache_serve(args: argparse.Namespace) -> int:
327
425
 
328
426
 
329
427
  def _query(args: argparse.Namespace) -> int:
428
+ args.root = _workspace_root(args.root)
330
429
  request: dict[str, object] = {"action": args.action}
331
430
  if args.value:
332
431
  if args.action in {"find", "metrics"}:
@@ -350,6 +449,21 @@ def _query(args: argparse.Namespace) -> int:
350
449
  return 0
351
450
 
352
451
 
452
+ def _workspace_root(value: str | Path) -> Path:
453
+ root = Path(value).expanduser()
454
+ if not root.exists():
455
+ raise _CliError(
456
+ "workspace_not_found",
457
+ f"Workspace root does not exist: {root}",
458
+ )
459
+ if not root.is_dir():
460
+ raise _CliError(
461
+ "workspace_not_directory",
462
+ f"Workspace root is not a directory: {root}",
463
+ )
464
+ return root.resolve()
465
+
466
+
353
467
  def _serve_worker(context: AgentContext, input_stream: BinaryIO, output_stream: BinaryIO, error_stream: TextIO) -> None:
354
468
  discarding_oversize_record = False
355
469
  while True:
@@ -658,7 +658,7 @@ class AgentContext:
658
658
  return revision
659
659
 
660
660
  def invalidate_revision_cache(self) -> None:
661
- self._last_revision_check_at = 0.0
661
+ self._last_revision_check_at = float("-inf")
662
662
 
663
663
  def handle(self, request: AgentRequest | Mapping[str, object]) -> AgentResponse:
664
664
  parsed = _validated_request(request)
@@ -853,6 +853,7 @@ class AgentContext:
853
853
  )
854
854
  elif request.project_id:
855
855
  self._focus = Focus(project_id=self._workspace.active_project_id)
856
+ self._require_registry(revision)
856
857
  return self._response(request, revision, [self._focus.to_mapping()])
857
858
 
858
859
  def _handle_metrics(self, request: AgentRequest, revision: str) -> AgentResponse:
@@ -992,7 +993,8 @@ class AgentContext:
992
993
  if not project_id:
993
994
  raise AgentProtocolError(
994
995
  "project_required",
995
- "Select a project before querying symbols.",
996
+ "Multiple projects were found. Run 'query open', then select one "
997
+ "with 'query focus --project-id PROJECT_ID'.",
996
998
  )
997
999
  return project_id
998
1000
 
@@ -220,7 +220,7 @@ Inspect Delphi/Object Pascal only through `delphi_codebase`; never raw bash/read
220
220
 
221
221
  ## Protocol v2 workflow
222
222
 
223
- 1. Call `open`. If the requested project is not active, select it with `focus(project_id)`.
223
+ 1. Call `open`. A multi-project repository is active as one workspace by default; select a concrete project with `focus(project_id)` only when project-specific compiler context is needed.
224
224
  2. Call `find` with a narrow query, then `focus(target_id)` for the returned target.
225
225
  3. Inspect focused details in this order as needed: `summary`, `declaration`, `members`, `context`, `body`, `implementations`.
226
226
  4. Trace relations with `references`, `callers`, `callees`, `uses`, `used_by`, `inherits`, or `implements`.
@@ -112,6 +112,21 @@ class AgentWorkspace:
112
112
  )
113
113
  projects: list[AgentProject] = []
114
114
  project_paths: dict[str, Path | None] = {}
115
+ default_project_id = ""
116
+ if (
117
+ resolved_project_file is None
118
+ and len(discovery.project_files) > 1
119
+ ):
120
+ default_project_id = make_target_id("project", "", "workspace")
121
+ projects.append(
122
+ AgentProject(
123
+ project_id=default_project_id,
124
+ name="Workspace",
125
+ path=".",
126
+ kind="workspace",
127
+ )
128
+ )
129
+ project_paths[default_project_id] = None
115
130
  if discovery.project_files:
116
131
  for value in discovery.project_files:
117
132
  path = Path(value)
@@ -140,7 +155,9 @@ class AgentWorkspace:
140
155
  project_paths[project_id] = None
141
156
 
142
157
  workspace = cls(root_path, discovery, tuple(projects), project_paths)
143
- if len(projects) == 1:
158
+ if default_project_id:
159
+ workspace.select_project(default_project_id)
160
+ elif len(projects) == 1:
144
161
  workspace.select_project(projects[0].project_id)
145
162
  return workspace
146
163
 
@@ -305,7 +322,11 @@ class AgentWorkspace:
305
322
  project_path = self._project_paths[project_id]
306
323
  cached = self._project_cache.get(project_id)
307
324
  if project_path is None:
308
- discovery = self._discovery if cached is None else discover_workspace_sources(self._root)
325
+ discovery = (
326
+ self._discovery
327
+ if cached is None and self._discovery.source_files
328
+ else discover_workspace_sources(self._root)
329
+ )
309
330
  else:
310
331
  discovery = discover_delphi_project(
311
332
  self._root,
@@ -75,7 +75,11 @@ def discover_delphi_project(
75
75
  on_progress: ProgressCallback | None = None,
76
76
  ) -> DelphiProjectDiscovery:
77
77
  root_path = Path(root).expanduser().resolve()
78
- project_path = Path(project_file).expanduser().resolve() if project_file is not None else None
78
+ project_path = Path(project_file).expanduser() if project_file is not None else None
79
+ if project_path is not None:
80
+ if not project_path.is_absolute():
81
+ project_path = root_path / project_path
82
+ project_path = project_path.resolve()
79
83
  discovery = DelphiProjectDiscovery(root=str(root_path))
80
84
  _emit_progress(on_progress, "discovery", str(root_path), 0, 0, None, "project discovery started")
81
85
 
@@ -142,7 +146,21 @@ def discover_delphi_project(
142
146
  for value in defines:
143
147
  add_define(value)
144
148
 
145
- candidates = _project_candidates(root_path, project_path)
149
+ explicit_dproj: Path | None = None
150
+ if (
151
+ project_path is not None
152
+ and project_path.suffix.casefold() == ".dproj"
153
+ ):
154
+ explicit_dproj = project_path
155
+ project_path = _project_entry_from_dproj(
156
+ explicit_dproj,
157
+ discovery,
158
+ )
159
+ candidates = (
160
+ []
161
+ if explicit_dproj is not None and project_path is None
162
+ else _project_candidates(root_path, project_path)
163
+ )
146
164
  for project in candidates:
147
165
  key = str(project).casefold()
148
166
  if key not in seen_projects:
@@ -156,8 +174,13 @@ def discover_delphi_project(
156
174
  seen_search,
157
175
  discovery.search_path_origins,
158
176
  )
159
- dproj = project.with_suffix(".dproj")
160
- if dproj.exists():
177
+ dproj_candidates = [project.with_suffix(".dproj")]
178
+ if explicit_dproj is not None:
179
+ dproj_candidates.insert(0, explicit_dproj)
180
+ for dproj in dproj_candidates:
181
+ config_key = str(dproj).casefold()
182
+ if config_key in seen_configs or not dproj.exists():
183
+ continue
161
184
  _read_dproj(
162
185
  dproj,
163
186
  discovery,
@@ -169,7 +192,7 @@ def discover_delphi_project(
169
192
  discovery.include_path_origins,
170
193
  add_define,
171
194
  )
172
- seen_configs.add(str(dproj).casefold())
195
+ seen_configs.add(config_key)
173
196
  discovery.config_files.append(str(dproj))
174
197
  for cfg in (project.with_suffix(".cfg"), project.with_suffix(".dof")):
175
198
  if cfg.exists():
@@ -479,6 +502,72 @@ def _main_source_from_dproj(path: Path) -> str | None:
479
502
  return None
480
503
 
481
504
 
505
+ def _project_entry_from_dproj(
506
+ path: Path,
507
+ discovery: DelphiProjectDiscovery,
508
+ ) -> Path | None:
509
+ try:
510
+ root = ET.parse(path).getroot()
511
+ except ET.ParseError as exc:
512
+ discovery.problems.append(
513
+ DiscoveryProblem(
514
+ "cant_read_project",
515
+ f"Could not parse Delphi project file: {exc}",
516
+ str(path),
517
+ )
518
+ )
519
+ return None
520
+ except OSError as exc:
521
+ discovery.problems.append(
522
+ DiscoveryProblem(
523
+ "cant_read_project",
524
+ f"Could not read Delphi project file: {exc}",
525
+ str(path),
526
+ )
527
+ )
528
+ return None
529
+
530
+ main_source = next(
531
+ (
532
+ (element.text or "").strip()
533
+ for element in root.iter()
534
+ if _xml_local_name(element.tag) == "MainSource"
535
+ and (element.text or "").strip()
536
+ ),
537
+ "",
538
+ )
539
+ if not main_source:
540
+ discovery.problems.append(
541
+ DiscoveryProblem(
542
+ "cant_read_project",
543
+ "Delphi project file does not define MainSource.",
544
+ str(path),
545
+ )
546
+ )
547
+ return None
548
+
549
+ entry = (path.parent / main_source).resolve()
550
+ if entry.suffix.casefold() not in PROJECT_EXTENSIONS:
551
+ discovery.problems.append(
552
+ DiscoveryProblem(
553
+ "cant_read_project",
554
+ "MainSource must reference a .dpr or .dpk entry file.",
555
+ str(path),
556
+ )
557
+ )
558
+ return None
559
+ if not entry.is_file():
560
+ discovery.problems.append(
561
+ DiscoveryProblem(
562
+ "cant_read_project",
563
+ f"MainSource does not exist: {entry}",
564
+ str(path),
565
+ )
566
+ )
567
+ return None
568
+ return entry
569
+
570
+
482
571
  def _scan_sources(
483
572
  root: Path,
484
573
  discovery: DelphiProjectDiscovery,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-delphi-lsp
3
- Version: 2.3.0
3
+ Version: 2.3.2
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
@@ -42,7 +42,7 @@ Dynamic: license-file
42
42
 
43
43
  `python-delphi-lsp` parses Delphi/Object Pascal, builds semantic and project
44
44
  indexes, serves LSP, and provides bounded codebase navigation for agents.
45
- Version 2.3.0 is authored by Dark Light and supports Windows, macOS, and Linux.
45
+ Version 2.3.2 is authored by Dark Light and supports Windows, macOS, and Linux.
46
46
 
47
47
  ## Install and quick start
48
48
 
@@ -166,7 +166,8 @@ available without returning the complete file as agent context.
166
166
  Auto-discovery reads `.dpr`, `.dpk`, `.dproj`, `.cfg`, and `.dof` files.
167
167
  Its resolution order is:
168
168
 
169
- 1. An explicit project selection takes precedence.
169
+ 1. An explicit `.dproj`, `.dpr`, or `.dpk` selection takes precedence. An
170
+ explicit `.dproj` resolves its `MainSource`.
170
171
  2. Otherwise, `.dpr` and `.dpk` candidates are considered.
171
172
  3. `MainSource` in a `.dproj` contributes its entry project.
172
173
  4. A selected entry associates same-stem `.dproj`, `.cfg`, and `.dof`.
@@ -175,11 +176,13 @@ Its resolution order is:
175
176
  6. Direct `Unit in 'path/Unit.pas'` references contribute their parent
176
177
  directory to unit search paths.
177
178
 
178
- A single discovered project is selected automatically. With no project entry,
179
- the server uses a synthetic workspace of supported sources. Scans skip build
180
- and cache directories such as `build`, `dist`, environments, VCS folders,
181
- `node_modules`, and tool caches. Missing paths and invalid metadata become
182
- problems; paths are not guessed.
179
+ A single discovered project is selected automatically. With multiple project
180
+ entries, a synthetic repository workspace containing every supported source is
181
+ selected automatically while the concrete projects remain available. With no
182
+ project entry, the same workspace model is used. Scans skip build and cache
183
+ directories such as `build`, `dist`, environments, VCS folders, `node_modules`,
184
+ and tool caches. Missing paths and invalid metadata become problems; paths are
185
+ not guessed.
183
186
 
184
187
  ## Agent CLI and Interface/Protocol v2
185
188
 
@@ -197,7 +200,7 @@ delphi-lsp-agent view --root PATH [--project-file FILE] --layer LAYER
197
200
  delphi-lsp-agent index --root PATH [--project-file FILE] [--out FILE]
198
201
  [--workers auto|N]
199
202
  delphi-lsp-agent query --root PATH ACTION [VALUE]
200
- [--project-id FILE] [--detail summary|declaration|members|context|body|implementations]
203
+ [--project-id ID] [--detail summary|declaration|members|context|body|implementations]
201
204
  [--relation references|callers|callees|uses|used_by|inherits|implements]
202
205
  [--cursor TEXT] [--max-items INT] [--max-chars INT]
203
206
  delphi-lsp-agent skill install [--target PATH] [--force]
@@ -229,6 +232,32 @@ delphi-lsp-agent query --root PATH metrics UNIT_QUERY
229
232
  delphi-lsp-agent cache status --root PATH --format json
230
233
  ```
231
234
 
235
+ The repository root is sufficient even when it contains many projects:
236
+
237
+ ```bash
238
+ delphi-lsp-agent cache start --root PATH
239
+ delphi-lsp-agent query --root PATH find TCustomer
240
+ ```
241
+
242
+ This prewarms one bounded repository navigation index and requires no project
243
+ selection. A `.dproj` is optional. Pass one when its project-specific compiler
244
+ settings and `MainSource` should define the index:
245
+
246
+ ```bash
247
+ delphi-lsp-agent cache start --root PATH --project-file relative/path/Main.dproj
248
+ ```
249
+
250
+ An explicit `.dpr` or `.dpk` remains supported. To switch an already running
251
+ repository cache to one of its concrete project views, list the projects and
252
+ focus one by its returned `project_id`:
253
+
254
+ ```bash
255
+ delphi-lsp-agent query --root PATH open
256
+ delphi-lsp-agent query --root PATH focus --project-id PROJECT_ID
257
+ ```
258
+
259
+ Selecting a project this way also prewarms its navigation cache.
260
+
232
261
  `inspect` uses the currently focused target, so call `focus TARGET_ID` before
233
262
  `inspect` unless a previous request already selected it.
234
263
 
@@ -1,14 +1,14 @@
1
1
  delphi_lsp/__init__.py,sha256=mq3tF0NrXDgbkJXDuf5HhW6sOnmP7DQxs3zl8fGoAmo,3867
2
- delphi_lsp/_version.py,sha256=CpK8IH_dCUAwg9tqv7zm9FxbBFkxCnED1JUiRe7cftU,22
3
- delphi_lsp/agent_cache.py,sha256=FMAYleW1XlWchBtha6bBnkFQiAFt_nCcMp5S28zKcy8,35211
4
- delphi_lsp/agent_cli.py,sha256=J2zmZP2D28pXi7tx06YyEqKsojbXQcO0LdZx27kGJ1M,16311
5
- delphi_lsp/agent_context.py,sha256=K3gGmmVKpc5vysmztwdEjIEeWXGTSmQXr8S47uzcKgQ,97269
2
+ delphi_lsp/_version.py,sha256=J4CRnpR3v72FGOMp8gqSua_XWZpAfXuqgVWiQFB-gTY,22
3
+ delphi_lsp/agent_cache.py,sha256=Fz7iPA-q2i-SLMmy6FBFTeUJ7Jdd8idXY2maRnhajbY,39204
4
+ delphi_lsp/agent_cli.py,sha256=6cb-cmRmjAyoTNh_CrR-VFUt-sFRNDiVFAmQIkX1DLc,19797
5
+ delphi_lsp/agent_context.py,sha256=9mDtYjL3LJRlwTEpUQeQIhcBkwDxQltQudKxVYdw8wk,97409
6
6
  delphi_lsp/agent_layers.py,sha256=Waiwp47BuNxxwzM8EfGb0tvLSRn2udRHkTmuX2JE6cg,25658
7
7
  delphi_lsp/agent_metrics.py,sha256=Zh96_qlaDzJIW33XiCucvUeD-Y0zaNr52MRAdKMv9Hg,5217
8
8
  delphi_lsp/agent_protocol.py,sha256=RxUyWaL-ixYO3nvAY2Apkhs1c2r5oNEheUaw64Oizo4,12648
9
9
  delphi_lsp/agent_relations.py,sha256=q01Mi44m-COs0gDRJa1bTcc1U5M5JsTkQBw4GX1HLPA,43381
10
- delphi_lsp/agent_templates.py,sha256=LXtbAPj0Nxvp3ycZARPELEb51BFoi2vtiCe-pYHNteE,21834
11
- delphi_lsp/agent_workspace.py,sha256=LVcJ35LNIkXTIcbE-t46SBtBWWlyREROezhirlaswsE,25674
10
+ delphi_lsp/agent_templates.py,sha256=8KuLxBbl76mXE-MzNqWVB7OXxiHVu52q17cRsf4nHr4,21930
11
+ delphi_lsp/agent_workspace.py,sha256=si79CAn99nOodapE4ls0-EhClvl1FpLQ-bwWw0j_wf4,26385
12
12
  delphi_lsp/binary.py,sha256=s-c0tstGtkbjxgyiS6vteqh5D40qmVEenoECtBCsDcU,8495
13
13
  delphi_lsp/comment_builder.py,sha256=XvFW7WTbYy7yGN3MG40gtabTvhGj1ea1MYkySDbVMDA,832
14
14
  delphi_lsp/consts.py,sha256=qM40iwxXmvEileWHJVLMYlQSkLoLyQaqM-Zscz3VJbs,5958
@@ -27,16 +27,16 @@ delphi_lsp/parser.py,sha256=qYY_eTeGgLYn30QyN4_ERjU9-TLFuD5u_CXAMYJdX5A,7721
27
27
  delphi_lsp/parser_backend.py,sha256=_-veK3B4OreA5wkv5pm96-JTTZtNZUQ92JZMktp2tPU,1667
28
28
  delphi_lsp/preprocessor.py,sha256=0WsY4DuYCSo8FOe8TK_8Vo2z8nFhh1nut1OQaifrvgI,32037
29
29
  delphi_lsp/progress.py,sha256=FF3lqdDKLvq3b7cOhuSc3P0FDzVnX0miD8U8_3xt5iY,535
30
- delphi_lsp/project_discovery.py,sha256=UpezFlNJDaF5sW8tkG_HUxHpAULSf1GSWXJTlUF8vMM,20230
30
+ delphi_lsp/project_discovery.py,sha256=xONO3Q7d2k-duNutn1vS3dknS8VmWR_guFhzZBS2XOQ,22852
31
31
  delphi_lsp/project_indexer.py,sha256=kaDbQixp4x2Z9LnzSNhyjdsWfyEFUmE8DSE3cgdL1ZQ,14435
32
32
  delphi_lsp/semantic.py,sha256=FZ9x_QsIS_u-1DlzVlYgRqH9gQZ5ySnDAD-xHwzblU8,9808
33
33
  delphi_lsp/semantic_builder.py,sha256=ulA7xHJpltjc6cTo5wh9yTcYazuIykM-YLMXD1h-wzk,58680
34
34
  delphi_lsp/source_reader.py,sha256=HWiw25sLVZ6s1GGpMl17uZ-o68HoxTEd3Z0__m1vsSE,520
35
35
  delphi_lsp/workspace.py,sha256=TU__SujItRlW8hB2kuYC7ZJx1WQVOj7frYxvo7rDqeI,2137
36
36
  delphi_lsp/writer.py,sha256=j-cnyiHFNOTFECL7Ehm-m43ye7ev7qFeCCscFMjrAOY,2503
37
- python_delphi_lsp-2.3.0.dist-info/licenses/LICENSE,sha256=-rPda9qyJvHAhjCx3ZF-Efy07F4eAg4sFvg6ChOGPoU,16726
38
- python_delphi_lsp-2.3.0.dist-info/METADATA,sha256=8DUvuIk9qZ9uos80JzdZSoSqXu33kmvHCx1tYgCJPIg,21104
39
- python_delphi_lsp-2.3.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
40
- python_delphi_lsp-2.3.0.dist-info/entry_points.txt,sha256=rh_yGH7diowge_acQcST87A-ewH1QezEsAYC8T5ineY,103
41
- python_delphi_lsp-2.3.0.dist-info/top_level.txt,sha256=0Zp0sjw5Qtic3EQX9zNeegURga8NuXk6c2e9PN-3OTs,11
42
- python_delphi_lsp-2.3.0.dist-info/RECORD,,
37
+ python_delphi_lsp-2.3.2.dist-info/licenses/LICENSE,sha256=-rPda9qyJvHAhjCx3ZF-Efy07F4eAg4sFvg6ChOGPoU,16726
38
+ python_delphi_lsp-2.3.2.dist-info/METADATA,sha256=5t0-eIjD3lYNQBEkRC1GzDNrBdEpgaHS-LvQISTC8VI,22175
39
+ python_delphi_lsp-2.3.2.dist-info/WHEEL,sha256=SmOxYU7pzNKBqASvQJ7DjX3XGUF92lrGhMb3R6_iiqI,91
40
+ python_delphi_lsp-2.3.2.dist-info/entry_points.txt,sha256=rh_yGH7diowge_acQcST87A-ewH1QezEsAYC8T5ineY,103
41
+ python_delphi_lsp-2.3.2.dist-info/top_level.txt,sha256=0Zp0sjw5Qtic3EQX9zNeegURga8NuXk6c2e9PN-3OTs,11
42
+ python_delphi_lsp-2.3.2.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (83.0.0)
2
+ Generator: setuptools (79.0.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5