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
loom_code/grep_tool.py
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
"""Enhanced ``grep`` tool for loom-code agents.
|
|
2
|
+
|
|
3
|
+
Same role as ``loomflow.tools.grep_tool`` — find regex matches under a
|
|
4
|
+
working directory — but with structured output the agent can actually
|
|
5
|
+
USE without N follow-up reads:
|
|
6
|
+
|
|
7
|
+
* **Surrounding context** — ±N lines around each match so the agent
|
|
8
|
+
sees the match in context (loomflow's default is just the matching
|
|
9
|
+
line, which forces a separate ``read`` of every interesting hit).
|
|
10
|
+
* **Grouped by file** — all matches for one file in a single block
|
|
11
|
+
with the path as a header. Easier to scan than 50 ``path:line:``
|
|
12
|
+
prefixes.
|
|
13
|
+
* **Test-file collapsing** — hits in ``tests/`` / ``test_*.py`` /
|
|
14
|
+
``*_test.py`` collapse into a one-line "+N matches in test files"
|
|
15
|
+
summary by default. Keeps prod code in focus; agent opts in to
|
|
16
|
+
test matches with ``include_tests=True``.
|
|
17
|
+
* **Optional language filter** — ``type=("py", "ts")`` restricts to
|
|
18
|
+
those extensions.
|
|
19
|
+
|
|
20
|
+
Default behaviour is the enhanced form so the agent's default grep is
|
|
21
|
+
the good one. Pass ``raw=True`` for the old flat-line format if a
|
|
22
|
+
tight one-line-per-match shape is needed.
|
|
23
|
+
|
|
24
|
+
Why this lives in loom-code (not loomflow): loom-code is opinionated
|
|
25
|
+
about the SHAPE of grep output for coding-agent UX. The framework's
|
|
26
|
+
``grep_tool`` is a sensible generic; this wrapper adds the loom-code
|
|
27
|
+
ergonomics without forking the framework.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import re
|
|
34
|
+
import shutil
|
|
35
|
+
import subprocess
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
from loomflow import tool
|
|
40
|
+
from loomflow.tools.registry import Tool
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _as_int(value: Any, default: int) -> int:
|
|
44
|
+
"""Coerce a model-supplied value to int. The tool-call layer
|
|
45
|
+
often serialises ``context=2`` as the STRING ``"2"`` (and some
|
|
46
|
+
providers send floats), so a typed ``int`` param arrives as a
|
|
47
|
+
str and ``lineno - context`` crashes with 'int - str'. Coerce
|
|
48
|
+
leniently; fall back to ``default`` on anything unparseable."""
|
|
49
|
+
if isinstance(value, bool):
|
|
50
|
+
# bool is an int subclass — don't let True become 1 silently
|
|
51
|
+
# for a numeric arg; treat as default.
|
|
52
|
+
return default
|
|
53
|
+
if isinstance(value, int):
|
|
54
|
+
return value
|
|
55
|
+
try:
|
|
56
|
+
return int(str(value).strip())
|
|
57
|
+
except (TypeError, ValueError):
|
|
58
|
+
return default
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _as_bool(value: Any, default: bool = False) -> bool:
|
|
62
|
+
"""Coerce a model-supplied value to bool. Weak models send
|
|
63
|
+
``ignore_case=true`` as the STRING ``"true"`` (or ``"1"`` /
|
|
64
|
+
``"yes"``); a typed ``bool`` param then arrives truthy-non-empty
|
|
65
|
+
for ANY non-empty string including ``"false"``. Parse the
|
|
66
|
+
common truthy/falsy spellings explicitly."""
|
|
67
|
+
if isinstance(value, bool):
|
|
68
|
+
return value
|
|
69
|
+
if value is None:
|
|
70
|
+
return default
|
|
71
|
+
s = str(value).strip().lower()
|
|
72
|
+
if s in ("true", "1", "yes", "y", "on"):
|
|
73
|
+
return True
|
|
74
|
+
if s in ("false", "0", "no", "n", "off", ""):
|
|
75
|
+
return False
|
|
76
|
+
return default
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# Directories we never search — matches loomflow's grep_tool noise
|
|
80
|
+
# list so behaviour around big virtualenvs / build outputs is the
|
|
81
|
+
# same as the framework default. Keeps walk time bounded on real
|
|
82
|
+
# projects with .venv / node_modules / etc.
|
|
83
|
+
_NOISE_DIRS = frozenset({
|
|
84
|
+
".git", "node_modules", "__pycache__", ".venv", "venv",
|
|
85
|
+
".env", ".tox", "dist", "build", ".pytest_cache", ".ruff_cache",
|
|
86
|
+
".mypy_cache", "graphify-out", ".loom",
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
# Heuristics for "is this a test file?". Anything under a ``tests``
|
|
90
|
+
# folder, or named ``test_*.py`` / ``*_test.py`` / ``*.test.*``.
|
|
91
|
+
_TEST_DIR_NAMES = frozenset({"tests", "test", "__tests__"})
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _is_test_path(rel: Path) -> bool:
|
|
95
|
+
"""True if the relative path is a test file by directory or
|
|
96
|
+
filename convention."""
|
|
97
|
+
parts = set(rel.parts)
|
|
98
|
+
if parts & _TEST_DIR_NAMES:
|
|
99
|
+
return True
|
|
100
|
+
name = rel.name.lower()
|
|
101
|
+
if name.startswith("test_") or name.endswith("_test.py"):
|
|
102
|
+
return True
|
|
103
|
+
if ".test." in name or ".spec." in name:
|
|
104
|
+
return True
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _walk_files(
|
|
109
|
+
root: Path,
|
|
110
|
+
glob: str,
|
|
111
|
+
type_filter: tuple[str, ...] | None,
|
|
112
|
+
) -> list[Path]:
|
|
113
|
+
"""Walk ``root`` honouring ``_NOISE_DIRS`` + the user's glob +
|
|
114
|
+
optional extension allowlist. Returns absolute paths."""
|
|
115
|
+
out: list[Path] = []
|
|
116
|
+
for path in root.rglob(glob):
|
|
117
|
+
if not path.is_file():
|
|
118
|
+
continue
|
|
119
|
+
if any(p in _NOISE_DIRS for p in path.parts):
|
|
120
|
+
continue
|
|
121
|
+
if type_filter is not None:
|
|
122
|
+
suffix = path.suffix.lstrip(".").lower()
|
|
123
|
+
if suffix not in type_filter:
|
|
124
|
+
continue
|
|
125
|
+
out.append(path)
|
|
126
|
+
return out
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _rg_path() -> str | None:
|
|
130
|
+
"""Absolute path to the real ripgrep binary, or None if absent.
|
|
131
|
+
|
|
132
|
+
``shutil.which`` finds the executable on PATH — not any shell
|
|
133
|
+
function shim (subprocess never sees shell functions anyway)."""
|
|
134
|
+
return shutil.which("rg")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _collect_with_ripgrep(
|
|
138
|
+
target: Path,
|
|
139
|
+
pattern: str,
|
|
140
|
+
*,
|
|
141
|
+
ignore_case: bool,
|
|
142
|
+
glob: str,
|
|
143
|
+
type_filter: tuple[str, ...] | None,
|
|
144
|
+
max_files: int,
|
|
145
|
+
max_per_file: int,
|
|
146
|
+
) -> dict[Path, list[tuple[int, str]]] | None:
|
|
147
|
+
"""Fast path: ripgrep does the matching; we parse its --json stream.
|
|
148
|
+
|
|
149
|
+
Returns ``matches_by_file`` (same shape the Python walk produces), or
|
|
150
|
+
``None`` to signal "fall back to Python" — when rg is absent, the
|
|
151
|
+
pattern uses a feature rg's Rust regex rejects (lookahead /
|
|
152
|
+
backrefs → exit 2), or rg errors. Never raises, so the caller's
|
|
153
|
+
fallback stays a simple ``is None`` check.
|
|
154
|
+
|
|
155
|
+
rg respects .gitignore (correct for a code tool); we ALSO pass the
|
|
156
|
+
historical _NOISE_DIRS as --glob excludes so a non-git tree or an
|
|
157
|
+
un-ignored .venv stays quiet — a superset of the old walk, never a
|
|
158
|
+
regression."""
|
|
159
|
+
rg = _rg_path()
|
|
160
|
+
if rg is None:
|
|
161
|
+
return None
|
|
162
|
+
argv = [rg, "--json", "--no-messages"]
|
|
163
|
+
if ignore_case:
|
|
164
|
+
argv.append("--ignore-case")
|
|
165
|
+
if glob and glob != "*":
|
|
166
|
+
argv += ["--glob", glob]
|
|
167
|
+
for noise in _NOISE_DIRS:
|
|
168
|
+
argv += ["--glob", f"!**/{noise}/**"]
|
|
169
|
+
if type_filter:
|
|
170
|
+
# rg has no arbitrary-extension flag — express each as a glob.
|
|
171
|
+
for ext in type_filter:
|
|
172
|
+
argv += ["--glob", f"*.{ext}"]
|
|
173
|
+
argv += ["--regexp", pattern, str(target)]
|
|
174
|
+
try:
|
|
175
|
+
proc = subprocess.run(
|
|
176
|
+
argv, capture_output=True, text=True, timeout=30
|
|
177
|
+
)
|
|
178
|
+
except (OSError, subprocess.SubprocessError):
|
|
179
|
+
return None
|
|
180
|
+
# rg exit codes: 0 = matches, 1 = no matches (NOT an error),
|
|
181
|
+
# 2 = real error (bad/unsupported regex) → fall back to the Python
|
|
182
|
+
# engine, which may accept the pattern.
|
|
183
|
+
if proc.returncode == 2:
|
|
184
|
+
return None
|
|
185
|
+
matches_by_file: dict[Path, list[tuple[int, str]]] = {}
|
|
186
|
+
for raw_line in proc.stdout.splitlines():
|
|
187
|
+
if not raw_line:
|
|
188
|
+
continue
|
|
189
|
+
try:
|
|
190
|
+
evt = json.loads(raw_line)
|
|
191
|
+
except json.JSONDecodeError:
|
|
192
|
+
continue
|
|
193
|
+
if evt.get("type") != "match":
|
|
194
|
+
continue
|
|
195
|
+
data = evt["data"]
|
|
196
|
+
fpath = Path(data["path"]["text"])
|
|
197
|
+
lineno = int(data["line_number"])
|
|
198
|
+
text = str(data["lines"]["text"]).rstrip("\n")
|
|
199
|
+
bucket = matches_by_file.setdefault(fpath, [])
|
|
200
|
+
if len(bucket) < max_per_file:
|
|
201
|
+
bucket.append((lineno, text))
|
|
202
|
+
# Honour the file cap deterministically (rg emits in walk order).
|
|
203
|
+
if len(matches_by_file) > max_files:
|
|
204
|
+
keep = sorted(matches_by_file)[:max_files]
|
|
205
|
+
matches_by_file = {k: matches_by_file[k] for k in keep}
|
|
206
|
+
return matches_by_file
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _render_grouped(
|
|
210
|
+
matches_by_file: dict[Path, list[tuple[int, str]]],
|
|
211
|
+
file_lines: dict[Path, list[str]],
|
|
212
|
+
*,
|
|
213
|
+
context: int,
|
|
214
|
+
root: Path,
|
|
215
|
+
) -> str:
|
|
216
|
+
"""Render the structured per-file output with context lines."""
|
|
217
|
+
if not matches_by_file:
|
|
218
|
+
return "no matches"
|
|
219
|
+
sections: list[str] = []
|
|
220
|
+
for path in sorted(matches_by_file):
|
|
221
|
+
hits = matches_by_file[path]
|
|
222
|
+
lines = file_lines[path]
|
|
223
|
+
rel = path.relative_to(root)
|
|
224
|
+
sections.append(
|
|
225
|
+
f"─ {rel} ({len(hits)} match{'es' if len(hits) != 1 else ''}) "
|
|
226
|
+
+ "─" * max(0, 40 - len(str(rel)))
|
|
227
|
+
)
|
|
228
|
+
# For each hit, show context lines. If multiple hits are
|
|
229
|
+
# close together their context windows merge naturally —
|
|
230
|
+
# we DON'T deduplicate here because that'd hide hit
|
|
231
|
+
# boundaries; instead we render each hit's window. Agent
|
|
232
|
+
# gets a slight redundancy in exchange for clearer per-
|
|
233
|
+
# hit framing.
|
|
234
|
+
for lineno, _ in hits:
|
|
235
|
+
start = max(0, lineno - 1 - context)
|
|
236
|
+
end = min(len(lines), lineno + context)
|
|
237
|
+
for i in range(start, end):
|
|
238
|
+
marker = "▸ " if i + 1 == lineno else " "
|
|
239
|
+
sections.append(
|
|
240
|
+
f" {marker}{i + 1:4d} │ {lines[i].rstrip()}"
|
|
241
|
+
)
|
|
242
|
+
sections.append("") # blank line between hit windows
|
|
243
|
+
# Remove trailing blank for tidiness.
|
|
244
|
+
if sections and sections[-1] == "":
|
|
245
|
+
sections.pop()
|
|
246
|
+
return "\n".join(sections)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def enhanced_grep_tool(
|
|
250
|
+
workdir: Path | str,
|
|
251
|
+
*,
|
|
252
|
+
max_files_with_matches: int = 30,
|
|
253
|
+
max_matches_per_file: int = 10,
|
|
254
|
+
default_context: int = 2,
|
|
255
|
+
) -> Tool:
|
|
256
|
+
"""Build the loom-code grep tool. Sees the model with:
|
|
257
|
+
|
|
258
|
+
grep(pattern, path=".", glob="*",
|
|
259
|
+
ignore_case=False, context=2,
|
|
260
|
+
include_tests=False, raw=False,
|
|
261
|
+
type="")
|
|
262
|
+
|
|
263
|
+
``pattern`` is a Python regex. ``path`` is relative to the agent's
|
|
264
|
+
workdir. ``context`` is ±N lines around each match (default 2).
|
|
265
|
+
``include_tests=True`` un-collapses test-file hits. ``raw=True``
|
|
266
|
+
drops the grouped/contextual rendering and falls back to flat
|
|
267
|
+
``path:lineno: line`` lines (loomflow's classic shape) for
|
|
268
|
+
consumers that want one-line-per-match. ``type`` is a
|
|
269
|
+
comma-separated extension filter (e.g. ``"py,ts"``) — empty
|
|
270
|
+
means no filter.
|
|
271
|
+
"""
|
|
272
|
+
root = Path(workdir).resolve()
|
|
273
|
+
|
|
274
|
+
async def grep(
|
|
275
|
+
pattern: str,
|
|
276
|
+
path: str = ".",
|
|
277
|
+
glob: str = "*",
|
|
278
|
+
ignore_case: bool = False,
|
|
279
|
+
context: int = default_context,
|
|
280
|
+
include_tests: bool = False,
|
|
281
|
+
raw: bool = False,
|
|
282
|
+
type: str = "", # noqa: A002 — model-facing arg name; matches CLI ergonomics
|
|
283
|
+
) -> str:
|
|
284
|
+
"""Find regex matches under ``path`` and return grouped,
|
|
285
|
+
context-rich results. See module docstring for the full
|
|
286
|
+
contract."""
|
|
287
|
+
# Coerce model-supplied args defensively — the tool-call
|
|
288
|
+
# layer serialises typed params as strings ("2", "true"),
|
|
289
|
+
# which crashed the line-window math ('int - str') and
|
|
290
|
+
# made bool flags always-truthy. Same lenient coercion
|
|
291
|
+
# loomflow's plan_write does for weak-model serialisation.
|
|
292
|
+
context = _as_int(context, default_context)
|
|
293
|
+
ignore_case = _as_bool(ignore_case, default=False)
|
|
294
|
+
include_tests = _as_bool(include_tests, default=False)
|
|
295
|
+
raw = _as_bool(raw, default=False)
|
|
296
|
+
|
|
297
|
+
# Resolve and bounds-check ``path``. Must stay under root.
|
|
298
|
+
target = (root / path).resolve()
|
|
299
|
+
try:
|
|
300
|
+
target.relative_to(root)
|
|
301
|
+
except ValueError:
|
|
302
|
+
return f"grep: refusing to search outside workdir: {target}"
|
|
303
|
+
if not target.exists():
|
|
304
|
+
return f"grep: path not found: {path}"
|
|
305
|
+
|
|
306
|
+
# Pre-compile the pattern; surface regex errors to the agent
|
|
307
|
+
# so it can fix the call instead of getting empty output.
|
|
308
|
+
flags = re.IGNORECASE if ignore_case else 0
|
|
309
|
+
try:
|
|
310
|
+
regex = re.compile(pattern, flags)
|
|
311
|
+
except re.error as exc:
|
|
312
|
+
return f"grep: invalid regex {pattern!r}: {exc}"
|
|
313
|
+
|
|
314
|
+
# Normalise the type filter.
|
|
315
|
+
type_filter: tuple[str, ...] | None = None
|
|
316
|
+
if type:
|
|
317
|
+
type_filter = tuple(
|
|
318
|
+
t.strip().lower() for t in type.split(",") if t.strip()
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
# Collect raw per-file hits. FAST PATH: ripgrep (respects
|
|
322
|
+
# .gitignore, Rust-fast). FALLBACK: the pure-Python walk below,
|
|
323
|
+
# used when rg is absent or rejects the pattern (lookahead /
|
|
324
|
+
# backrefs) — so capability never regresses, only speed varies.
|
|
325
|
+
raw_hits: dict[Path, list[tuple[int, str]]] = {}
|
|
326
|
+
rg_result = _collect_with_ripgrep(
|
|
327
|
+
target,
|
|
328
|
+
pattern,
|
|
329
|
+
ignore_case=ignore_case,
|
|
330
|
+
glob=glob,
|
|
331
|
+
type_filter=type_filter,
|
|
332
|
+
max_files=max_files_with_matches * 4,
|
|
333
|
+
max_per_file=max_matches_per_file,
|
|
334
|
+
)
|
|
335
|
+
if rg_result is not None:
|
|
336
|
+
raw_hits = rg_result
|
|
337
|
+
else:
|
|
338
|
+
# Pure-Python walk: read every candidate file, regex each
|
|
339
|
+
# line. Identical regex dialect to the agent's `pattern`.
|
|
340
|
+
for fpath in _walk_files(target, glob, type_filter):
|
|
341
|
+
try:
|
|
342
|
+
text = fpath.read_text(
|
|
343
|
+
encoding="utf-8", errors="replace"
|
|
344
|
+
)
|
|
345
|
+
except OSError:
|
|
346
|
+
continue
|
|
347
|
+
hits: list[tuple[int, str]] = []
|
|
348
|
+
for i, line in enumerate(text.splitlines(), start=1):
|
|
349
|
+
if regex.search(line):
|
|
350
|
+
hits.append((i, line))
|
|
351
|
+
if len(hits) >= max_matches_per_file:
|
|
352
|
+
break
|
|
353
|
+
if hits:
|
|
354
|
+
raw_hits[fpath] = hits
|
|
355
|
+
|
|
356
|
+
# Shared post-process: apply the test-file collapse + file cap +
|
|
357
|
+
# build the context cache. Runs identically for both paths so rg
|
|
358
|
+
# and Python output are byte-for-byte the same.
|
|
359
|
+
matches_by_file: dict[Path, list[tuple[int, str]]] = {}
|
|
360
|
+
file_lines_cache: dict[Path, list[str]] = {}
|
|
361
|
+
test_file_count = 0
|
|
362
|
+
test_match_count = 0
|
|
363
|
+
for fpath in sorted(raw_hits):
|
|
364
|
+
hits = raw_hits[fpath]
|
|
365
|
+
rel = fpath.relative_to(root)
|
|
366
|
+
if (not include_tests) and _is_test_path(rel):
|
|
367
|
+
test_file_count += 1
|
|
368
|
+
test_match_count += len(hits)
|
|
369
|
+
continue
|
|
370
|
+
try:
|
|
371
|
+
lines = fpath.read_text(
|
|
372
|
+
encoding="utf-8", errors="replace"
|
|
373
|
+
).splitlines()
|
|
374
|
+
except OSError:
|
|
375
|
+
continue
|
|
376
|
+
matches_by_file[fpath] = hits
|
|
377
|
+
file_lines_cache[fpath] = lines
|
|
378
|
+
if len(matches_by_file) >= max_files_with_matches:
|
|
379
|
+
break
|
|
380
|
+
|
|
381
|
+
if raw:
|
|
382
|
+
# Old flat shape — loomflow's classic output. Kept as
|
|
383
|
+
# an escape hatch for one-line-per-match consumers.
|
|
384
|
+
out: list[str] = []
|
|
385
|
+
for fpath in sorted(matches_by_file):
|
|
386
|
+
rel = fpath.relative_to(root)
|
|
387
|
+
for lineno, line in matches_by_file[fpath]:
|
|
388
|
+
out.append(f"{rel}:{lineno}: {line}")
|
|
389
|
+
if test_match_count and not include_tests:
|
|
390
|
+
out.append(
|
|
391
|
+
f"... +{test_match_count} match(es) in "
|
|
392
|
+
f"{test_file_count} test file(s) "
|
|
393
|
+
"(pass include_tests=True to show)"
|
|
394
|
+
)
|
|
395
|
+
return "\n".join(out) if out else "no matches"
|
|
396
|
+
|
|
397
|
+
# Default rendering: grouped + with context.
|
|
398
|
+
body = _render_grouped(
|
|
399
|
+
matches_by_file,
|
|
400
|
+
file_lines_cache,
|
|
401
|
+
context=context,
|
|
402
|
+
root=root,
|
|
403
|
+
)
|
|
404
|
+
if test_match_count and not include_tests:
|
|
405
|
+
body += (
|
|
406
|
+
f"\n\n+ {test_match_count} match(es) in "
|
|
407
|
+
f"{test_file_count} test file(s) — "
|
|
408
|
+
"pass include_tests=True to show"
|
|
409
|
+
)
|
|
410
|
+
return body
|
|
411
|
+
|
|
412
|
+
# Use the @tool decorator pattern by promoting the closure
|
|
413
|
+
# into a Tool with a manually-built schema. We can't use the
|
|
414
|
+
# bare @tool decorator on a nested function because the
|
|
415
|
+
# decorator-derived description would lose the loom-code-
|
|
416
|
+
# specific guidance the agent needs to pick the right args.
|
|
417
|
+
return tool(
|
|
418
|
+
name="grep",
|
|
419
|
+
description=(
|
|
420
|
+
"Search file contents for a regex. Returns grouped "
|
|
421
|
+
"results: one block per file, with ±2 lines of "
|
|
422
|
+
"context around each hit. Test-file matches are "
|
|
423
|
+
"collapsed by default — pass include_tests=True to "
|
|
424
|
+
"show. Args: pattern (regex), path='.', glob='*', "
|
|
425
|
+
"ignore_case=False, context=2 (±N lines), "
|
|
426
|
+
"include_tests=False, raw=False (raw=True for "
|
|
427
|
+
"flat one-line-per-match output), type='' "
|
|
428
|
+
"(comma-separated extensions e.g. 'py,ts')."
|
|
429
|
+
),
|
|
430
|
+
)(grep)
|