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.
- loom_code/__init__.py +22 -0
- loom_code/_post_commit.py +119 -0
- loom_code/agent.py +544 -0
- loom_code/approval.py +616 -0
- loom_code/browse/__init__.py +291 -0
- loom_code/browse/act.py +467 -0
- loom_code/browse/observe.py +249 -0
- loom_code/browse/session.py +96 -0
- loom_code/browse/verify.py +194 -0
- loom_code/checkpoint.py +283 -0
- loom_code/cli.py +495 -0
- loom_code/code_index.py +703 -0
- loom_code/compact.py +143 -0
- loom_code/consent.py +47 -0
- loom_code/credentials.py +527 -0
- loom_code/edit_tool.py +635 -0
- loom_code/extensions.py +522 -0
- loom_code/file_history.py +322 -0
- loom_code/file_tools.py +93 -0
- loom_code/git_hook.py +200 -0
- loom_code/grep_tool.py +430 -0
- loom_code/hooks.py +297 -0
- loom_code/loominit/__init__.py +23 -0
- loom_code/loominit/_ast_walk.py +429 -0
- loom_code/loominit/_files.py +284 -0
- loom_code/loominit/_graph.py +141 -0
- loom_code/loominit/_resolve.py +392 -0
- loom_code/loominit/_tests_map.py +108 -0
- loom_code/loominit/extractor.py +332 -0
- loom_code/loominit/repomap.py +225 -0
- loom_code/loominit/schema.py +242 -0
- loom_code/lsp_tools.py +396 -0
- loom_code/mcp_host.py +79 -0
- loom_code/operator.py +449 -0
- loom_code/paste.py +97 -0
- loom_code/paths.py +52 -0
- loom_code/permissions.py +177 -0
- loom_code/project.py +104 -0
- loom_code/prompts.py +451 -0
- loom_code/render.py +783 -0
- loom_code/repl.py +4080 -0
- loom_code/rules.py +267 -0
- loom_code/sandboxed_bash.py +176 -0
- loom_code/scribe.py +88 -0
- loom_code/skills/__init__.py +16 -0
- loom_code/skills/graphify/SKILL.md +97 -0
- loom_code/skills/graphify/tools.py +570 -0
- loom_code/trust.py +216 -0
- loom_code/turn.py +169 -0
- loom_code/web_fetch.py +370 -0
- loom_code/workers.py +758 -0
- loom_code/worktree.py +134 -0
- loom_code-0.1.1.dist-info/METADATA +224 -0
- loom_code-0.1.1.dist-info/RECORD +58 -0
- loom_code-0.1.1.dist-info/WHEEL +5 -0
- loom_code-0.1.1.dist-info/entry_points.txt +2 -0
- loom_code-0.1.1.dist-info/licenses/LICENSE +21 -0
- 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
|
+
]
|