loom-code 0.1.1__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.
Files changed (58) hide show
  1. loom_code/__init__.py +22 -0
  2. loom_code/_post_commit.py +119 -0
  3. loom_code/agent.py +544 -0
  4. loom_code/approval.py +616 -0
  5. loom_code/browse/__init__.py +291 -0
  6. loom_code/browse/act.py +467 -0
  7. loom_code/browse/observe.py +249 -0
  8. loom_code/browse/session.py +96 -0
  9. loom_code/browse/verify.py +194 -0
  10. loom_code/checkpoint.py +283 -0
  11. loom_code/cli.py +495 -0
  12. loom_code/code_index.py +703 -0
  13. loom_code/compact.py +143 -0
  14. loom_code/consent.py +47 -0
  15. loom_code/credentials.py +527 -0
  16. loom_code/edit_tool.py +635 -0
  17. loom_code/extensions.py +522 -0
  18. loom_code/file_history.py +322 -0
  19. loom_code/file_tools.py +93 -0
  20. loom_code/git_hook.py +200 -0
  21. loom_code/grep_tool.py +430 -0
  22. loom_code/hooks.py +297 -0
  23. loom_code/loominit/__init__.py +23 -0
  24. loom_code/loominit/_ast_walk.py +429 -0
  25. loom_code/loominit/_files.py +284 -0
  26. loom_code/loominit/_graph.py +141 -0
  27. loom_code/loominit/_resolve.py +392 -0
  28. loom_code/loominit/_tests_map.py +108 -0
  29. loom_code/loominit/extractor.py +332 -0
  30. loom_code/loominit/repomap.py +225 -0
  31. loom_code/loominit/schema.py +242 -0
  32. loom_code/lsp_tools.py +396 -0
  33. loom_code/mcp_host.py +79 -0
  34. loom_code/operator.py +449 -0
  35. loom_code/paste.py +97 -0
  36. loom_code/paths.py +52 -0
  37. loom_code/permissions.py +177 -0
  38. loom_code/project.py +104 -0
  39. loom_code/prompts.py +451 -0
  40. loom_code/render.py +783 -0
  41. loom_code/repl.py +4080 -0
  42. loom_code/rules.py +267 -0
  43. loom_code/sandboxed_bash.py +176 -0
  44. loom_code/scribe.py +88 -0
  45. loom_code/skills/__init__.py +16 -0
  46. loom_code/skills/graphify/SKILL.md +97 -0
  47. loom_code/skills/graphify/tools.py +570 -0
  48. loom_code/trust.py +216 -0
  49. loom_code/turn.py +169 -0
  50. loom_code/web_fetch.py +370 -0
  51. loom_code/workers.py +758 -0
  52. loom_code/worktree.py +134 -0
  53. loom_code-0.1.1.dist-info/METADATA +224 -0
  54. loom_code-0.1.1.dist-info/RECORD +58 -0
  55. loom_code-0.1.1.dist-info/WHEEL +5 -0
  56. loom_code-0.1.1.dist-info/entry_points.txt +2 -0
  57. loom_code-0.1.1.dist-info/licenses/LICENSE +21 -0
  58. loom_code-0.1.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,242 @@
