python-delphi-lsp 2.0.1__py3-none-any.whl → 2.0.3__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.1"
1
+ __version__ = "2.0.3"
delphi_lsp/agent_cli.py CHANGED
@@ -67,11 +67,19 @@ def build_parser() -> argparse.ArgumentParser:
67
67
 
68
68
  opencode = subcommands.add_parser("opencode", help="Install opencode integration.")
69
69
  opencode_commands = opencode.add_subparsers(dest="opencode_command", required=True)
70
- opencode_install = opencode_commands.add_parser("install", help="Install .agents skill and opencode plugin.")
70
+ opencode_install = opencode_commands.add_parser(
71
+ "install", help="Install the package-named skill, Markdown agent, and OpenCode plugin."
72
+ )
71
73
  opencode_install.add_argument("--target", type=Path, default=Path("."))
72
74
  opencode_install.add_argument("--python", default=sys.executable)
73
75
  opencode_install.add_argument("--force", action="store_true")
74
- opencode_install.add_argument("--write-config", action="store_true")
76
+ opencode_install.add_argument(
77
+ "--write-agent",
78
+ "--write-config",
79
+ dest="write_config",
80
+ action="store_true",
81
+ help="Deprecated compatibility option; the Markdown agent is always installed and opencode.json is never touched.",
82
+ )
75
83
  opencode_install.set_defaults(func=_opencode_install)
76
84
 
77
85
  worker = subcommands.add_parser("worker", help="Serve Protocol v2 NDJSON requests.")
@@ -139,7 +147,7 @@ def _skill_install(args: argparse.Namespace) -> None:
139
147
 
140
148
 
141
149
  def _opencode_install(args: argparse.Namespace) -> None:
