sliceagent 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.
- sliceagent/__init__.py +3 -0
- sliceagent/__main__.py +6 -0
- sliceagent/access.py +93 -0
- sliceagent/agents.py +173 -0
- sliceagent/background_review.py +146 -0
- sliceagent/binsniff.py +89 -0
- sliceagent/cli.py +890 -0
- sliceagent/clock.py +32 -0
- sliceagent/code_grep.py +329 -0
- sliceagent/code_index.py +417 -0
- sliceagent/config.py +240 -0
- sliceagent/context_overflow.py +227 -0
- sliceagent/envspec.py +129 -0
- sliceagent/errors.py +167 -0
- sliceagent/events.py +96 -0
- sliceagent/finding_types.py +70 -0
- sliceagent/flags.py +63 -0
- sliceagent/fuzzy.py +135 -0
- sliceagent/guardrails.py +438 -0
- sliceagent/guidance.py +69 -0
- sliceagent/hippocampus.py +581 -0
- sliceagent/hooks.py +334 -0
- sliceagent/interfaces.py +144 -0
- sliceagent/llm.py +695 -0
- sliceagent/loop.py +548 -0
- sliceagent/mcp_client.py +255 -0
- sliceagent/mcp_security.py +77 -0
- sliceagent/memory.py +428 -0
- sliceagent/metrics.py +103 -0
- sliceagent/model_catalog.py +124 -0
- sliceagent/monitor.py +615 -0
- sliceagent/neocortex.py +436 -0
- sliceagent/onboarding.py +323 -0
- sliceagent/oracle.py +36 -0
- sliceagent/pagetable.py +255 -0
- sliceagent/pfc.py +449 -0
- sliceagent/plugins.py +127 -0
- sliceagent/policy.py +234 -0
- sliceagent/procman.py +187 -0
- sliceagent/prompt.py +239 -0
- sliceagent/records.py +108 -0
- sliceagent/recovery.py +119 -0
- sliceagent/regions.py +678 -0
- sliceagent/registry.py +128 -0
- sliceagent/retriever.py +19 -0
- sliceagent/safety.py +332 -0
- sliceagent/sandbox.py +143 -0
- sliceagent/scheduler.py +92 -0
- sliceagent/search_index.py +289 -0
- sliceagent/seed.py +465 -0
- sliceagent/sensory_cortex.py +500 -0
- sliceagent/session.py +222 -0
- sliceagent/skill_provenance.py +71 -0
- sliceagent/skill_usage.py +123 -0
- sliceagent/skills.py +209 -0
- sliceagent/subagent.py +332 -0
- sliceagent/subdir_hints.py +222 -0
- sliceagent/swap.py +182 -0
- sliceagent/taskstate.py +57 -0
- sliceagent/telemetry.py +59 -0
- sliceagent/terminal.py +240 -0
- sliceagent/text_utils.py +56 -0
- sliceagent/tool_summary.py +93 -0
- sliceagent/tools.py +1194 -0
- sliceagent/tui.py +1377 -0
- sliceagent/web.py +354 -0
- sliceagent-0.1.0.dist-info/METADATA +262 -0
- sliceagent-0.1.0.dist-info/RECORD +71 -0
- sliceagent-0.1.0.dist-info/WHEEL +4 -0
- sliceagent-0.1.0.dist-info/entry_points.txt +2 -0
- sliceagent-0.1.0.dist-info/licenses/LICENSE +21 -0
sliceagent/clock.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Clock abstraction.
|
|
2
|
+
|
|
3
|
+
Wraps wall-clock time behind a tiny interface so timed logic (cron) is DETERMINISTICALLY testable:
|
|
4
|
+
SystemClock in production, FakeClock in tests. Anything that needs "now" takes a clock instead of
|
|
5
|
+
calling time.time() directly.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SystemClock:
|
|
13
|
+
"""Real wall-clock time (epoch seconds)."""
|
|
14
|
+
|
|
15
|
+
def now(self) -> float:
|
|
16
|
+
return time.time()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class FakeClock:
|
|
20
|
+
"""Controllable clock for tests — `now()` returns a fixed value you `advance()` explicitly."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, t: float = 0.0):
|
|
23
|
+
self._t = float(t)
|
|
24
|
+
|
|
25
|
+
def now(self) -> float:
|
|
26
|
+
return self._t
|
|
27
|
+
|
|
28
|
+
def advance(self, seconds: float) -> None:
|
|
29
|
+
self._t += float(seconds)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
SYSTEM_CLOCK = SystemClock()
|
sliceagent/code_grep.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""Guarded `grep` tool — ripgrep pagination + a consecutive-search guard.
|
|
2
|
+
|
|
3
|
+
The single discovery-on-demand seam (W7 deleted `code_index.snippets`; the model now
|
|
4
|
+
greps for content instead). Two mechanisms:
|
|
5
|
+
|
|
6
|
+
- Grep pagination: run ripgrep, then slice the result lines by offset/limit and
|
|
7
|
+
append an explicit "[truncated; use offset=N to see more]" notice when more
|
|
8
|
+
remain.
|
|
9
|
+
- Consecutive-search guard (the `{last_key, consecutive}` tracker): the 4th
|
|
10
|
+
identical-in-a-row call returns a BLOCKED message instead of re-running
|
|
11
|
+
the same search forever. The key INCLUDES offset, so paging through truncated results
|
|
12
|
+
(a *different* offset each call) never trips the guard.
|
|
13
|
+
|
|
14
|
+
Moat note: GREP_GUARD is keyed by host identity (not a transcript). Every call sources
|
|
15
|
+
live from the workspace via ripgrep; no growing message history is assumed.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import shutil
|
|
20
|
+
import subprocess
|
|
21
|
+
import threading
|
|
22
|
+
|
|
23
|
+
from .access import FileAccess
|
|
24
|
+
from .registry import ToolEntry, ToolText
|
|
25
|
+
|
|
26
|
+
# {host_id: {"last_key": tuple | None, "consecutive": int}} — module-level, per-host.
|
|
27
|
+
# A per-task tracker shape. Not a transcript: a tiny durable counter.
|
|
28
|
+
GREP_GUARD: dict = {}
|
|
29
|
+
_GREP_LOCK = threading.Lock() # parallel explorers share GREP_GUARD; serialize the check-then-update
|
|
30
|
+
|
|
31
|
+
_BLOCK_AFTER = 4 # 4th identical-in-a-row call is blocked (count>=4)
|
|
32
|
+
_DEFAULT_LIMIT = 50
|
|
33
|
+
_RG_MAX_FILESIZE = "300K"
|
|
34
|
+
_RG_MAX_COLUMNS = "400"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_GREP_SCHEMA = {
|
|
38
|
+
"type": "function",
|
|
39
|
+
"function": {
|
|
40
|
+
"name": "grep",
|
|
41
|
+
"description": (
|
|
42
|
+
"Search file CONTENTS for a regex pattern (ripgrep) under the workspace. `output_mode` shapes the "
|
|
43
|
+
"result: 'content' (default — line-numbered file:line:text matches), 'files_with_matches' (just the "
|
|
44
|
+
"matching file paths, newest-modified first), or 'count' (per-file match counts) — use the latter "
|
|
45
|
+
"two to locate code cheaply before reading. Results paginate via offset/limit. Prefer this over "
|
|
46
|
+
"reading whole files or bash grep."
|
|
47
|
+
),
|
|
48
|
+
"parameters": {
|
|
49
|
+
"type": "object",
|
|
50
|
+
"properties": {
|
|
51
|
+
"pattern": {"type": "string", "description": "Regex pattern to search for."},
|
|
52
|
+
"path": {"type": "string", "description": "Subdirectory to search under (default: workspace root)."},
|
|
53
|
+
"glob": {"type": "string", "description": "Optional file glob filter, e.g. '*.py' or '*.{ts,tsx}'."},
|
|
54
|
+
"type": {"type": "string", "description": "Optional ripgrep file type, e.g. 'py', 'js', 'rust'."},
|
|
55
|
+
"output_mode": {"type": "string", "enum": ["content", "files_with_matches", "count"],
|
|
56
|
+
"description": "content (default) | files_with_matches | count."},
|
|
57
|
+
"context": {"type": "integer", "description": "Lines of context around each match (content mode; like rg -C)."},
|
|
58
|
+
"offset": {"type": "integer", "description": "Number of leading result lines to skip (default 0)."},
|
|
59
|
+
"limit": {"type": "integer", "description": "Max result lines to return (default 50)."},
|
|
60
|
+
},
|
|
61
|
+
"required": ["pattern"],
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _norm_int(value, default: int) -> int:
|
|
68
|
+
"""Coerce a model-supplied arg to a non-negative int, falling back to default."""
|
|
69
|
+
try:
|
|
70
|
+
n = int(value)
|
|
71
|
+
except (TypeError, ValueError):
|
|
72
|
+
return default
|
|
73
|
+
return n if n >= 0 else default
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _bump_guard(host_id, key) -> int:
|
|
77
|
+
"""Update the consecutive-identical counter for this host; return the run length. Locked: parallel
|
|
78
|
+
explorers share GREP_GUARD, so the check-then-clear and the counter update must be atomic (else a
|
|
79
|
+
concurrent clear() blows away another thread's run length)."""
|
|
80
|
+
with _GREP_LOCK:
|
|
81
|
+
if host_id not in GREP_GUARD and len(GREP_GUARD) > 256:
|
|
82
|
+
GREP_GUARD.clear() # bound the per-host map (no host-teardown hook + id() reuse) — resetting the
|
|
83
|
+
# consecutive counter is harmless (worst case: one missed back-to-back warning)
|
|
84
|
+
data = GREP_GUARD.setdefault(host_id, {"last_key": None, "consecutive": 0})
|
|
85
|
+
if data["last_key"] == key:
|
|
86
|
+
data["consecutive"] += 1
|
|
87
|
+
else:
|
|
88
|
+
data["last_key"] = key
|
|
89
|
+
data["consecutive"] = 1
|
|
90
|
+
return data["consecutive"]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def make_grep_tool(host) -> ToolEntry:
|
|
94
|
+
"""Build the guarded grep ToolEntry bound to a host (LocalToolHost-like: root()/_resolve)."""
|
|
95
|
+
|
|
96
|
+
def handler(args: dict) -> str:
|
|
97
|
+
pattern = args.get("pattern") or ""
|
|
98
|
+
if not pattern:
|
|
99
|
+
return "grep: no pattern given — pass a 'pattern' to search for."
|
|
100
|
+
path = args.get("path") or "."
|
|
101
|
+
glob = args.get("glob") or ""
|
|
102
|
+
ftype = (args.get("type") or "").strip()
|
|
103
|
+
mode = (args.get("output_mode") or "content").strip().lower()
|
|
104
|
+
if mode not in ("content", "files_with_matches", "count"):
|
|
105
|
+
mode = "content"
|
|
106
|
+
context = _norm_int(args.get("context"), 0)
|
|
107
|
+
offset = _norm_int(args.get("offset"), 0)
|
|
108
|
+
limit = _norm_int(args.get("limit"), _DEFAULT_LIMIT)
|
|
109
|
+
if limit <= 0:
|
|
110
|
+
limit = _DEFAULT_LIMIT
|
|
111
|
+
|
|
112
|
+
# Consecutive-identical guard. Key includes offset so paging never trips it.
|
|
113
|
+
key = (pattern, path, glob, ftype, mode, context, limit, offset)
|
|
114
|
+
count = _bump_guard((id(host), threading.get_ident()), key) # per-THREAD: parallel explorers share the host but must not cross-contaminate the consecutive-grep counter
|
|
115
|
+
if count >= _BLOCK_AFTER:
|
|
116
|
+
return (
|
|
117
|
+
f"BLOCKED: you have run this exact grep {count} times in a row and the results "
|
|
118
|
+
"have NOT changed. STOP re-searching — use what you already have, or change the "
|
|
119
|
+
"pattern/path/glob/type/output_mode/offset."
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# Confine the search target under the workspace root (rejects escapes).
|
|
123
|
+
try:
|
|
124
|
+
target = host._resolve(path)
|
|
125
|
+
except (PermissionError, ValueError) as e:
|
|
126
|
+
return ToolText(f"Error: {e}", ok=False) # ok=False so a repeated boundary-escape is seen by the failure guardrail
|
|
127
|
+
|
|
128
|
+
rg = shutil.which("rg")
|
|
129
|
+
if not rg:
|
|
130
|
+
# Quiet, non-failing: degrade gracefully when ripgrep is absent.
|
|
131
|
+
return "grep: ripgrep (rg) is not available in this environment; no results."
|
|
132
|
+
|
|
133
|
+
cmd = [rg]
|
|
134
|
+
if mode == "files_with_matches":
|
|
135
|
+
cmd += ["-l", "--sortr", "modified"] # just the files, newest-changed first (cheap relevance)
|
|
136
|
+
elif mode == "count":
|
|
137
|
+
cmd += ["-c"]
|
|
138
|
+
else:
|
|
139
|
+
cmd += ["-n"]
|
|
140
|
+
if context > 0:
|
|
141
|
+
cmd += ["-C", str(context)]
|
|
142
|
+
cmd += ["--max-filesize", _RG_MAX_FILESIZE, "--max-columns", _RG_MAX_COLUMNS]
|
|
143
|
+
if glob:
|
|
144
|
+
cmd += ["--glob", glob]
|
|
145
|
+
if ftype:
|
|
146
|
+
cmd += ["--type", ftype]
|
|
147
|
+
cmd += ["--", pattern, target]
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
proc = subprocess.run(
|
|
151
|
+
cmd, capture_output=True, text=True, cwd=host.root(), timeout=30
|
|
152
|
+
)
|
|
153
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
154
|
+
return f"grep: search failed ({e}); no results."
|
|
155
|
+
|
|
156
|
+
# rg exit codes: 0 = matches, 1 = no matches (not an error), 2 = real error.
|
|
157
|
+
if proc.returncode == 1:
|
|
158
|
+
return "grep: no matches found."
|
|
159
|
+
if proc.returncode not in (0, 1):
|
|
160
|
+
err = (proc.stderr or "").strip()
|
|
161
|
+
return f"grep: no matches found.{(' (' + err + ')') if err else ''}"
|
|
162
|
+
|
|
163
|
+
lines = [ln for ln in proc.stdout.splitlines() if ln]
|
|
164
|
+
if not lines:
|
|
165
|
+
return "grep: no matches found."
|
|
166
|
+
|
|
167
|
+
total = len(lines)
|
|
168
|
+
window = lines[offset:offset + limit]
|
|
169
|
+
if not window:
|
|
170
|
+
return f"grep: no results at offset={offset} ({total} total matches)."
|
|
171
|
+
|
|
172
|
+
body = "\n".join(window)
|
|
173
|
+
if offset + limit < total:
|
|
174
|
+
next_offset = offset + limit
|
|
175
|
+
body += f"\n\n[truncated; use offset={next_offset} to see more]"
|
|
176
|
+
return body
|
|
177
|
+
|
|
178
|
+
return ToolEntry(
|
|
179
|
+
name="grep",
|
|
180
|
+
schema=_GREP_SCHEMA,
|
|
181
|
+
handler=handler,
|
|
182
|
+
accesses=lambda args: [FileAccess("search", args.get("path") or ".", recursive=True)],
|
|
183
|
+
source="builtin",
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ── glob: find files by NAME (the discovery companion to grep's find-by-CONTENT) ──────────────────────
|
|
188
|
+
_GLOB_DEFAULT_LIMIT = 100
|
|
189
|
+
_GLOB_IGNORE_DIRS = {".git", ".venv", "venv", "node_modules", "__pycache__", ".ruff_cache", ".pytest_cache",
|
|
190
|
+
".next", ".turbo", ".parcel-cache", ".nuxt", ".svelte-kit", ".output", "dist", "build"}
|
|
191
|
+
|
|
192
|
+
_GLOB_SCHEMA = {
|
|
193
|
+
"type": "function",
|
|
194
|
+
"function": {
|
|
195
|
+
"name": "glob",
|
|
196
|
+
"description": (
|
|
197
|
+
"Find files AND directories by NAME pattern under the workspace (for CONTENTS use grep). Matching "
|
|
198
|
+
"folders (e.g. a 'hunter/' project dir) are returned too, listed first. Supports glob wildcards incl. "
|
|
199
|
+
"brace sets, e.g. '*.py', 'src/**/*.{ts,tsx}', '*hunter*'. Paths most-recently-modified first, capped. "
|
|
200
|
+
"Use to locate a file or project folder before opening it."
|
|
201
|
+
),
|
|
202
|
+
"parameters": {
|
|
203
|
+
"type": "object",
|
|
204
|
+
"properties": {
|
|
205
|
+
"pattern": {"type": "string", "description": "File-name glob, e.g. '*.py' or '**/*.{ts,tsx}'."},
|
|
206
|
+
"path": {"type": "string", "description": "Subdirectory to search under (default: workspace root)."},
|
|
207
|
+
"limit": {"type": "integer", "description": "Max paths to return (default 100)."},
|
|
208
|
+
},
|
|
209
|
+
"required": ["pattern"],
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _expand_braces(pat: str) -> list:
|
|
216
|
+
"""Expand ONE level of brace alternation ('*.{ts,tsx}' -> ['*.ts','*.tsx']); recurse for nested."""
|
|
217
|
+
import re as _re
|
|
218
|
+
m = _re.search(r"\{([^{}]*)\}", pat)
|
|
219
|
+
if not m:
|
|
220
|
+
return [pat]
|
|
221
|
+
pre, post = pat[:m.start()], pat[m.end():]
|
|
222
|
+
out: list = []
|
|
223
|
+
for opt in m.group(1).split(","):
|
|
224
|
+
out.extend(_expand_braces(pre + opt + post))
|
|
225
|
+
return out
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _glob_walk(root: str, pattern: str, cap: int) -> list:
|
|
229
|
+
"""ripgrep-free fallback: os.walk + brace-expanded fnmatch, newest-modified first."""
|
|
230
|
+
import fnmatch
|
|
231
|
+
import os as _os
|
|
232
|
+
pats = _expand_braces(pattern)
|
|
233
|
+
hits: list = []
|
|
234
|
+
for dp, dns, fns in _os.walk(root):
|
|
235
|
+
dns[:] = [d for d in dns if d not in _GLOB_IGNORE_DIRS]
|
|
236
|
+
for fn in fns:
|
|
237
|
+
full = _os.path.join(dp, fn)
|
|
238
|
+
rel = _os.path.relpath(full, root)
|
|
239
|
+
if any(fnmatch.fnmatch(rel, p) or fnmatch.fnmatch(fn, p) for p in pats):
|
|
240
|
+
hits.append(full)
|
|
241
|
+
if len(hits) >= cap * 4: # gather generously, then mtime-sort + cap
|
|
242
|
+
break
|
|
243
|
+
hits.sort(key=lambda f: -(_os.path.getmtime(f) if _os.path.exists(f) else 0.0))
|
|
244
|
+
return hits
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _dir_matches(root: str, pats: list, cap: int, maxdepth: int = 12) -> list:
|
|
248
|
+
"""Find DIRECTORIES whose name (or relpath) matches the pattern. `rg --files` lists FILES only, so a
|
|
249
|
+
project FOLDER named like the pattern (e.g. a 'hunter/' dir) is otherwise invisible to glob. BREADTH-
|
|
250
|
+
first so a shallow match (Desktop/hunter) is found before descending huge deep trees (Library/…), and
|
|
251
|
+
bounded by depth + a node budget so it can't run away on a big home directory."""
|
|
252
|
+
import fnmatch
|
|
253
|
+
import os as _os
|
|
254
|
+
from collections import deque
|
|
255
|
+
hits: list = []
|
|
256
|
+
q: deque = deque([(root, 0)])
|
|
257
|
+
budget = 40000
|
|
258
|
+
while q and budget > 0:
|
|
259
|
+
base, depth = q.popleft()
|
|
260
|
+
budget -= 1
|
|
261
|
+
try:
|
|
262
|
+
entries = list(_os.scandir(base))
|
|
263
|
+
except OSError:
|
|
264
|
+
continue
|
|
265
|
+
for e in entries:
|
|
266
|
+
try:
|
|
267
|
+
if not e.is_dir(follow_symlinks=False): # no symlink loops; dirs only
|
|
268
|
+
continue
|
|
269
|
+
except OSError:
|
|
270
|
+
continue
|
|
271
|
+
if e.name in _GLOB_IGNORE_DIRS:
|
|
272
|
+
continue
|
|
273
|
+
rel = _os.path.relpath(e.path, root)
|
|
274
|
+
if any(fnmatch.fnmatch(rel, p) or fnmatch.fnmatch(e.name, p) for p in pats):
|
|
275
|
+
hits.append(e.path + "/")
|
|
276
|
+
if len(hits) >= cap:
|
|
277
|
+
return hits
|
|
278
|
+
if depth + 1 < maxdepth:
|
|
279
|
+
q.append((e.path, depth + 1))
|
|
280
|
+
return hits
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def make_glob_tool(host) -> ToolEntry:
|
|
284
|
+
"""Build the file-name `glob` ToolEntry bound to a host (uses ripgrep --files, falls back to os.walk)."""
|
|
285
|
+
|
|
286
|
+
def handler(args: dict) -> str:
|
|
287
|
+
pattern = (args.get("pattern") or "").strip()
|
|
288
|
+
if not pattern:
|
|
289
|
+
return "glob: no pattern given — pass a 'pattern' like '*.py'."
|
|
290
|
+
path = args.get("path") or "."
|
|
291
|
+
limit = _norm_int(args.get("limit"), _GLOB_DEFAULT_LIMIT) or _GLOB_DEFAULT_LIMIT
|
|
292
|
+
try:
|
|
293
|
+
target = host._resolve(path)
|
|
294
|
+
except (PermissionError, ValueError) as e:
|
|
295
|
+
return ToolText(f"Error: {e}", ok=False)
|
|
296
|
+
|
|
297
|
+
rg = shutil.which("rg")
|
|
298
|
+
files: list = []
|
|
299
|
+
if rg:
|
|
300
|
+
cmd = [rg, "--files", "--sortr", "modified", "-g", pattern, target]
|
|
301
|
+
try:
|
|
302
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, cwd=host.root(), timeout=30)
|
|
303
|
+
if proc.returncode in (0, 1): # 0 = files, 1 = none (not an error)
|
|
304
|
+
files = [ln for ln in proc.stdout.splitlines() if ln]
|
|
305
|
+
except (OSError, subprocess.SubprocessError):
|
|
306
|
+
files = []
|
|
307
|
+
else:
|
|
308
|
+
files = _glob_walk(target, pattern, limit) # graceful degrade when rg is absent
|
|
309
|
+
|
|
310
|
+
# rg --files lists FILES only — so a DIRECTORY named like the pattern (a 'hunter/' project folder)
|
|
311
|
+
# would be invisible. Always add matching directories so "glob *hunter*" finds folders too; show
|
|
312
|
+
# them FIRST (a name search is usually after the folder, not files buried beneath it).
|
|
313
|
+
dirs = _dir_matches(target, _expand_braces(pattern), limit)
|
|
314
|
+
results = dirs + files
|
|
315
|
+
if not results:
|
|
316
|
+
return f"glob: nothing matches {pattern!r} (no files or directories)."
|
|
317
|
+
total = len(results)
|
|
318
|
+
body = "\n".join(results[:limit])
|
|
319
|
+
if total > limit:
|
|
320
|
+
body += f"\n\n[{total - limit} more not shown; narrow the pattern or path]"
|
|
321
|
+
return body
|
|
322
|
+
|
|
323
|
+
return ToolEntry(
|
|
324
|
+
name="glob",
|
|
325
|
+
schema=_GLOB_SCHEMA,
|
|
326
|
+
handler=handler,
|
|
327
|
+
accesses=lambda args: [FileAccess("search", args.get("path") or ".", recursive=True)],
|
|
328
|
+
source="builtin",
|
|
329
|
+
)
|