turnloop 0.1.0__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.
- turnloop/__init__.py +3 -0
- turnloop/__main__.py +4 -0
- turnloop/agent/__init__.py +0 -0
- turnloop/agent/factory.py +175 -0
- turnloop/agent/headless.py +150 -0
- turnloop/agent/loop.py +396 -0
- turnloop/agent/subagent.py +182 -0
- turnloop/agent/system_prompt.py +263 -0
- turnloop/cli.py +202 -0
- turnloop/commands/__init__.py +0 -0
- turnloop/commands/dispatch.py +428 -0
- turnloop/commands/loader.py +140 -0
- turnloop/config.py +425 -0
- turnloop/context/__init__.py +0 -0
- turnloop/context/budget.py +74 -0
- turnloop/context/compaction.py +441 -0
- turnloop/context/memory.py +120 -0
- turnloop/core/__init__.py +0 -0
- turnloop/core/events.py +145 -0
- turnloop/core/ids.py +29 -0
- turnloop/core/messages.py +217 -0
- turnloop/core/tokens.py +104 -0
- turnloop/diagnostics.py +233 -0
- turnloop/errors.py +64 -0
- turnloop/experiments/__init__.py +0 -0
- turnloop/experiments/configs/bakeoff.yaml +50 -0
- turnloop/experiments/configs/ceiling-check.yaml +31 -0
- turnloop/experiments/configs/context-glm.yaml +59 -0
- turnloop/experiments/configs/context.yaml +65 -0
- turnloop/experiments/configs/glm-selfhosted.yaml +36 -0
- turnloop/experiments/configs/groq-free.yaml +28 -0
- turnloop/experiments/configs/hard-calibration.yaml +26 -0
- turnloop/experiments/configs/hard-glm.yaml +41 -0
- turnloop/experiments/configs/loop.yaml +34 -0
- turnloop/experiments/configs/nvidia-smoke.yaml +22 -0
- turnloop/experiments/configs/reliability.yaml +44 -0
- turnloop/experiments/configs/rewrite-calibration.yaml +24 -0
- turnloop/experiments/configs/smoke.yaml +22 -0
- turnloop/experiments/graders.py +209 -0
- turnloop/experiments/report.py +371 -0
- turnloop/experiments/runner.py +495 -0
- turnloop/experiments/suite/default.yaml +185 -0
- turnloop/experiments/suite/fixtures/bulky/module_1.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_2.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_3.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_4.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_5.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_6.py +672 -0
- turnloop/experiments/suite/fixtures/bulky/module_7.py +670 -0
- turnloop/experiments/suite/fixtures/bulky/module_8.py +670 -0
- turnloop/experiments/suite/fixtures/circular_import/models.py +11 -0
- turnloop/experiments/suite/fixtures/circular_import/services.py +15 -0
- turnloop/experiments/suite/fixtures/circular_import/test_cancel.py +12 -0
- turnloop/experiments/suite/fixtures/cli_flag/app.py +16 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/decode.py +7 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/encode.py +10 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/test_roundtrip.py +11 -0
- turnloop/experiments/suite/fixtures/cross_file_contract/test_wire_format.py +7 -0
- turnloop/experiments/suite/fixtures/empty/.keep +1 -0
- turnloop/experiments/suite/fixtures/failing_test/calc.py +14 -0
- turnloop/experiments/suite/fixtures/failing_test/test_calc.py +17 -0
- turnloop/experiments/suite/fixtures/generate_bulky.py +80 -0
- turnloop/experiments/suite/fixtures/grep_edit/ingest.py +5 -0
- turnloop/experiments/suite/fixtures/grep_edit/load.py +5 -0
- turnloop/experiments/suite/fixtures/grep_edit/transform.py +5 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/checkout.py +21 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/invoice.py +8 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/pricing.py +11 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/test_checkout.py +12 -0
- turnloop/experiments/suite/fixtures/misleading_traceback/test_invoice.py +6 -0
- turnloop/experiments/suite/fixtures/needle/inventory.py +18 -0
- turnloop/experiments/suite/fixtures/needle/pricing.py +17 -0
- turnloop/experiments/suite/fixtures/needle/shipping.py +13 -0
- turnloop/experiments/suite/fixtures/regression_trap/test_validators.py +14 -0
- turnloop/experiments/suite/fixtures/regression_trap/validators.py +23 -0
- turnloop/experiments/suite/fixtures/rename/billing.py +7 -0
- turnloop/experiments/suite/fixtures/rename/report.py +5 -0
- turnloop/experiments/suite/fixtures/rename/test_billing.py +11 -0
- turnloop/experiments/suite/fixtures/spec/parser.py +19 -0
- turnloop/experiments/suite/fixtures/spec/test_parser.py +28 -0
- turnloop/experiments/suite/fixtures/subtle_spec_edge/leaderboard.py +12 -0
- turnloop/experiments/suite/fixtures/subtle_spec_edge/test_leaderboard.py +28 -0
- turnloop/experiments/suite/fixtures/util/util.py +9 -0
- turnloop/hooks/__init__.py +0 -0
- turnloop/hooks/runner.py +221 -0
- turnloop/mcp/__init__.py +0 -0
- turnloop/mcp/adapter.py +109 -0
- turnloop/mcp/client.py +299 -0
- turnloop/permissions/__init__.py +0 -0
- turnloop/permissions/engine.py +211 -0
- turnloop/permissions/rules.py +325 -0
- turnloop/providers/__init__.py +0 -0
- turnloop/providers/anthropic.py +318 -0
- turnloop/providers/base.py +329 -0
- turnloop/providers/gemini.py +217 -0
- turnloop/providers/mock.py +226 -0
- turnloop/providers/openai_compat.py +357 -0
- turnloop/providers/pricing.py +148 -0
- turnloop/providers/registry.py +75 -0
- turnloop/providers/sse.py +114 -0
- turnloop/sessions/__init__.py +0 -0
- turnloop/sessions/models.py +139 -0
- turnloop/sessions/store.py +271 -0
- turnloop/tools/__init__.py +0 -0
- turnloop/tools/ask.py +133 -0
- turnloop/tools/base.py +286 -0
- turnloop/tools/bash.py +423 -0
- turnloop/tools/builtin.py +63 -0
- turnloop/tools/edit.py +238 -0
- turnloop/tools/glob.py +235 -0
- turnloop/tools/grep.py +314 -0
- turnloop/tools/read.py +151 -0
- turnloop/tools/runner.py +284 -0
- turnloop/tools/shell.py +222 -0
- turnloop/tools/skill.py +89 -0
- turnloop/tools/task.py +161 -0
- turnloop/tools/textio.py +87 -0
- turnloop/tools/todo.py +140 -0
- turnloop/tools/webfetch.py +235 -0
- turnloop/tools/write.py +97 -0
- turnloop/tui/__init__.py +0 -0
- turnloop/tui/app.py +330 -0
- turnloop/tui/app.tcss +84 -0
- turnloop/tui/bridge.py +142 -0
- turnloop/tui/widgets/__init__.py +0 -0
- turnloop/tui/widgets/permission.py +131 -0
- turnloop/tui/widgets/status.py +110 -0
- turnloop/tui/widgets/transcript.py +146 -0
- turnloop-0.1.0.dist-info/METADATA +916 -0
- turnloop-0.1.0.dist-info/RECORD +132 -0
- turnloop-0.1.0.dist-info/WHEEL +4 -0
- turnloop-0.1.0.dist-info/entry_points.txt +3 -0
turnloop/tools/edit.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Edit — exact-string replacement, applied atomically.
|
|
2
|
+
|
|
3
|
+
One tool with a list of edits rather than separate Edit and MultiEdit. Two
|
|
4
|
+
near-identical tool descriptions is a real cost on a 65k-token window, and the
|
|
5
|
+
single-element list is barely more typing for the model.
|
|
6
|
+
|
|
7
|
+
The behavior that earns its keep: edits are applied to an in-memory copy and the
|
|
8
|
+
file is written once, so a batch either lands completely or not at all. A partial
|
|
9
|
+
batch leaves a file in a state neither the model nor the user predicted, which is
|
|
10
|
+
much worse than a clean error.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import difflib
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, Field
|
|
18
|
+
|
|
19
|
+
from turnloop.tools.base import Tool, ToolContext, ToolOutput
|
|
20
|
+
from turnloop.tools.textio import (
|
|
21
|
+
detect_newline,
|
|
22
|
+
has_bom,
|
|
23
|
+
preserve_trailing_newline,
|
|
24
|
+
read_text,
|
|
25
|
+
write_text,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class EditOp(BaseModel):
|
|
30
|
+
old_string: str = Field(description="Exact text to find, including indentation.")
|
|
31
|
+
new_string: str = Field(description="Replacement text. Empty string deletes.")
|
|
32
|
+
replace_all: bool = Field(
|
|
33
|
+
default=False, description="Replace every occurrence instead of requiring uniqueness."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class EditArgs(BaseModel):
|
|
38
|
+
file_path: str = Field(description="Absolute or project-relative path to edit.")
|
|
39
|
+
edits: list[EditOp] = Field(
|
|
40
|
+
description="Edits applied in order. Use one element for a single change."
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class EditTool(Tool):
|
|
45
|
+
name = "Edit"
|
|
46
|
+
Args = EditArgs
|
|
47
|
+
read_only = False
|
|
48
|
+
parallel_safe = False
|
|
49
|
+
|
|
50
|
+
descriptions = {
|
|
51
|
+
"terse": """
|
|
52
|
+
Replace exact strings in a file. `edits` is a list of {old_string, new_string}.
|
|
53
|
+
Read the file first. `old_string` must be unique unless replace_all.
|
|
54
|
+
All edits apply together or none do.
|
|
55
|
+
""",
|
|
56
|
+
"normal": """
|
|
57
|
+
Edit a file by exact string replacement.
|
|
58
|
+
|
|
59
|
+
- Read the file first. Edits are rejected if the file was never read, or if it
|
|
60
|
+
changed on disk since.
|
|
61
|
+
- `old_string` must match the file exactly, including indentation and line
|
|
62
|
+
breaks. Copy it from Read output, minus the line-number prefix.
|
|
63
|
+
- `old_string` must be unique in the file, otherwise the edit is rejected with a
|
|
64
|
+
count — add surrounding context to disambiguate, or set `replace_all`.
|
|
65
|
+
- Pass several edits in one call to make a coherent change. They are applied in
|
|
66
|
+
order against the file, and written atomically: if any edit fails, none are
|
|
67
|
+
applied.
|
|
68
|
+
- To delete text, use an empty `new_string`.
|
|
69
|
+
""",
|
|
70
|
+
"verbose": """
|
|
71
|
+
Edit a file by exact string replacement.
|
|
72
|
+
|
|
73
|
+
Arguments:
|
|
74
|
+
- `file_path`: absolute, or relative to the working directory.
|
|
75
|
+
- `edits`: a list of `{old_string, new_string, replace_all}`. One element is the
|
|
76
|
+
common case; several are applied in order.
|
|
77
|
+
|
|
78
|
+
Matching rules:
|
|
79
|
+
- `old_string` must match the file byte-for-byte, including leading whitespace.
|
|
80
|
+
When copying from Read output, strip the line-number prefix (the number and the
|
|
81
|
+
following tab) — including it is the most common cause of a failed edit.
|
|
82
|
+
- `old_string` must appear exactly once. If it appears more than once the edit is
|
|
83
|
+
rejected and the match count is reported; extend `old_string` with surrounding
|
|
84
|
+
lines to make it unique, or set `replace_all: true` when every occurrence
|
|
85
|
+
really should change (renaming a local variable, for example).
|
|
86
|
+
- `old_string` and `new_string` must differ.
|
|
87
|
+
- An empty `new_string` deletes the matched text.
|
|
88
|
+
|
|
89
|
+
Application semantics:
|
|
90
|
+
- Edits apply sequentially to an in-memory copy, so a later edit can match text a
|
|
91
|
+
previous edit introduced.
|
|
92
|
+
- The write is atomic across the batch: if any edit fails to match, the file is
|
|
93
|
+
left untouched and an error explains which edit failed. A half-applied batch
|
|
94
|
+
would leave the file in a state neither of us intended.
|
|
95
|
+
- The file's newline style and trailing-newline convention are preserved, so a
|
|
96
|
+
one-line change does not produce a whole-file diff on a CRLF checkout.
|
|
97
|
+
""",
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async def run(self, args: EditArgs, ctx: ToolContext) -> ToolOutput:
|
|
101
|
+
path = ctx.resolve(args.file_path)
|
|
102
|
+
|
|
103
|
+
if ctx.readonly:
|
|
104
|
+
return ToolOutput.error("plan mode is read-only; Edit is not available")
|
|
105
|
+
if not args.edits:
|
|
106
|
+
return ToolOutput.error("no edits supplied")
|
|
107
|
+
if not ctx.within_allowed(path):
|
|
108
|
+
return ToolOutput.error(f"{path} is outside the project root ({ctx.root}).")
|
|
109
|
+
if not path.exists():
|
|
110
|
+
return ToolOutput.error(f"{path} does not exist. Use Write to create it.")
|
|
111
|
+
if not ctx.files.has_read(path):
|
|
112
|
+
return ToolOutput.error(
|
|
113
|
+
f"{path} has not been read in this session. Read it first — editing text you "
|
|
114
|
+
"have not seen is how unintended changes happen."
|
|
115
|
+
)
|
|
116
|
+
if ctx.files.is_stale(path):
|
|
117
|
+
return ToolOutput.error(
|
|
118
|
+
f"{path} changed on disk since you read it. Read it again, then retry."
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
original = read_text(path)
|
|
122
|
+
updated = original
|
|
123
|
+
applied: list[tuple[EditOp, int]] = []
|
|
124
|
+
|
|
125
|
+
for index, op in enumerate(args.edits):
|
|
126
|
+
if op.old_string == op.new_string:
|
|
127
|
+
return ToolOutput.error(
|
|
128
|
+
f"edit {index + 1}: old_string and new_string are identical"
|
|
129
|
+
)
|
|
130
|
+
if not op.old_string:
|
|
131
|
+
return ToolOutput.error(
|
|
132
|
+
f"edit {index + 1}: old_string is empty. Use Write to create content."
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
count = updated.count(op.old_string)
|
|
136
|
+
if count == 0:
|
|
137
|
+
return ToolOutput.error(_no_match_message(index, op, updated))
|
|
138
|
+
if count > 1 and not op.replace_all:
|
|
139
|
+
return ToolOutput.error(
|
|
140
|
+
f"edit {index + 1}: old_string appears {count} times. Add surrounding "
|
|
141
|
+
f"context to make it unique, or set replace_all: true.\n"
|
|
142
|
+
f"{_occurrence_context(updated, op.old_string)}"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
updated = (
|
|
146
|
+
updated.replace(op.old_string, op.new_string)
|
|
147
|
+
if op.replace_all
|
|
148
|
+
else updated.replace(op.old_string, op.new_string, 1)
|
|
149
|
+
)
|
|
150
|
+
applied.append((op, count))
|
|
151
|
+
|
|
152
|
+
if updated == original:
|
|
153
|
+
return ToolOutput.error("edits produced no change")
|
|
154
|
+
|
|
155
|
+
updated = preserve_trailing_newline(original, updated)
|
|
156
|
+
write_text(path, updated, newline=detect_newline(path), bom=has_bom(path))
|
|
157
|
+
ctx.files.record_write(path, updated)
|
|
158
|
+
|
|
159
|
+
diff = _unified_diff(original, updated, path.name)
|
|
160
|
+
replaced = sum(c if op.replace_all else 1 for op, c in applied)
|
|
161
|
+
return ToolOutput(
|
|
162
|
+
content=(
|
|
163
|
+
f"Applied {len(applied)} edit(s) to {path} "
|
|
164
|
+
f"({replaced} replacement(s)).\n\n{_diff_for_model(diff)}"
|
|
165
|
+
),
|
|
166
|
+
display=diff,
|
|
167
|
+
metrics={"edits": len(applied), "replacements": replaced},
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def summary(self, args: EditArgs) -> str: # type: ignore[override]
|
|
171
|
+
n = len(args.edits)
|
|
172
|
+
return f"Edit {args.file_path}" + (f" ({n} edits)" if n > 1 else "")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _no_match_message(index: int, op: EditOp, content: str) -> str:
|
|
176
|
+
"""Explain a failed match usefully.
|
|
177
|
+
|
|
178
|
+
Blank whitespace mismatches account for most failures, so name that
|
|
179
|
+
explicitly rather than making the model guess at re-reading the file.
|
|
180
|
+
"""
|
|
181
|
+
stripped = op.old_string.strip()
|
|
182
|
+
hint = ""
|
|
183
|
+
if stripped and stripped in content:
|
|
184
|
+
hint = (
|
|
185
|
+
" The text exists but with different surrounding whitespace — copy the exact "
|
|
186
|
+
"indentation from Read output."
|
|
187
|
+
)
|
|
188
|
+
else:
|
|
189
|
+
first_line = op.old_string.splitlines()[0].strip() if op.old_string.strip() else ""
|
|
190
|
+
if first_line and first_line in content:
|
|
191
|
+
hint = (
|
|
192
|
+
f" Its first line ({first_line[:60]!r}) is present, so the later lines differ. "
|
|
193
|
+
"Re-read the file and copy the current text."
|
|
194
|
+
)
|
|
195
|
+
else:
|
|
196
|
+
candidates = difflib.get_close_matches(
|
|
197
|
+
op.old_string.splitlines()[0] if op.old_string.splitlines() else op.old_string,
|
|
198
|
+
content.splitlines(),
|
|
199
|
+
n=2,
|
|
200
|
+
cutoff=0.7,
|
|
201
|
+
)
|
|
202
|
+
if candidates:
|
|
203
|
+
hint = " Closest lines in the file: " + " | ".join(c.strip()[:70] for c in candidates)
|
|
204
|
+
return f"edit {index + 1}: old_string not found in the file.{hint}"
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _occurrence_context(content: str, needle: str, limit: int = 3) -> str:
|
|
208
|
+
lines = content.splitlines()
|
|
209
|
+
first_needle_line = needle.splitlines()[0] if needle.splitlines() else needle
|
|
210
|
+
hits = [i + 1 for i, line in enumerate(lines) if first_needle_line in line][:limit]
|
|
211
|
+
if not hits:
|
|
212
|
+
return ""
|
|
213
|
+
return "Occurrences near lines: " + ", ".join(str(h) for h in hits)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _unified_diff(before: str, after: str, name: str) -> str:
|
|
217
|
+
diff = difflib.unified_diff(
|
|
218
|
+
before.splitlines(keepends=False),
|
|
219
|
+
after.splitlines(keepends=False),
|
|
220
|
+
fromfile=f"a/{name}",
|
|
221
|
+
tofile=f"b/{name}",
|
|
222
|
+
n=3,
|
|
223
|
+
lineterm="",
|
|
224
|
+
)
|
|
225
|
+
return "\n".join(diff)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _diff_for_model(diff: str, max_lines: int = 80) -> str:
|
|
229
|
+
"""Send the model a bounded diff.
|
|
230
|
+
|
|
231
|
+
It needs confirmation of what changed, not the whole patch — a 400-line diff
|
|
232
|
+
echoed back into context is pure waste when the file is one Read away.
|
|
233
|
+
"""
|
|
234
|
+
lines = diff.splitlines()
|
|
235
|
+
if len(lines) <= max_lines:
|
|
236
|
+
return diff
|
|
237
|
+
head = lines[:max_lines]
|
|
238
|
+
return "\n".join(head) + f"\n... [{len(lines) - max_lines} more diff lines]"
|
turnloop/tools/glob.py
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Glob — find files by name pattern.
|
|
2
|
+
|
|
3
|
+
Ignore handling delegates to `git ls-files` when the project is a repository.
|
|
4
|
+
Reimplementing .gitignore semantics (negations, directory-only patterns, nested
|
|
5
|
+
ignore files, precedence) is a genuinely hard problem that git has already
|
|
6
|
+
solved; falling back to a hardcoded skip list only when git is unavailable is
|
|
7
|
+
strictly better than getting it subtly wrong everywhere.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import subprocess
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, Field
|
|
17
|
+
|
|
18
|
+
from turnloop.permissions.rules import _glob_to_regex
|
|
19
|
+
from turnloop.tools.base import Tool, ToolContext, ToolOutput
|
|
20
|
+
|
|
21
|
+
MAX_RESULTS = 200
|
|
22
|
+
|
|
23
|
+
ALWAYS_SKIP = {
|
|
24
|
+
".git", "node_modules", "__pycache__", ".venv", "venv", ".mypy_cache",
|
|
25
|
+
".pytest_cache", ".ruff_cache", "dist", "build", ".next", "target",
|
|
26
|
+
".turnloop", ".idea", ".gradle", "vendor",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class GlobArgs(BaseModel):
|
|
31
|
+
pattern: str = Field(description="Glob pattern, e.g. '**/*.py' or 'src/**/test_*.py'.")
|
|
32
|
+
path: str | None = Field(default=None, description="Directory to search in. Defaults to cwd.")
|
|
33
|
+
limit: int | None = Field(default=None, description=f"Max results (default {MAX_RESULTS}).")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class GlobTool(Tool):
|
|
37
|
+
name = "Glob"
|
|
38
|
+
Args = GlobArgs
|
|
39
|
+
read_only = True
|
|
40
|
+
parallel_safe = True
|
|
41
|
+
|
|
42
|
+
descriptions = {
|
|
43
|
+
"terse": """
|
|
44
|
+
Find files by glob pattern (`**/*.py`). Returns paths, newest first.
|
|
45
|
+
Ignored files (.git, node_modules, build output) are skipped.
|
|
46
|
+
""",
|
|
47
|
+
"normal": """
|
|
48
|
+
Find files by name pattern.
|
|
49
|
+
|
|
50
|
+
- `pattern` supports `**` (any depth) and `*` (one path segment).
|
|
51
|
+
- Results are sorted by modification time, newest first — recently touched files
|
|
52
|
+
are usually the relevant ones.
|
|
53
|
+
- Files ignored by the repository's .gitignore are excluded, as are build and
|
|
54
|
+
dependency directories.
|
|
55
|
+
- Use this to locate files by name. Use Grep to search their contents.
|
|
56
|
+
""",
|
|
57
|
+
"verbose": """
|
|
58
|
+
Find files by name pattern.
|
|
59
|
+
|
|
60
|
+
Arguments:
|
|
61
|
+
- `pattern`: a glob. `**` matches any number of directories, `*` matches within
|
|
62
|
+
one path segment, `?` matches one character. Examples: `**/*.ts`,
|
|
63
|
+
`src/**/test_*.py`, `*.md`.
|
|
64
|
+
- `path`: directory to search under. Defaults to the working directory.
|
|
65
|
+
- `limit`: maximum results, default 200.
|
|
66
|
+
|
|
67
|
+
Behavior:
|
|
68
|
+
- Results are ordered by modification time, newest first, on the theory that the
|
|
69
|
+
file you want is one somebody touched recently.
|
|
70
|
+
- When the project is a git repository, .gitignore is honored by asking git
|
|
71
|
+
itself, so the ignore semantics match what the user sees. Otherwise a fixed
|
|
72
|
+
skip list is used (.git, node_modules, __pycache__, .venv, dist, build,
|
|
73
|
+
target, .next, vendor).
|
|
74
|
+
- Output is truncated at the limit with an explicit note. If you hit it, narrow
|
|
75
|
+
the pattern rather than raising the limit — a 200-path list is already more
|
|
76
|
+
than a turn should spend context on.
|
|
77
|
+
- This searches names only. To search file contents, use Grep.
|
|
78
|
+
""",
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async def run(self, args: GlobArgs, ctx: ToolContext) -> ToolOutput:
|
|
82
|
+
base = ctx.resolve(args.path) if args.path else ctx.cwd
|
|
83
|
+
if not base.is_dir():
|
|
84
|
+
return ToolOutput.error(f"{base} is not a directory")
|
|
85
|
+
if not ctx.within_allowed(base):
|
|
86
|
+
return ToolOutput.error(f"{base} is outside the project root ({ctx.root}).")
|
|
87
|
+
|
|
88
|
+
limit = args.limit or MAX_RESULTS
|
|
89
|
+
started = time.monotonic()
|
|
90
|
+
|
|
91
|
+
tracked = _git_tracked(base)
|
|
92
|
+
if tracked is not None:
|
|
93
|
+
matches = _match_against(tracked, args.pattern, base)
|
|
94
|
+
else:
|
|
95
|
+
matches = _walk_match(base, args.pattern)
|
|
96
|
+
|
|
97
|
+
matches.sort(key=lambda p: _mtime(p), reverse=True)
|
|
98
|
+
total = len(matches)
|
|
99
|
+
shown = matches[:limit]
|
|
100
|
+
|
|
101
|
+
if not shown:
|
|
102
|
+
return ToolOutput(
|
|
103
|
+
content=f"No files match {args.pattern!r} under {base}.",
|
|
104
|
+
metrics={"matches": 0, "elapsed_s": time.monotonic() - started},
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
rel = [_rel(p, ctx.cwd) for p in shown]
|
|
108
|
+
body = "\n".join(rel)
|
|
109
|
+
if total > limit:
|
|
110
|
+
body += f"\n\n[{total - limit} more matches not shown; narrow the pattern]"
|
|
111
|
+
|
|
112
|
+
return ToolOutput(
|
|
113
|
+
content=body,
|
|
114
|
+
display=f"Glob {args.pattern} → {total} file(s)",
|
|
115
|
+
metrics={"matches": total, "shown": len(shown), "used_git": tracked is not None},
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def summary(self, args: GlobArgs) -> str: # type: ignore[override]
|
|
119
|
+
return f"Glob {args.pattern}"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _git_tracked(base: Path) -> list[Path] | None:
|
|
123
|
+
"""Files git knows about, honoring .gitignore. None if not a repo.
|
|
124
|
+
|
|
125
|
+
Also None if `base` itself is inside a git-ignored tree: `git ls-files`
|
|
126
|
+
never descends into an ignored directory, so it reports zero entries there
|
|
127
|
+
regardless of what actually exists on disk. Treating that the same as "not
|
|
128
|
+
a repo" (i.e. falling back to `_walk_match`) is deliberate — the caller
|
|
129
|
+
can't tell "nothing here" from "git refuses to look here" any other way,
|
|
130
|
+
and the alternative (trusting an empty result) is exactly the bug this
|
|
131
|
+
exists to avoid: Glob silently reporting no matches for a directory full
|
|
132
|
+
of real files just because some ancestor is .gitignore'd.
|
|
133
|
+
"""
|
|
134
|
+
try:
|
|
135
|
+
proc = subprocess.run(
|
|
136
|
+
["git", "ls-files", "--cached", "--others", "--exclude-standard"],
|
|
137
|
+
cwd=base,
|
|
138
|
+
capture_output=True,
|
|
139
|
+
text=True,
|
|
140
|
+
timeout=20,
|
|
141
|
+
)
|
|
142
|
+
except (OSError, subprocess.SubprocessError):
|
|
143
|
+
return None
|
|
144
|
+
if proc.returncode != 0:
|
|
145
|
+
return None
|
|
146
|
+
out: list[Path] = []
|
|
147
|
+
for line in proc.stdout.splitlines():
|
|
148
|
+
if not line.strip():
|
|
149
|
+
continue
|
|
150
|
+
p = base / line
|
|
151
|
+
if p.is_file():
|
|
152
|
+
out.append(p)
|
|
153
|
+
if not out and _is_ignored(base):
|
|
154
|
+
return None
|
|
155
|
+
return out
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _is_ignored(path: Path) -> bool:
|
|
159
|
+
"""Whether git itself would refuse to track `path`.
|
|
160
|
+
|
|
161
|
+
Checked explicitly (rather than just "ls-files came back empty") because an
|
|
162
|
+
empty result also happens for a genuinely empty, non-ignored directory —
|
|
163
|
+
conflating the two would make Glob walk the filesystem and include files
|
|
164
|
+
that a normal, non-ignored empty directory would never have had, but worse,
|
|
165
|
+
it would also change nothing for the real failure mode this guards against.
|
|
166
|
+
The check is cheap and only runs on the already-rare empty-result path.
|
|
167
|
+
"""
|
|
168
|
+
try:
|
|
169
|
+
proc = subprocess.run(
|
|
170
|
+
["git", "check-ignore", "-q", str(path)],
|
|
171
|
+
cwd=path,
|
|
172
|
+
capture_output=True,
|
|
173
|
+
timeout=20,
|
|
174
|
+
)
|
|
175
|
+
except (OSError, subprocess.SubprocessError):
|
|
176
|
+
return False
|
|
177
|
+
return proc.returncode == 0
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _match_against(paths: list[Path], pattern: str, base: Path) -> list[Path]:
|
|
181
|
+
import re
|
|
182
|
+
|
|
183
|
+
regex = re.compile(_glob_to_regex(_normalize_pattern(pattern)))
|
|
184
|
+
out = []
|
|
185
|
+
for p in paths:
|
|
186
|
+
try:
|
|
187
|
+
rel = p.relative_to(base).as_posix()
|
|
188
|
+
except ValueError:
|
|
189
|
+
continue
|
|
190
|
+
if regex.fullmatch(rel):
|
|
191
|
+
out.append(p)
|
|
192
|
+
return out
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _walk_match(base: Path, pattern: str) -> list[Path]:
|
|
196
|
+
import os
|
|
197
|
+
import re
|
|
198
|
+
|
|
199
|
+
regex = re.compile(_glob_to_regex(_normalize_pattern(pattern)))
|
|
200
|
+
out: list[Path] = []
|
|
201
|
+
for dirpath, dirnames, filenames in os.walk(base):
|
|
202
|
+
dirnames[:] = [d for d in dirnames if d not in ALWAYS_SKIP and not d.startswith(".git")]
|
|
203
|
+
for name in filenames:
|
|
204
|
+
full = Path(dirpath) / name
|
|
205
|
+
rel = full.relative_to(base).as_posix()
|
|
206
|
+
if regex.fullmatch(rel):
|
|
207
|
+
out.append(full)
|
|
208
|
+
return out
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _normalize_pattern(pattern: str) -> str:
|
|
212
|
+
"""Make a bare pattern behave the way people expect.
|
|
213
|
+
|
|
214
|
+
`*.py` almost always means "python files anywhere here", not "python files in
|
|
215
|
+
exactly this directory". Anchoring it strictly produces empty results and a
|
|
216
|
+
confused model, so a leading `**/` is implied for patterns with no separator.
|
|
217
|
+
"""
|
|
218
|
+
p = pattern.replace("\\", "/").lstrip("./")
|
|
219
|
+
if "/" not in p:
|
|
220
|
+
return f"**/{p}"
|
|
221
|
+
return p
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _mtime(path: Path) -> float:
|
|
225
|
+
try:
|
|
226
|
+
return path.stat().st_mtime
|
|
227
|
+
except OSError:
|
|
228
|
+
return 0.0
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _rel(path: Path, base: Path) -> str:
|
|
232
|
+
try:
|
|
233
|
+
return path.resolve().relative_to(base.resolve()).as_posix()
|
|
234
|
+
except ValueError:
|
|
235
|
+
return str(path)
|