142
- skill_path, plugin_path, config_path = install_opencode_support(
150
+ skill_path, plugin_path, agent_path = install_opencode_support(
143
151
  args.target,
144
152
  python_executable=args.python,
145
153
  force=args.force,
@@ -147,8 +155,7 @@ def _opencode_install(args: argparse.Namespace) -> None:
147
155
  )
148
156
  print(skill_path)
149
157
  print(plugin_path)
150
- if config_path is not None:
151
- print(config_path)
158
+ print(agent_path)
152
159
 
153
160
 
154
161
  def _worker(args: argparse.Namespace) -> None:
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  from bisect import bisect_left, bisect_right
4
4
  from collections.abc import Mapping
5
5
  from dataclasses import dataclass
6
+ from heapq import heappop, heappush
6
7
  import hashlib
7
8
  import json
8
9
  from pathlib import Path, PureWindowsPath
@@ -14,13 +15,12 @@ from .agent_protocol import (
14
15
  AgentResponse,
15
16
  ContextBudget,
16
17
  Focus,
17
- Page,
18
18
  make_target_id,
19
19
  paginate_items,
20
20
  )
21
21
  from .agent_metrics import build_workspace_metrics, project_metric_item, unit_metric_item
22
22
  from .agent_relations import ProjectRelationIndex, RelationTarget
23
- from .agent_workspace import AgentWorkspace, unit_display_path, unit_source_path, unit_target_id
23
+ from .agent_workspace import AgentUnit, AgentWorkspace, unit_display_path, unit_source_path, unit_target_id
24
24
  from .consts import AttributeName, SyntaxNodeType
25
25
  from .lsp_server import build_outline_semantic_model, multiline_string_block_end
26
26
  from .nodes import CompoundSyntaxNode, SyntaxNode
@@ -121,6 +121,10 @@ class _SourceDocument:
121
121
  self.tokens = tuple(_lex_delphi(text))
122
122
  self.token_starts = tuple(token.start for token in self.tokens)
123
123
  self.directive_starts = tuple(token.start for token in self.tokens if token.directive)
124
+ self.declaration_section_indexes = [0]
125
+ self.declaration_section_checkpoints: dict[int, tuple[str, int, int, int]] = {
126
+ 0: ("", 0, 0, 0)
127
+ }
124
128
  words = [token for token in self.tokens if token.word and not token.escaped]
125
129
  self.unit_kind = next(
126
130
  (token.value for token in words if token.value in {"unit", "program", "library", "package"}),
@@ -129,6 +133,7 @@ class _SourceDocument:
129
133
  implementation = next((token for token in words if token.value == "implementation"), None)
130
134
  self.implementation_line = self.line_col(implementation.start)[0] if implementation else 0
131
135
  self.routine_spans: dict[int, tuple[int, int] | None] = {}
136
+ self.routine_token_spans: dict[int, tuple[int, int, int] | None] = {}
132
137
  self.parser_spans: dict[str, tuple[int, int] | None] = {}
133
138
  self._full_parse_attempted = False
134
139
  self._full_parse_result: object | None = None
@@ -230,6 +235,7 @@ class _Registry:
230
235
  entries: tuple[_SymbolEntry, ...]
231
236
  by_target: dict[str, _SymbolEntry]
232
237
  sources: dict[Path, _SourceDocument]
238
+ ranked_queries: dict[str, tuple[_SymbolEntry, ...]]
233
239
 
234
240
 
235
241
  class AgentContext:
@@ -280,7 +286,12 @@ class AgentContext:
280
286
  return self._handle_focus(parsed, revision)
281
287
  if parsed.action == "find":
282
288
  registry = self._require_registry(revision)
283
- items = [entry.card() for entry in _ranked_entries(registry.entries, parsed.query)]
289
+ ranked = registry.ranked_queries.get(parsed.query)
290
+ if ranked is None:
291
+ ranked = tuple(_ranked_entries(registry.entries, parsed.query))
292
+ registry.ranked_queries.clear()
293
+ registry.ranked_queries[parsed.query] = ranked
294
+ items = [entry.card() for entry in ranked]
284
295
  return self._response(parsed, revision, items)
285
296
  if parsed.action == "inspect":
286
297
  registry = self._require_registry(revision)
@@ -293,9 +304,10 @@ class AgentContext:
293
304
  previous_project_id = self._workspace.active_project_id
294
305
  selected_project_id = requested_project_id or previous_project_id
295
306
  if selected_project_id:
296
- self._workspace.select_project(selected_project_id)
307
+ revision = self._workspace._select_project_with_revision(selected_project_id)
308
+ else:
309
+ revision = self._workspace.workspace_revision
297
310
  current_project_id = self._workspace.active_project_id
298
- revision = self._workspace.workspace_revision
299
311
 
300
312
  if current_project_id != previous_project_id:
301
313
  self._registry = None
@@ -782,6 +794,7 @@ def _build_registry(workspace: AgentWorkspace, project_id: str, revision: str) -
782
794
  entries=entries_tuple,
783
795
  by_target={entry.target_id: entry for entry in entries_tuple},
784
796
  sources=sources,
797
+ ranked_queries={},
785
798
  )
786
799
 
787
800
 
@@ -925,32 +938,47 @@ def _correct_outline_symbol_kind(document: _SourceDocument, symbol: Symbol) -> N
925
938
  symbol.kind = SymbolKind.TYPE
926
939
 
927
940
 
941
+ def _advance_declaration_section(
942
+ state: tuple[str, int, int, int],
943
+ token: _Token,
944
+ ) -> tuple[str, int, int, int]:
945
+ section, parentheses, brackets, angles = state
946
+ if token.directive:
947
+ return state
948
+ if token.value == "(":
949
+ parentheses += 1
950
+ elif token.value == ")":
951
+ parentheses = max(0, parentheses - 1)
952
+ elif token.value == "[":
953
+ brackets += 1
954
+ elif token.value == "]":
955
+ brackets = max(0, brackets - 1)
956
+ elif token.value == "<":
957
+ angles += 1
958
+ elif token.value == ">":
959
+ angles = max(0, angles - 1)
960
+ elif not parentheses and not brackets and not angles and token.word:
961
+ if token.value in {"const", "resourcestring", "threadvar", "type", "var"}:
962
+ section = token.value
963
+ elif token.value in {"implementation", "initialization", "finalization"}:
964
+ section = ""
965
+ return section, parentheses, brackets, angles
966
+
967
+
928
968
  def _declaration_section(document: _SourceDocument, offset: int) -> str:
929
- section = ""
930
- parentheses = 0
931
- brackets = 0
932
- angles = 0
933
- for token in document.tokens[:document.first_token_index(offset)]:
934
- if token.directive:
935
- continue
936
- if token.value == "(":
937
- parentheses += 1
938
- elif token.value == ")":
939
- parentheses = max(0, parentheses - 1)
940
- elif token.value == "[":
941
- brackets += 1
942
- elif token.value == "]":
943
- brackets = max(0, brackets - 1)
944
- elif token.value == "<":
945
- angles += 1
946
- elif token.value == ">":
947
- angles = max(0, angles - 1)
948
- elif not any((parentheses, brackets, angles)) and token.word:
949
- if token.value in {"const", "resourcestring", "threadvar", "type", "var"}:
950
- section = token.value
951
- elif token.value in {"implementation", "initialization", "finalization"}:
952
- section = ""
953
- return section
969
+ target_index = document.first_token_index(offset)
970
+ cached = document.declaration_section_checkpoints.get(target_index)
971
+ if cached is not None:
972
+ return cached[0]
973
+ checkpoint_position = bisect_right(document.declaration_section_indexes, target_index) - 1
974
+ checkpoint_index = document.declaration_section_indexes[checkpoint_position]
975
+ state = document.declaration_section_checkpoints[checkpoint_index]
976
+ for token in document.tokens[checkpoint_index:target_index]:
977
+ state = _advance_declaration_section(state, token)
978
+ insert_at = bisect_left(document.declaration_section_indexes, target_index)
979
+ document.declaration_section_indexes.insert(insert_at, target_index)
980
+ document.declaration_section_checkpoints[target_index] = state
981
+ return state[0]
954
982
 
955
983
 
956
984
  def _declared_symbol_name(document: _SourceDocument, symbol: Symbol) -> str:
@@ -1208,20 +1236,47 @@ def _exclude_routine_locals(
1208
1236
  span = _raw_routine_span(raw, document)
1209
1237
  if span is not None:
1210
1238
  containers.append((span[0], span[1], raw))
1239
+ if not containers:
1240
+ return symbols
1211
1241
 
1212
- filtered: list[_RawSymbol] = []
1213
- for raw in symbols:
1214
- offset = document.offset(
1215
- raw.symbol.decl_range.start_line,
1216
- raw.symbol.decl_range.start_col,
1242
+ containers.sort(key=lambda item: (item[0], item[1]))
1243
+ positioned = sorted(
1244
+ (
1245
+ document.offset(
1246
+ raw.symbol.decl_range.start_line,
1247
+ raw.symbol.decl_range.start_col,
1248
+ ),
1249
+ order,
1250
+ raw,
1217
1251
  )
1218
- if any(
1219
- start < offset < end and raw is not container
1220
- for start, end, container in containers
1252
+ for order, raw in enumerate(symbols)
1253
+ )
1254
+ active_ends: list[tuple[int, int, int]] = []
1255
+ active_ids: set[int] = set()
1256
+ excluded_orders: set[int] = set()
1257
+ container_index = 0
1258
+ for offset, order, raw in positioned:
1259
+ while (
1260
+ container_index < len(containers)
1261
+ and containers[container_index][0] < offset
1221
1262
  ):
1222
- continue
1223
- filtered.append(raw)
1224
- return filtered
1263
+ _, end, container = containers[container_index]
1264
+ container_id = id(container)
1265
+ heappush(active_ends, (end, container_index, container_id))
1266
+ active_ids.add(container_id)
1267
+ container_index += 1
1268
+ while active_ends and active_ends[0][0] <= offset:
1269
+ _, _, container_id = heappop(active_ends)
1270
+ active_ids.discard(container_id)
1271
+ raw_id = id(raw)
1272
+ if active_ids and (raw_id not in active_ids or len(active_ids) > 1):
1273
+ excluded_orders.add(order)
1274
+
1275
+ return [
1276
+ raw
1277
+ for order, raw in enumerate(symbols)
1278
+ if order not in excluded_orders
1279
+ ]
1225
1280
 
1226
1281
 
1227
1282
  def _raw_routine_span(
@@ -1523,6 +1578,7 @@ def _routine_span(
1523
1578
  document.tokens,
1524
1579
  document.token_starts,
1525
1580
  token_index,
1581
+ cache=document.routine_token_spans,
1526
1582
  )
1527
1583
  span = (start, found[1]) if found is not None else None
1528
1584
  document.routine_spans[start] = span
@@ -1534,6 +1590,7 @@ def _find_routine_token_span(
1534
1590
  token_starts: tuple[int, ...],
1535
1591
  start_index: int,
1536
1592
  *,
1593
+ cache: dict[int, tuple[int, int, int] | None] | None = None,
1537
1594
  depth: int = 0,
1538
1595
  ) -> tuple[int, int, int] | None:
1539
1596
  if depth > 64:
@@ -1541,56 +1598,100 @@ def _find_routine_token_span(
1541
1598
  routine_index = _routine_keyword_index(tokens, start_index)
1542
1599
  if routine_index is None:
1543
1600
  return None
1601
+ spans = cache if cache is not None else {}
1602
+ missing = object()
1603
+ cached = spans.get(routine_index, missing)
1604
+ if cached is not missing:
1605
+ if cached is None:
1606
+ return None
1607
+ return tokens[start_index].start, cached[1], cached[2]
1608
+
1544
1609
  heading_end = _heading_semicolon_index(tokens, routine_index)
1545
1610
  if heading_end is None:
1611
+ spans[routine_index] = None
1546
1612
  return None
1547
1613
 
1548
- index = heading_end + 1
1549
- while index < len(tokens):
1614
+ frames: list[list[int]] = [[routine_index, heading_end + 1]]
1615
+
1616
+ def reject_active_frames() -> None:
1617
+ for active_routine_index, _ in frames:
1618
+ spans[active_routine_index] = None
1619
+ frames.clear()
1620
+
1621
+ while frames:
1622
+ frame = frames[-1]
1623
+ frame_routine_index, index = frame
1624
+ if index >= len(tokens):
1625
+ reject_active_frames()
1626
+ continue
1627
+
1550
1628
  token = tokens[index]
1551
1629
  if token.directive:
1552
- return None
1630
+ reject_active_frames()
1631
+ continue
1553
1632
  if token.word and not token.escaped:
1554
1633
  if token.value in _NO_BODY_DIRECTIVES:
1555
- return None
1634
+ spans[frame_routine_index] = None
1635
+ frames.pop()
1636
+ continue
1556
1637
  if token.value in {"implementation", "initialization", "finalization"}:
1557
- return None
1638
+ reject_active_frames()
1639
+ continue
1558
1640
  if (
1559
1641
  token.value in _STRUCTURED_TYPE_WORDS
1560
1642
  and _is_structured_type_opener(tokens, index)
1561
1643
  ):
1562
1644
  if index + 1 < len(tokens) and tokens[index + 1].value == ";":
1563
- index += 2
1645
+ frame[1] = index + 2
1564
1646
  continue
1565
1647
  structured_end = _match_end_terminated_block(tokens, index)
1566
1648
  if structured_end is None:
1567
- return None
1568
- index = bisect_left(token_starts, structured_end)
1649
+ reject_active_frames()
1650
+ continue
1651
+ frame[1] = bisect_left(token_starts, structured_end)
1569
1652
  continue
1570
1653
  if token.value == "end":
1571
- return None
1654
+ reject_active_frames()
1655
+ continue
1572
1656
  if token.value in {"begin", "asm"}:
1573
1657
  end = _match_end_terminated_block(tokens, index)
1574
1658
  if end is None:
1575
- return None
1659
+ reject_active_frames()
1660
+ continue
1576
1661
  end_index = bisect_left(token_starts, end)
1577
- return tokens[start_index].start, end, end_index
1578
- if token.value in _ROUTINE_WORDS and _is_nested_routine_declaration(tokens, index):
1579
- nested = _find_routine_token_span(
1580
- tokens,
1581
- token_starts,
1582
- index,
1583
- depth=depth + 1,
1662
+ spans[frame_routine_index] = (
1663
+ tokens[frame_routine_index].start,
1664
+ end,
1665
+ end_index,
1584
1666
  )
1667
+ frames.pop()
1668
+ continue
1669
+ if token.value in _ROUTINE_WORDS and _is_nested_routine_declaration(tokens, index):
1670
+ nested_routine_index = _routine_keyword_index(tokens, index)
1671
+ if nested_routine_index is None:
1672
+ frame[1] = index + 1
1673
+ continue
1674
+ nested = spans.get(nested_routine_index, missing)
1675
+ if nested is missing:
1676
+ nested_heading_end = _heading_semicolon_index(tokens, nested_routine_index)
1677
+ if nested_heading_end is None:
1678
+ spans[nested_routine_index] = None
1679
+ continue
1680
+ frames.append([nested_routine_index, nested_heading_end + 1])
1681
+ continue
1585
1682
  if nested is not None:
1586
- index = max(index + 1, nested[2])
1683
+ frame[1] = max(index + 1, nested[2])
1587
1684
  continue
1588
1685
  skipped = _routine_declaration_end_index(tokens, index)
1589
1686
  if skipped is not None:
1590
- index = skipped + 1
1687
+ frame[1] = skipped + 1
1591
1688
  continue
1592
- index += 1
1593
- return None
1689
+ frame[1] = index + 1
1690
+
1691
+ result = spans.get(routine_index)
1692
+ if result is None:
1693
+ return None
1694
+ return tokens[start_index].start, result[1], result[2]
1594
1695
 
1595
1696
 
1596
1697
  def _routine_keyword_index(
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import hashlib
3
4
  from pathlib import Path
4
5
  import json
5
6
  import os
@@ -7,14 +8,20 @@ import secrets
7
8
  import stat
8
9
 
9
10
 
10
- SKILL_NAME = "delphi-codebase-navigator"
11
+ SKILL_NAME = "python-delphi-lsp"
12
+ AGENT_NAME = "python-delphi-lsp"
13
+ _LEGACY_SKILL_NAME = "delphi-codebase-navigator"
14
+ _LEGACY_SKILL_RELATIVE_PATH = Path(".agents") / "skills" / _LEGACY_SKILL_NAME / "SKILL.md"
15
+ _LEGACY_SKILL_SHA256 = "0843d37bd48431c2b992f191b985175a3c60fe5b56d28e0a46b5e18db6383b28"
11
16
  _LEGACY_TOOL_RELATIVE_PATH = Path(".opencode") / "tools" / "delphi_codebase.ts"
12
17
 
13
18
 
14
19
  def install_skill(target: str | Path, *, force: bool = False) -> Path:
15
20
  target_path = Path(target).expanduser().resolve()
21
+ legacy_path = _preflight_legacy_skill(target_path, force=force)
16
22
  skill_path = target_path / ".agents" / "skills" / SKILL_NAME / "SKILL.md"
17
23
  _write_text(skill_path, _skill_markdown(), force=force)
24
+ _remove_legacy_skill(legacy_path)
18
25
  return skill_path
19
26
 
20
27
 
@@ -24,26 +31,30 @@ def install_opencode_support(
24
31
  python_executable: str,
25
32
  force: bool = False,
26
33
  write_config: bool = False,
27
- ) -> tuple[Path, Path, Path | None]:
34
+ ) -> tuple[Path, Path, Path]:
28
35
  target_path = Path(target).expanduser().resolve()
29
36
  legacy_path = target_path / _LEGACY_TOOL_RELATIVE_PATH
37
+ legacy_skill_path = _preflight_legacy_skill(target_path, force=force)
30
38
  skill_path = target_path / ".agents" / "skills" / SKILL_NAME / "SKILL.md"
31
39
  plugin_path = target_path / ".opencode" / "plugins" / "delphi_codebase.ts"
32
- config_path = target_path / "opencode.json" if write_config else None
40
+ agent_path = target_path / ".opencode" / "agents" / f"{AGENT_NAME}.md"
33
41
  skill_text = _skill_markdown()
34
42
  plugin_text = _opencode_plugin(python_executable)
43
+ agent_text = _agent_markdown()
35
44
 
36
45
  legacy_before = _preflight_legacy(legacy_path, force=force)
37
46
  snapshots: dict[Path, bytes | None] = {
38
47
  legacy_path: legacy_before,
48
+ **(
49
+ {legacy_skill_path: legacy_skill_path.read_bytes()}
50
+ if legacy_skill_path is not None
51
+ else {}
52
+ ),
39
53
  skill_path: _preflight_destination(skill_path, skill_text, force=force),
40
54
  plugin_path: _preflight_destination(plugin_path, plugin_text, force=force),
55
+ agent_path: _preflight_destination(agent_path, agent_text, force=force),
41
56
  }
42
- writes = [(skill_path, skill_text), (plugin_path, plugin_text)]
43
- if config_path is not None:
44
- config_before, config_text = _render_opencode_config(config_path)
45
- snapshots[config_path] = config_before
46
- writes.append((config_path, config_text))
57
+ writes = [(skill_path, skill_text), (plugin_path, plugin_text), (agent_path, agent_text)]
47
58
 
48
59
  try:
49
60
  for path, text in writes:
@@ -51,11 +62,39 @@ def install_opencode_support(
51
62
  _write_text(path, text, force=True)
52
63
  if legacy_before is not None:
53
64
  legacy_path.unlink()
65
+ _remove_legacy_skill(legacy_skill_path)
54
66
  except BaseException:
55
67
  for path, content in reversed(tuple(snapshots.items())):
56
68
  _restore_file(path, content)
57
69
  raise
58
- return skill_path, plugin_path, config_path
70
+ _ = write_config # Deprecated compatibility input; user configuration is never touched.
71
+ return skill_path, plugin_path, agent_path
72
+
73
+
74
+ def _preflight_legacy_skill(target: Path, *, force: bool) -> Path | None:
75
+ legacy_path = target / _LEGACY_SKILL_RELATIVE_PATH
76
+ legacy_path.relative_to(target)
77
+ _reject_symbolic_link(legacy_path)
78
+ if not legacy_path.exists():
79
+ return None
80
+ if not legacy_path.is_file():
81
+ raise FileExistsError(f"Legacy skill path is not a file: {legacy_path}")
82
+ digest = hashlib.sha256(legacy_path.read_bytes()).hexdigest()
83
+ if digest != _LEGACY_SKILL_SHA256 and not force:
84
+ raise FileExistsError(
85
+ f"Refusing to remove modified legacy skill without force: {legacy_path}"
86
+ )
87
+ return legacy_path
88
+
89
+
90
+ def _remove_legacy_skill(path: Path | None) -> None:
91
+ if path is None:
92
+ return
93
+ path.unlink()
94
+ try:
95
+ path.parent.rmdir()
96
+ except OSError:
97
+ pass
59
98
 
60
99
 
61
100
  def _preflight_legacy(legacy_path: Path, *, force: bool) -> bytes | None:
@@ -157,13 +196,18 @@ def _write_bytes_atomic(path: Path, content: bytes) -> None:
157
196
 
158
197
 
159
198
  def _reject_symbolic_link(path: Path) -> None:
160
- if path.is_symlink():
161
- raise FileExistsError(f"Generated destination must not be a symbolic link: {path}")
199
+ current = path
200
+ while current != current.parent:
201
+ if current.is_symlink():
202
+ raise FileExistsError(
203
+ f"Generated destination must not contain a symbolic link: {current}"
204
+ )
205
+ current = current.parent
162
206
 
163
207
 
164
208
  def _skill_markdown() -> str:
165
209
  return """---
166
- name: delphi-codebase-navigator
210
+ name: python-delphi-lsp
167
211
  description: Inspect Delphi and Object Pascal codebases through the Protocol v2 semantic navigator.
168
212
  compatibility: opencode
169
213
  metadata:
@@ -192,6 +236,42 @@ Prefer `summary` and `declaration`, narrow `max_items` and `max_chars`, and requ
192
236
  """
193
237
 
194
238
 
239
+ def _agent_markdown() -> str:
240
+ return """---
241
+ description: Inspect Delphi and Object Pascal codebases through python-delphi-lsp.
242
+ mode: all
243
+ temperature: 0
244
+ permission:
245
+ delphi_codebase: allow
246
+ skill:
247
+ "*": deny
248
+ python-delphi-lsp: allow
249
+ lsp: deny
250
+ bash: deny
251
+ read: deny
252
+ glob: deny
253
+ grep: deny
254
+ list: deny
255
+ edit: deny
256
+ write: deny
257
+ patch: deny
258
+ task: deny
259
+ webfetch: deny
260
+ websearch: deny
261
+ question: deny
262
+ todowrite: deny
263
+ todoread: deny
264
+ codebase_map: deny
265
+ code_guidelines: deny
266
+ ---
267
+
268
+ Load `python-delphi-lsp` first, then use only `delphi_codebase` for Delphi and
269
+ Object Pascal codebase inspection. Do not use `lsp`, `bash`, `read`, `glob`,
270
+ `grep`, `edit`, `write`, `task`, `webfetch`, or `todowrite`. Preserve returned
271
+ citations exactly and report partial or ambiguous semantic evidence explicitly.
272
+ """
273
+
274
+
195
275
  def _opencode_plugin(python_executable: str) -> str:
196
276
  python_json = json.dumps(python_executable)
197
277
  template = '''import { tool, type Plugin } from "@opencode-ai/plugin"
@@ -525,56 +605,4 @@ export const DelphiCodebasePlugin: Plugin = async (_input) => {
525
605
  return template.replace("__PYTHON_EXECUTABLE__", python_json)
526
606
 
527
607
 
528
- def _render_opencode_config(config_path: Path) -> tuple[bytes | None, str]:
529
- _reject_symbolic_link(config_path)
530
- if config_path.exists():
531
- if not config_path.is_file():
532
- raise ValueError(f"opencode config path is not a file: {config_path}")
533
- config_before = config_path.read_bytes()
534
- config = json.loads(config_before.decode("utf-8"))
535
- else:
536
- config_before = None
537
- config = {"$schema": "https://opencode.ai/config.json"}
538
- if not isinstance(config, dict):
539
- raise ValueError("opencode config must be a JSON object")
540
- if "agent" not in config:
541
- agents = {}
542
- config["agent"] = agents
543
- else:
544
- agents = config["agent"]
545
- if not isinstance(agents, dict):
546
- raise ValueError("opencode config field 'agent' must be a JSON object")
547
- agents["vllm-delphi-codebase"] = {
548
- "description": "Use the Delphi codebase navigator skill and plugin without direct filesystem source inspection.",
549
- "temperature": 0,
550
- "prompt": (
551
- "load delphi-codebase-navigator first, then use only delphi_codebase for Delphi/Object Pascal "
552
- "codebase inspection. Do not use lsp, bash, read, glob, grep, edit, write, task, webfetch, or todowrite."
553
- ),
554
- "tools": {
555
- "delphi_codebase": True,
556
- "skill": True,
557
- "lsp": False,
558
- "bash": False,
559
- "read": False,
560
- "glob": False,
561
- "grep": False,
562
- "edit": False,
563
- "write": False,
564
- "task": False,
565
- "webfetch": False,
566
- "todowrite": False,
567
- },
568
- "permission": {
569
- "delphi_codebase": "allow",
570
- "skill": {
571
- "*": "deny",
572
- SKILL_NAME: "allow",
573
- },
574
- "lsp": "deny",
575
- },
576
- }
577
- return config_before, json.dumps(config, indent=2, sort_keys=True) + "\n"
578
-
579
-
580
608
  __all__ = ["SKILL_NAME", "install_opencode_support", "install_skill"]
@@ -264,6 +264,9 @@ class AgentWorkspace:
264
264
  return f"workspace_v2_{fingerprint}"
265
265
 
266
266
  def select_project(self, project_id: str) -> None:
267
+ self._select_project_with_revision(project_id)
268
+
269
+ def _select_project_with_revision(self, project_id: str) -> str:
267
270
  if project_id not in self._project_paths:
268
271
  raise AgentProtocolError("project_not_found", f"Project not found: {project_id}.")
269
272
  project_path = self._project_paths[project_id]
@@ -280,7 +283,7 @@ class AgentWorkspace:
280
283
  fingerprint = _selection_fingerprint(discovery, cached.result, root=self._root)
281
284
  if fingerprint == cached.fingerprint:
282
285
  self._activate_project(project_id, discovery, cached.result)
283
- return
286
+ return f"workspace_v2_{fingerprint}"
284
287
 
285
288
  if project_path is None:
286
289
  result = _catalog_workspace_sources(discovery)
@@ -292,11 +295,13 @@ class AgentWorkspace:
292
295
  source_transform=_outline_agent_source,
293
296
  )
294
297
  result = indexer.index(str(project_path))
298
+ fingerprint = _selection_fingerprint(discovery, result, root=self._root)
295
299
  self._project_cache[project_id] = _ProjectCache(
296
300
  result=result,
297
- fingerprint=_selection_fingerprint(discovery, result, root=self._root),
301
+ fingerprint=fingerprint,
298
302
  )
299
303
  self._activate_project(project_id, discovery, result)
304
+ return f"workspace_v2_{fingerprint}"
300
305
 
301
306
  def _activate_project(
302
307
  self,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-delphi-lsp
3
- Version: 2.0.1
3
+ Version: 2.0.3
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.1 is authored by Dark Light and supports Windows, macOS, and Linux.
44
+ Version 2.0.3 is authored by Dark Light and supports Windows, macOS, and Linux.
45
45
 
46
46
  ## Install and quick start
47
47
 
@@ -177,7 +177,7 @@ delphi-lsp-agent view --root PATH [--project-file FILE] --layer LAYER
177
177
  delphi-lsp-agent index --root PATH [--project-file FILE] [--out FILE]
178
178
  delphi-lsp-agent skill install [--target PATH] [--force]
179
179
  delphi-lsp-agent opencode install [--target PATH] [--python PYTHON]
180
- [--force] [--write-config]
180
+ [--force] [--write-agent|--write-config]
181
181
  delphi-lsp-agent worker --root PATH [--project-file FILE]
182
182
  ```
183
183
 
@@ -187,8 +187,9 @@ delphi-lsp-agent worker --root PATH [--project-file FILE]
187
187
  returns a project summary and detailed unit metric objects; `--query` filters
188
188
  units by name or path.
189
189
  `index` materializes overview, projects, and problems JSON. `skill install`
190
- writes the skill; `opencode install` writes both integration files, while
191
- `--write-config` additionally writes the restricted agent configuration.
190
+ writes the skill; `opencode install` writes the package-named skill, Markdown
191
+ agent, and plugin. The two deprecated write flags are harmless aliases and do
192
+ not change user configuration.
192
193
  `worker` serves NDJSON over standard input/output.
193
194
 
194
195
  Protocol v2 actions are `open`, `find`, `inspect`, `trace`, `focus`,
@@ -225,49 +226,63 @@ optimization does not remove LSP functionality.
225
226
  Install the generated integration in a worktree:
226
227
 
227
228
  ```bash
228
- delphi-lsp-agent opencode install --target . --write-config
229
+ delphi-lsp-agent opencode install --target .
229
230
  ```
230
231
 
231
232
  It writes:
232
233
 
233
234
  ```text
234
- .agents/skills/delphi-codebase-navigator/SKILL.md
235
+ .agents/skills/python-delphi-lsp/SKILL.md
235
236
  .opencode/plugins/delphi_codebase.ts
237
+ .opencode/agents/python-delphi-lsp.md
236
238
  ```
237
239
 
238
- The generated configuration enables only the named
239
- `delphi-codebase-navigator` skill and `delphi_codebase`. It denies
240
+ The package-named Markdown agent enables only the
241
+ `python-delphi-lsp` skill and `delphi_codebase`. It denies
240
242
  `bash`, `read`, `glob`, `grep`, and `lsp`, along with edit/write and
241
243
  other raw source tools. The skill is enabled. The installer does not use the
242
- retired `.opencode/tools` path.
244
+ retired `.opencode/tools` path and never reads or changes `opencode.json`; that
245
+ file remains entirely user-owned. The deprecated `--write-config` and
246
+ `--write-agent` options are accepted harmlessly for compatibility.
243
247
 
244
248
  The plugin maintains one worker per session/root, reusing focus and indexes.
245
249
  During compaction it restores the focus and summary into the new context.
246
250
  Transport failure, session deletion, and plugin disposal clean up the worker.
247
251
 
248
- A generated OpenCode agent looks like this; providers and unrelated agents stay
249
- unchanged:
250
-
251
- ```json
252
- {
253
- "agent": {
254
- "vllm-delphi-codebase": {
255
- "tools": {
256
- "delphi_codebase": true, "skill": true, "lsp": false,
257
- "bash": false, "read": false, "glob": false, "grep": false
258
- },
259
- "permission": {
260
- "delphi_codebase": "allow",
261
- "skill": {"*": "deny", "delphi-codebase-navigator": "allow"},
262
- "lsp": "deny"
263
- }
264
- }
265
- }
266
- }
252
+ A generated OpenCode agent starts with this Markdown frontmatter:
253
+
254
+ ```markdown
255
+ ---
256
+ description: Inspect Delphi and Object Pascal codebases through python-delphi-lsp.
257
+ mode: all
258
+ temperature: 0
259
+ permission:
260
+ delphi_codebase: allow
261
+ skill:
262
+ "*": deny
263
+ python-delphi-lsp: allow
264
+ lsp: deny
265
+ bash: deny
266
+ read: deny
267
+ glob: deny
268
+ grep: deny
269
+ list: deny
270
+ edit: deny
271
+ write: deny
272
+ patch: deny
273
+ task: deny
274
+ webfetch: deny
275
+ websearch: deny
276
+ question: deny
277
+ todowrite: deny
278
+ todoread: deny
279
+ codebase_map: deny
280
+ code_guidelines: deny
281
+ ---
267
282
  ```
268
283
 
269
- Select `vllm-delphi-codebase`, ask it to load
270
- `delphi-codebase-navigator`, then use `delphi_codebase` actions such as
284
+ Select `python-delphi-lsp`, ask it to load the `python-delphi-lsp` skill, then
285
+ use `delphi_codebase` actions such as
271
286
  `open`, `find`, `focus`, and `inspect`. Use semantic tool calls, not raw
272
287
  source tools.
273
288
 
@@ -1,13 +1,13 @@
1
1
  delphi_lsp/__init__.py,sha256=Gtoeg_D3hLGCFi34wdYiKrIByAbkWe1zS84v6_KJ6_4,3768
2
- delphi_lsp/_version.py,sha256=wAxkK8w13vqoF47A8iqWdSlIgRRXmZiQ0R4wePZfzhs,22
3
- delphi_lsp/agent_cli.py,sha256=FY5h_rmbPLBAtr8NWL5IyzqEaJBJm7Y5IQptvlt8l4Y,9022
4
- delphi_lsp/agent_context.py,sha256=VCWiqIhLPQX6-_4P_kYHLKMZK7ia0c1mbFC7RcbQsOY,73316
2
+ delphi_lsp/_version.py,sha256=_GEKEa6BYjBV34SZkSlAR87aCM5Y9G0aSI0LXL52iJg,22
3
+ delphi_lsp/agent_cli.py,sha256=Mma4C1Ca_Hd_9dl0mePT7uIypw6j6gDWdKu9XxIMXQQ,9226
4
+ delphi_lsp/agent_context.py,sha256=FdRQ2MFGDcqBDGMnD0w2X8p1kB3YwyTPDm_4cdCmwbA,77504
5
5
  delphi_lsp/agent_layers.py,sha256=7TmplTL7m_riS8BxZCD-3j1p8iOGiD9QWHay5ZcDYiw,21369
6
6
  delphi_lsp/agent_metrics.py,sha256=6y9DrSnKitZbwRFQrofanw6k89YGqbvEacqgSKHgh2c,2695
7
7
  delphi_lsp/agent_protocol.py,sha256=cZ1LIMhrkOb5CqiGcjSH1eZUNqtJCYySUc54h-PCyOs,12551
8
8
  delphi_lsp/agent_relations.py,sha256=pXhuCjfwmicG3VG7lYtGvKKv-mFPQ_hJ3w0eavqMoV0,32900
9
- delphi_lsp/agent_templates.py,sha256=U6ldX6RpdpttF5D0QSAi_OsqkcCe30Jc75hyhV3geHQ,21305
10
- delphi_lsp/agent_workspace.py,sha256=nnLwjLzkh_gsBxz8Czl_Q1OHRya7mHENGhmBi8br6AE,23527
9
+ delphi_lsp/agent_templates.py,sha256=LXtbAPj0Nxvp3ycZARPELEb51BFoi2vtiCe-pYHNteE,21834
10
+ delphi_lsp/agent_workspace.py,sha256=HvCWfMVv08Jlx257my4hkkXCLUNoPcy5AAnOnl_mNUU,23761
11
11
  delphi_lsp/binary.py,sha256=40mEpE0SBYmifwEoNHwSU7IU_D2usVyWYWHJ73aDfoo,8529
12
12
  delphi_lsp/comment_builder.py,sha256=XvFW7WTbYy7yGN3MG40gtabTvhGj1ea1MYkySDbVMDA,832
13
13
  delphi_lsp/consts.py,sha256=qM40iwxXmvEileWHJVLMYlQSkLoLyQaqM-Zscz3VJbs,5958
@@ -26,9 +26,9 @@ delphi_lsp/semantic_builder.py,sha256=ulA7xHJpltjc6cTo5wh9yTcYazuIykM-YLMXD1h-wz
26
26
  delphi_lsp/source_reader.py,sha256=HWiw25sLVZ6s1GGpMl17uZ-o68HoxTEd3Z0__m1vsSE,520
27
27
  delphi_lsp/workspace.py,sha256=TU__SujItRlW8hB2kuYC7ZJx1WQVOj7frYxvo7rDqeI,2137
28
28
  delphi_lsp/writer.py,sha256=j-cnyiHFNOTFECL7Ehm-m43ye7ev7qFeCCscFMjrAOY,2503
29
- python_delphi_lsp-2.0.1.dist-info/licenses/LICENSE,sha256=-rPda9qyJvHAhjCx3ZF-Efy07F4eAg4sFvg6ChOGPoU,16726
30
- python_delphi_lsp-2.0.1.dist-info/METADATA,sha256=4CthXJEauAQJm7-BDm1iDnMdj9-Eovu-py69_ALNiPY,14621
31
- python_delphi_lsp-2.0.1.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
32
- python_delphi_lsp-2.0.1.dist-info/entry_points.txt,sha256=rh_yGH7diowge_acQcST87A-ewH1QezEsAYC8T5ineY,103
33
- python_delphi_lsp-2.0.1.dist-info/top_level.txt,sha256=0Zp0sjw5Qtic3EQX9zNeegURga8NuXk6c2e9PN-3OTs,11
34
- python_delphi_lsp-2.0.1.dist-info/RECORD,,
29
+ python_delphi_lsp-2.0.3.dist-info/licenses/LICENSE,sha256=-rPda9qyJvHAhjCx3ZF-Efy07F4eAg4sFvg6ChOGPoU,16726
30
+ python_delphi_lsp-2.0.3.dist-info/METADATA,sha256=Dw8iQ0nH5GwHL2eIbYPEMXpVcoMp0Mc0tkGltO7iUMo,14942
31
+ python_delphi_lsp-2.0.3.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
32
+ python_delphi_lsp-2.0.3.dist-info/entry_points.txt,sha256=rh_yGH7diowge_acQcST87A-ewH1QezEsAYC8T5ineY,103
33
+ python_delphi_lsp-2.0.3.dist-info/top_level.txt,sha256=0Zp0sjw5Qtic3EQX9zNeegURga8NuXk6c2e9PN-3OTs,11
34
+ python_delphi_lsp-2.0.3.dist-info/RECORD,,