1
+ """Schema for ``.loom/index.json`` — the machine-readable index.
2
+
3
+ This file is the CONTRACT between every other loominit module. The
4
+ extractor produces it; the annotator consumes it; the staleness
5
+ pipeline diffs against it; the refresh pass re-emits it. Lock the
6
+ shape carefully — every field is hash-keyed where possible so the
7
+ diff-aware refresh in :mod:`refresh` can identify what changed.
8
+
9
+ We use Pydantic v2 because loomflow already pins it (``>=2.6``) —
10
+ no new dep. Models are immutable (``frozen=True``) to surface
11
+ accidental mutation as a TypeError; the refresh pass replaces, not
12
+ edits.
13
+
14
+ Versioning: ``LoomIndex.version`` is an integer. Bumping it
15
+ invalidates older ``index.json`` files on read. Bump it any time
16
+ the JSON shape changes incompatibly; for additive fields, the
17
+ ``model_config`` ``extra="ignore"`` policy keeps older readers
18
+ working against newer writers.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from datetime import datetime
24
+ from typing import Literal
25
+
26
+ from pydantic import BaseModel, ConfigDict, Field
27
+
28
+ # Bump on incompatible schema changes. The reader (``persistence.py``)
29
+ # refuses to load an index whose ``version`` differs from this — and
30
+ # the REPL nudges the user to ``/loominit rebuild``.
31
+ SCHEMA_VERSION = 1
32
+
33
+
34
+ class _Immutable(BaseModel):
35
+ """Shared base — every loominit model is immutable + ignores
36
+ unknown fields so newer writers and older readers stay compatible
37
+ as long as we only add fields."""
38
+
39
+ model_config = ConfigDict(frozen=True, extra="ignore")
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Files
44
+ # ---------------------------------------------------------------------------
45
+
46
+
47
+ class FileEntry(_Immutable):
48
+ """One file in the repo. ``path`` is repo-relative POSIX.
49
+
50
+ ``sha256`` is the content hash the staleness pipeline diffs
51
+ against. ``in_api_surface`` is true when the file is reachable
52
+ from a package's ``__init__.py`` — agents over-read internals,
53
+ so this lets the annotator default to API-level descriptions.
54
+ """
55
+
56
+ path: str
57
+ lang: Literal["python", "markdown", "toml", "yaml", "json", "other"]
58
+ size_bytes: int
59
+ lines: int
60
+ sha256: str
61
+ mtime: datetime
62
+ # Number of commits touching this file in the last 90 days. None
63
+ # if the repo is not a git checkout. Used as a heat hint when
64
+ # ranking which files to annotate first.
65
+ git_changes_90d: int | None
66
+ is_test: bool
67
+ in_api_surface: bool
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Symbols (top-level class / function / module-level constant)
72
+ # ---------------------------------------------------------------------------
73
+
74
+
75
+ class SymbolEntry(_Immutable):
76
+ """One symbol — class, function, method, or module-level constant.
77
+
78
+ ``id`` is ``<path>:<qualified_name>`` (POSIX). The qualified name
79
+ is dotted for nested classes/methods (``Repl.run``); module
80
+ constants use just the bare name.
81
+
82
+ ``signature`` is the verbatim Python source for the
83
+ ``def …`` / ``class …`` line (single-line, no body). Useful in
84
+ LOOM.md as ground-truth — the annotator can quote it.
85
+
86
+ ``pagerank`` is the symbol's centrality in the import+call graph.
87
+ The annotator picks the top-K by this score plus everything in
88
+ ``in_api_surface=True``.
89
+
90
+ ``tests`` lists test-file callers (``tests/foo.py:42``). Built by
91
+ grepping test directories for the symbol's bare name — exact
92
+ matches only; the agent can verify if false-positive worries it.
93
+ """
94
+
95
+ id: str # f"{path}:{qualified_name}"
96
+ name: str # bare name ("login")
97
+ qualified_name: str # dotted ("AuthManager.login")
98
+ kind: Literal["class", "function", "method", "constant"]
99
+ path: str
100
+ line: int
101
+ end_line: int
102
+ signature: str
103
+ docstring_first_line: str | None
104
+ decorators: list[str] = Field(default_factory=list)
105
+ is_public: bool # not _-prefixed; in __all__ if defined
106
+ in_api_surface: bool # reachable through package __init__.py
107
+ pagerank: float
108
+ n_callers: int
109
+ n_callees: int
110
+ tests: list[str] = Field(default_factory=list)
111
+
112
+
113
+ # ---------------------------------------------------------------------------
114
+ # Graph edges
115
+ # ---------------------------------------------------------------------------
116
+
117
+
118
+ class ImportEdge(_Immutable):
119
+ """An ``import`` or ``from X import Y`` statement.
120
+
121
+ ``from_path`` and ``to_module`` are dotted Python module paths
122
+ when resolvable, otherwise the literal string from the source.
123
+ Unresolvable imports (third-party deps, stdlib) are still
124
+ recorded — they're a useful tech-stack signal — but never
125
+ contribute to the call graph."""
126
+
127
+ from_path: str
128
+ to_module: str
129
+ line: int
130
+ resolved: bool # True if to_module maps to a file in this repo
131
+
132
+
133
+ class CallEdge(_Immutable):
134
+ """A function-call edge ``caller -> callee``.
135
+
136
+ Both are symbol IDs (``<path>:<qualified_name>``). We only record
137
+ edges where BOTH ends resolve to symbols we know about — calls
138
+ into stdlib / third-party are dropped (would dominate PageRank
139
+ noise). Line is the call-site line in ``caller``'s file."""
140
+
141
+ caller: str
142
+ callee: str
143
+ line: int
144
+
145
+
146
+ # ---------------------------------------------------------------------------
147
+ # Landmarks — decorators / entry points
148
+ # ---------------------------------------------------------------------------
149
+
150
+
151
+ class DecoratorLandmark(_Immutable):
152
+ """A "landmark" decorator the annotator treats specially —
153
+ ``@app.route``, ``@click.command``, ``@tool``, ``@step``,
154
+ ``@pytest.fixture``, ``@dataclass``, ``@property``. These mark
155
+ entry points, tool definitions, fixtures, etc., and the
156
+ annotator uses them to populate the Entry Points section without
157
+ needing the LLM to discover them.
158
+
159
+ ``decorator`` is the source-form name (``"@app.route"`` —
160
+ keep the @ to disambiguate from regular calls). ``target`` is
161
+ the symbol ID being decorated."""
162
+
163
+ decorator: str
164
+ target: str # symbol id
165
+ path: str
166
+ line: int
167
+
168
+
169
+ class EntryPoint(_Immutable):
170
+ """A user-facing entry to the program.
171
+
172
+ Sources we mine:
173
+
174
+ * pyproject.toml ``[project.scripts]`` — ``kind="pyproject_script"``
175
+ * ``if __name__ == "__main__":`` blocks — ``kind="main_block"``
176
+ * Decorators on the landmark allow-list (``@click.command``,
177
+ ``@app.route``, etc.) — ``kind="decorated"``
178
+ """
179
+
180
+ kind: Literal["pyproject_script", "main_block", "decorated"]
181
+ name: str # CLI name / route path / function name
182
+ path: str
183
+ line: int | None
184
+ callable_id: str | None # symbol id, when known
185
+
186
+
187
+ # ---------------------------------------------------------------------------
188
+ # Clusters — file groups the annotator treats as one subsystem
189
+ # ---------------------------------------------------------------------------
190
+
191
+
192
+ class Cluster(_Immutable):
193
+ """A subsystem — group of files the annotator describes together.
194
+
195
+ Clustering signal: (1) path-prefix grouping (``loom_code/loominit/*``
196
+ is one cluster) takes precedence; (2) within larger packages, an
197
+ import-graph community-detection pass splits further.
198
+
199
+ ``hash_bucket`` is a hash over the SORTED list of file sha256s
200
+ in the cluster. When a file changes, the bucket changes; the
201
+ surgical refresh in :mod:`refresh` re-annotates only clusters
202
+ whose bucket moved.
203
+ """
204
+
205
+ id: str # stable slug; used as the LOOM.md section anchor
206
+ title: str # human-readable ("Loominit indexer")
207
+ paths: list[str]
208
+ centroid_symbols: list[str] = Field(default_factory=list)
209
+ centrality: float # mean PageRank over symbols in the cluster
210
+ hash_bucket: str
211
+
212
+
213
+ # ---------------------------------------------------------------------------
214
+ # The top-level container
215
+ # ---------------------------------------------------------------------------
216
+
217
+
218
+ class LoomIndex(_Immutable):
219
+ """The whole ``.loom/index.json`` — produced by the structural
220
+ extractor, consumed by everything else.
221
+
222
+ Field ordering matches the conceptual layering: metadata first,
223
+ then files, then symbols, then graph edges, then landmarks /
224
+ clusters. Don't reorder — git diffs read more cleanly when the
225
+ JSON output preserves this shape across refreshes.
226
+ """
227
+
228
+ version: int = SCHEMA_VERSION
229
+ generated_at: datetime
230
+ repo_root: str # absolute path on the generating machine
231
+ git_commit: str | None # None if not a git repo
232
+ # Number of LLM calls the annotator made (recorded so /loominit
233
+ # status can report cost ballpark without re-reading workspace).
234
+ annotation_calls: int = 0
235
+
236
+ files: list[FileEntry] = Field(default_factory=list)
237
+ symbols: list[SymbolEntry] = Field(default_factory=list)
238
+ imports: list[ImportEdge] = Field(default_factory=list)
239
+ calls: list[CallEdge] = Field(default_factory=list)
240
+ decorators: list[DecoratorLandmark] = Field(default_factory=list)
241
+ entry_points: list[EntryPoint] = Field(default_factory=list)
242
+ clusters: list[Cluster] = Field(default_factory=list)
loom_code/lsp_tools.py ADDED
@@ -0,0 +1,396 @@
1
+ """LSP-backed navigation tools — go-to-definition, find-references, hover.
2
+
3
+ The agent's answer to "where is X defined / who calls X / what is X" WITHOUT
4
+ grep. grep finds the *string* ``login``; these resolve the *symbol* —
5
+ through imports, across files, respecting scope — the way an IDE's
6
+ "Go to Definition" does. Built on ``jedi`` (the pure-Python engine
7
+ behind most editors' Python intelligence): no language-server process,
8
+ no protocol, just in-process static analysis.
9
+
10
+ Three tools, all keyed by a bare or dotted symbol name (the agent
11
+ rarely knows exact line/columns, so we resolve the name across the
12
+ project first, then run jedi at that location):
13
+
14
+ * ``go_to_definition(symbol)`` — where it's defined (file:line + signature).
15
+ * ``find_references(symbol)`` — every place it's used (file:line list).
16
+ * ``hover(symbol)`` — its signature + docstring (the "what is this").
17
+
18
+ Python-only (jedi is Python-only), matching the rest of loom-code's v1
19
+ static-analysis surface (repomap, code index, AST walk). When jedi
20
+ isn't installed, or a symbol can't be resolved, the tool returns a
21
+ plain explanatory string and suggests grep — it never raises, so a
22
+ navigation miss never aborts a turn.
23
+
24
+ Why jedi over a real LSP server (pyright/pylsp): jedi is a single pure-
25
+ Python dependency, starts instantly (no server lifecycle), and delivers
26
+ >90% of the navigation value at a fraction of the weight. A real server
27
+ is a future option if multi-language support is needed.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from pathlib import Path
33
+ from typing import Any
34
+
35
+ from loomflow import tool
36
+ from loomflow.tools.registry import Tool
37
+
38
+ # Directories we never scan for symbol resolution — vendored / generated
39
+ # / VCS noise. Same spirit as the code index's skip set.
40
+ _SKIP_DIRS: frozenset[str] = frozenset(
41
+ {
42
+ ".git",
43
+ ".loom",
44
+ ".venv",
45
+ "venv",
46
+ "node_modules",
47
+ "__pycache__",
48
+ ".mypy_cache",
49
+ ".ruff_cache",
50
+ ".pytest_cache",
51
+ "dist",
52
+ "build",
53
+ ".tox",
54
+ "site-packages",
55
+ }
56
+ )
57
+
58
+ # Cap how many files we scan to resolve a bare symbol name to its
59
+ # definition site. A monorepo with thousands of files would make the
60
+ # resolve-by-name scan slow; the cap keeps the tool snappy. The agent
61
+ # can pass a more specific dotted name or a path hint if a symbol is
62
+ # missed in a huge tree.
63
+ _MAX_SCAN_FILES = 400
64
+
65
+ # Cap references returned — a heavily-used symbol could have hundreds of
66
+ # call sites; the agent wants the map, not the phone book.
67
+ _MAX_REFS = 40
68
+
69
+
70
+ def _iter_py_files(root: Path) -> list[Path]:
71
+ out: list[Path] = []
72
+ for p in root.rglob("*.py"):
73
+ if any(part in _SKIP_DIRS for part in p.parts):
74
+ continue
75
+ out.append(p)
76
+ if len(out) >= _MAX_SCAN_FILES:
77
+ break
78
+ return out
79
+
80
+
81
+ def _find_definition_site(
82
+ root: Path, symbol: str
83
+ ) -> tuple[Any, Path, int, int] | None:
84
+ """Resolve a bare/dotted ``symbol`` to its definition location by
85
+ scanning project files with jedi's name index.
86
+
87
+ Returns ``(jedi.Script, path, line, column)`` at the definition, or
88
+ None if not found. We match the LAST dotted segment against jedi's
89
+ definition names (so ``AuthManager.login`` matches a ``login``
90
+ method), preferring an exact qualified match when available.
91
+ """
92
+ import jedi # local import — optional dep, surfaced as a tool message
93
+
94
+ bare = symbol.rsplit(".", 1)[-1]
95
+ fallback: tuple[Any, Path, int, int] | None = None
96
+ for fpath in _iter_py_files(root):
97
+ try:
98
+ source = fpath.read_text(encoding="utf-8", errors="replace")
99
+ except OSError:
100
+ continue
101
+ try:
102
+ script = jedi.Script(code=source, path=str(fpath))
103
+ names = script.get_names(all_scopes=True, definitions=True)
104
+ except Exception:
105
+ continue
106
+ for n in names:
107
+ if n.name != bare:
108
+ continue
109
+ # Exact match on the qualified name wins immediately; a
110
+ # bare-name match is held as a fallback (first one found).
111
+ qual = _qualified(n)
112
+ if qual == symbol or n.name == symbol:
113
+ return script, fpath, n.line, n.column
114
+ if fallback is None:
115
+ fallback = (script, fpath, n.line, n.column)
116
+ return fallback
117
+
118
+
119
+ def _qualified(name: Any) -> str:
120
+ """Best-effort dotted name for a jedi Name (full_name when jedi
121
+ resolved it, else the bare name)."""
122
+ full = getattr(name, "full_name", None)
123
+ return str(full) if full else str(getattr(name, "name", ""))
124
+
125
+
126
+ def _rel(root: Path, p: Path | str | None) -> str:
127
+ if p is None:
128
+ return "<unknown>"
129
+ try:
130
+ return Path(p).resolve().relative_to(root).as_posix()
131
+ except (ValueError, OSError):
132
+ return str(p)
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # go_to_definition
137
+ # ---------------------------------------------------------------------------
138
+
139
+
140
+ def go_to_definition_tool(workdir: Path | str) -> Tool:
141
+ """Build the ``go_to_definition`` tool for ``workdir``.
142
+
143
+ Model-facing: ``go_to_definition(symbol)`` — ``symbol`` is a
144
+ function/class/method name (bare ``login`` or dotted
145
+ ``AuthManager.login``). Returns the definition's file:line +
146
+ signature so the agent can ``read`` it directly instead of
147
+ grepping. Python-only; falls back to a grep suggestion when jedi
148
+ is unavailable or the symbol can't be resolved.
149
+ """
150
+ root = Path(workdir).resolve()
151
+
152
+ async def go_to_definition(symbol: str) -> str:
153
+ symbol = str(symbol).strip()
154
+ if not symbol:
155
+ return "go_to_definition: empty symbol"
156
+ try:
157
+ import jedi # noqa: F401
158
+ except ImportError:
159
+ return (
160
+ "go_to_definition unavailable (jedi not installed). "
161
+ "Use grep to find the definition."
162
+ )
163
+ try:
164
+ found = _find_definition_site(root, symbol)
165
+ if found is None:
166
+ return (
167
+ f"go_to_definition: no Python definition found for "
168
+ f"'{symbol}'. Try grep, or a more specific name."
169
+ )
170
+ script, path, line, col = found
171
+ defs = script.goto(
172
+ line=line,
173
+ column=col,
174
+ follow_imports=True,
175
+ follow_builtin_imports=False,
176
+ )
177
+ if not defs:
178
+ # The name-index hit IS the definition site.
179
+ sig = _line_text(path, line)
180
+ return (
181
+ f"{_rel(root, path)}:{line} {sig}"
182
+ )
183
+ out: list[str] = []
184
+ for d in defs:
185
+ dpath = d.module_path
186
+ dline = d.line or line
187
+ sig = (d.description or "").strip()
188
+ loc = f"{_rel(root, dpath)}:{dline}"
189
+ out.append(f" {d.type} {d.name} ({loc}) {sig}")
190
+ return "\n".join(out)
191
+ except Exception as exc: # never abort a turn on a nav failure
192
+ return (
193
+ f"go_to_definition unavailable "
194
+ f"({type(exc).__name__}: {exc}). Use grep."
195
+ )
196
+
197
+ return tool(
198
+ name="go_to_definition",
199
+ description=(
200
+ "Jump to where a Python symbol is DEFINED — resolves "
201
+ "through imports + scope like an IDE's Go-to-Definition, "
202
+ "unlike grep which only matches the string. Args: symbol "
203
+ "(function/class/method name, bare 'login' or dotted "
204
+ "'AuthManager.login'). Returns the definition's file:line "
205
+ "+ signature to read next. Python only; use grep for "
206
+ "other languages."
207
+ ),
208
+ )(go_to_definition)
209
+
210
+
211
+ # ---------------------------------------------------------------------------
212
+ # find_references
213
+ # ---------------------------------------------------------------------------
214
+
215
+
216
+ def find_references_tool(workdir: Path | str) -> Tool:
217
+ """Build the ``find_references`` tool for ``workdir``.
218
+
219
+ Model-facing: ``find_references(symbol)`` — every place ``symbol``
220
+ is used across the project (file:line list), scope-aware via jedi.
221
+ The answer to "what breaks if I change this?" — far more precise
222
+ than grepping the bare name (which matches comments, strings, and
223
+ unrelated same-named locals).
224
+ """
225
+ root = Path(workdir).resolve()
226
+
227
+ async def find_references(symbol: str) -> str:
228
+ symbol = str(symbol).strip()
229
+ if not symbol:
230
+ return "find_references: empty symbol"
231
+ try:
232
+ import jedi # noqa: F401
233
+ except ImportError:
234
+ return (
235
+ "find_references unavailable (jedi not installed). "
236
+ "Use grep to find usages."
237
+ )
238
+ try:
239
+ found = _find_definition_site(root, symbol)
240
+ if found is None:
241
+ return (
242
+ f"find_references: no Python definition found for "
243
+ f"'{symbol}'. Try grep."
244
+ )
245
+ script, path, line, col = found
246
+ refs = script.get_references(
247
+ line=line, column=col, scope="project"
248
+ )
249
+ if not refs:
250
+ return f"find_references: no usages found for '{symbol}'"
251
+ seen: set[tuple[str, int]] = set()
252
+ lines: list[str] = []
253
+ for r in refs:
254
+ rel = _rel(root, r.module_path)
255
+ key = (rel, r.line or 0)
256
+ if key in seen:
257
+ continue
258
+ seen.add(key)
259
+ text = _line_text(
260
+ Path(r.module_path) if r.module_path else path,
261
+ r.line or 0,
262
+ )
263
+ lines.append(f" {rel}:{r.line} {text}")
264
+ if len(lines) >= _MAX_REFS:
265
+ lines.append(
266
+ f" … (+more; showing first {_MAX_REFS})"
267
+ )
268
+ break
269
+ return "\n".join(lines)
270
+ except Exception as exc:
271
+ return (
272
+ f"find_references unavailable "
273
+ f"({type(exc).__name__}: {exc}). Use grep."
274
+ )
275
+
276
+ return tool(
277
+ name="find_references",
278
+ description=(
279
+ "Find every place a Python symbol is USED across the "
280
+ "project — scope-aware (resolves through imports, ignores "
281
+ "same-named unrelated locals/strings/comments that grep "
282
+ "would falsely match). Args: symbol (function/class/method "
283
+ "name). Returns a file:line list of real usages — the "
284
+ "answer to 'what breaks if I change this?'. Python only."
285
+ ),
286
+ )(find_references)
287
+
288
+
289
+ # ---------------------------------------------------------------------------
290
+ # hover
291
+ # ---------------------------------------------------------------------------
292
+
293
+
294
+ def hover_tool(workdir: Path | str) -> Tool:
295
+ """Build the ``hover`` tool for ``workdir``.
296
+
297
+ Model-facing: ``hover(symbol)`` — the symbol's signature +
298
+ docstring (the IDE "what is this" tooltip), so the agent learns a
299
+ function's contract without reading its whole file.
300
+ """
301
+ root = Path(workdir).resolve()
302
+
303
+ async def hover(symbol: str) -> str:
304
+ symbol = str(symbol).strip()
305
+ if not symbol:
306
+ return "hover: empty symbol"
307
+ try:
308
+ import jedi # noqa: F401
309
+ except ImportError:
310
+ return (
311
+ "hover unavailable (jedi not installed). "
312
+ "Use read to inspect the symbol."
313
+ )
314
+ try:
315
+ found = _find_definition_site(root, symbol)
316
+ if found is None:
317
+ return (
318
+ f"hover: no Python definition found for '{symbol}'. "
319
+ "Use grep / read."
320
+ )
321
+ script, path, line, col = found
322
+ helps = script.help(line=line, column=col)
323
+ if not helps:
324
+ return f"hover: nothing to show for '{symbol}'"
325
+ parts: list[str] = []
326
+ for h in helps:
327
+ loc = f"{_rel(root, path)}:{line}"
328
+ header = f"{h.type} {h.name} ({loc})"
329
+ sigs = []
330
+ try:
331
+ sigs = [s.to_string() for s in h.get_signatures()]
332
+ except Exception:
333
+ sigs = []
334
+ doc = ""
335
+ try:
336
+ doc = (h.docstring() or "").strip()
337
+ except Exception:
338
+ doc = ""
339
+ block = header
340
+ if sigs:
341
+ block += "\n " + sigs[0]
342
+ if doc:
343
+ # jedi's docstring() prepends the call signature as
344
+ # the FIRST paragraph, then the real docstring. Drop
345
+ # that leading signature paragraph so we show the
346
+ # actual contract, not "verify_token(token)" twice.
347
+ paras = doc.split("\n\n", 1)
348
+ body = (
349
+ paras[1] if len(paras) == 2 else paras[0]
350
+ ).strip()
351
+ if body:
352
+ block += "\n " + body.replace("\n", "\n ")
353
+ parts.append(block)
354
+ return "\n\n".join(parts)
355
+ except Exception as exc:
356
+ return (
357
+ f"hover unavailable ({type(exc).__name__}: {exc}). "
358
+ "Use read."
359
+ )
360
+
361
+ return tool(
362
+ name="hover",
363
+ description=(
364
+ "Show a Python symbol's signature + docstring (the IDE "
365
+ "'what is this' tooltip) — learn a function's contract "
366
+ "without reading its whole file. Args: symbol "
367
+ "(function/class/method name). Python only; use read for "
368
+ "other languages or full bodies."
369
+ ),
370
+ )(hover)
371
+
372
+
373
+ def _line_text(path: Path | str, line: int) -> str:
374
+ """The stripped source text at ``path:line`` — a one-line preview
375
+ so reference/definition lists show what's there. Best-effort."""
376
+ if line <= 0:
377
+ return ""
378
+ try:
379
+ text = Path(path).read_text(encoding="utf-8", errors="replace")
380
+ except OSError:
381
+ return ""
382
+ lines = text.splitlines()
383
+ if 0 < line <= len(lines):
384
+ return lines[line - 1].strip()[:100]
385
+ return ""
386
+
387
+
388
+ def lsp_tools(workdir: Path | str) -> list[Tool]:
389
+ """All three navigation tools for ``workdir`` — the convenience
390
+ bundle the agent builder wires in one call (mirrors how the file
391
+ tools are grouped)."""
392
+ return [
393
+ go_to_definition_tool(workdir),
394
+ find_references_tool(workdir),
395
+ hover_tool(workdir),
396
+ ]